diff --git a/.env.example b/.env.example index 0e2cb55..1375dd3 100644 --- a/.env.example +++ b/.env.example @@ -3,17 +3,13 @@ # # Usage : # cp .env.example .env -# Editer .env (gitignore) avec les valeurs reelles. +# docker compose up -d # -# Audience : -# Destine a l'auteur, au jury et aux contributeurs futurs. -# -# Modele de deploiement : -# Ce projet tourne sur serveur derriere un reverse proxy Traefik. Il n'y a -# pas de binding de ports hote : l'acces se fait uniquement via les FQDN -# configures ci-dessous et routes par Traefik (reseau admin_proxy). -# Les distinctions dev / staging / prod se font par FQDN distincts -# (ex : .dev.acadenice.fr vs .acadenice.fr) et par .env dedie. +# Ce template fonctionne EN LOCAL tel quel (valeurs dev) : la stack est joignable +# sur http://kiosk.localhost:8080 (borne) et http://admin.localhost:8080 (admin). +# Le deploiement derriere un reverse proxy (Traefik) se fait via l'overlay +# docker-compose.prod.yml + les variables du bloc "Deploiement prod" en fin de +# fichier. En prod : changer les mots de passe, APP_DEBUG=false, vrais FQDN. # # =================================================================== @@ -24,27 +20,30 @@ APP_ENV=dev # dev | staging | prod APP_DEBUG=true # true en dev, false en prod APP_TIMEZONE=Europe/Paris -# URL publique de la borne (Bloc 1), doit pointer vers le FQDN Traefik. -# Placeholder example.com (RFC 2606) - a remplacer par le FQDN reel. -APP_URL_KIOSK=https://kiosk.example.com +# Port hote publie par wakdo-web (acces local). Change si 8080 est pris. +HTTP_PORT=8080 -# URL publique du back-office + API (Bloc 2). -# Placeholder example.com (RFC 2606) - a remplacer par le FQDN reel. -APP_URL_ADMIN=https://admin.example.com +# Hostnames des deux vhosts Apache (ServerName). En local : *.localhost resout +# vers 127.0.0.1 nativement. En prod : les vrais FQDN (voir bloc prod en bas). +APP_HOST_KIOSK=kiosk.localhost +APP_HOST_ADMIN=admin.localhost + +# URLs publiques (consommees par l'app). En local = les hostnames sur HTTP_PORT. +APP_URL_KIOSK=http://kiosk.localhost:8080 +APP_URL_ADMIN=http://admin.localhost:8080 # =================================================================== # Base de donnees (MariaDB) # =================================================================== -# Valeurs ci-dessous = PLACEHOLDERS. Remplacer par des mots de passe forts. -# Pas accessible depuis l'exterieur : le service wakdo-db est sur le reseau -# interne uniquement, aucun port exposé a l'hote. +# Valeurs dev ci-dessous : OK en local. EN PROD : mots de passe forts. +# wakdo-db est sur le reseau interne, aucun port expose a l'hote. DB_HOST=wakdo-db # nom du service docker-compose DB_PORT=3306 DB_NAME=wakdo DB_USER=wakdo -DB_PASSWORD=change_me_strong_password -DB_ROOT_PASSWORD=change_me_root_password +DB_PASSWORD=wakdo_dev_password +DB_ROOT_PASSWORD=wakdo_dev_root_password # =================================================================== # Sessions @@ -58,13 +57,54 @@ SESSION_NAME=WAKDO_SID # nom du cookie (evite PHPSESSID) # Securite # =================================================================== -# Origine autorisee pour les requetes CORS de l'API. -# Doit correspondre exactement a APP_URL_KIOSK (pas de wildcard). -CORS_ALLOWED_ORIGIN=https://kiosk.example.com +# Origine autorisee pour les requetes CORS de l'API. Doit correspondre +# exactement a APP_URL_KIOSK (pas de wildcard). +CORS_ALLOWED_ORIGIN=http://kiosk.localhost:8080 -# Algorithme de hashage mot de passe (password_hash PHP). -# argon2id recommande depuis PHP 7.3 pour les nouveaux projets. -PASSWORD_ALGO=argon2id +# Algorithme de hashage : argon2id, FIXE dans le code (App\Auth\PasswordHasher), +# choix security-by-design non configurable. Seuls les COUTS ci-dessous sont reglables. + +# Parametres de cout argon2id (password_hash options). Defauts alignes OWASP +# (memoire >= 19 MiB, >= 2 iterations). Servent aussi au hash du PIN equipier. +ARGON2_MEMORY_COST=65536 # KiB (64 MiB) +ARGON2_TIME_COST=4 # nombre d'iterations +ARGON2_THREADS=1 # parallelisme (1 = portable, deterministe) + +# =================================================================== +# Anti brute-force - throttling de connexion (security-by-design) +# =================================================================== +# Deux gardes : par compte (user.failed_login_attempts / lockout_until) et par IP +# (table login_throttle). Backoff degressif, pas de lock definitif. + +ACCOUNT_LOCKOUT_THRESHOLD=5 +ACCOUNT_LOCKOUT_BASE_SECONDS=60 +ACCOUNT_LOCKOUT_MAX_SECONDS=900 # plafond du backoff (15 min) + +IP_THROTTLE_WINDOW_SECONDS=900 # 15 min +IP_THROTTLE_MAX_ATTEMPTS=20 # par IP sur la fenetre + +# PIN equipier pour actions sensibles. Chiffres, bornes min ET max (RG-T18). +STAFF_PIN_MIN_LENGTH=4 +STAFF_PIN_MAX_LENGTH=12 + +# Throttle du PIN d'action sensible (RG-T22) - compteurs SEPARES du login, +# dimension = utilisateur agissant. Bornes plus permissives que le login. +PIN_THROTTLE_THRESHOLD=5 +PIN_THROTTLE_BASE_SECONDS=30 +PIN_THROTTLE_MAX_SECONDS=300 +PIN_THROTTLE_WINDOW_SECONDS=900 + +# Expiration du token de reinitialisation de mot de passe (secondes). +PASSWORD_RESET_TTL=3600 # 1h + +# =================================================================== +# Retention des donnees (RGPD) +# =================================================================== +# Purges executees par le service cron (docker/cron/crontab). + +AUDIT_LOG_RETENTION_DAYS=365 # journal d'audit ~12 mois +THROTTLE_PURGE_AFTER_HOURS=24 # login_throttle : lignes sans lockout actif > 24h +ORDER_RETENTION_DAYS=1095 # commandes (historique/stats) ~3 ans # =================================================================== # Upload images produits @@ -76,35 +116,18 @@ UPLOAD_ALLOWED_MIME=image/jpeg,image/png,image/webp # =================================================================== # Cron (fenetre de maintenance 01h30 - 09h30) # =================================================================== -# Les jobs sont definis dans docker/cron/crontab. Ici uniquement le TZ. CRON_TIMEZONE=Europe/Paris # =================================================================== -# Exposition via Traefik +# Deploiement prod (overlay docker-compose.prod.yml) - OPTIONNEL # =================================================================== -# FQDN consommes par les labels docker-compose.yml pour generer les routes -# Traefik et les certificats TLS (Traefik gere le resolver par defaut). -# Le Traefik de l'hote prend en charge Let's Encrypt automatiquement. - -TRAEFIK_DOMAIN_KIOSK=kiosk.example.com -TRAEFIK_DOMAIN_ADMIN=admin.example.com - -# =================================================================== -# Reseau Docker externe du reverse proxy -# =================================================================== -# Nom du reseau Docker externe auquel le service web doit se connecter -# pour etre expose par le reverse proxy de l'hote. +# A ignorer pour un usage local. Necessaire UNIQUEMENT derriere un reverse proxy +# Traefik, avec : docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d # -# Adapter selon votre infrastructure. Valeurs courantes : -# traefik_proxy - convention neutre (placeholder) -# traefik_public - convention doc Traefik -# traefik - setups simples -# proxy - autre convention frequente +# En prod, surcharger aussi : APP_ENV=prod, APP_DEBUG=false, mots de passe forts, +# et APP_HOST_*/APP_URL_*/CORS_ALLOWED_ORIGIN avec les vrais FQDN HTTPS. # -# Le reseau doit exister AVANT 'make init' (cree par votre stack de -# reverse proxy, ou manuellement : docker network create ). -# La cible 'make init' echoue proprement avec un message d'aide si le -# reseau est introuvable. - -REVERSE_PROXY_NETWORK=traefik_proxy +# 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 `). +REVERSE_PROXY_NETWORK=admin_proxy diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..d5feaf5 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,172 @@ +name: CI +# CI Wakdo - Forgejo Actions (runner stark-wakdo, label `docker`). +# Strategie solo dev : PR obligatoire ; l'auto-merge NATIF Forgejo +# (merge_when_checks_succeed, programme a l'ouverture de la PR) fusionne en squash +# des que les checks requis passent. Pas de job de merge dans le workflow. +# +# Etat des jobs selon la phase projet : +# - secret-scan : fonctionnel des maintenant (gitleaks scanne tout le depot) +# - php-lint : fonctionnel sur les fichiers PHP presents (stubs P1, code P2+) +# - static-tests: PHPStan + PHPUnit GARDES - s'activent quand P2 ajoute +# composer.json / phpstan.neon / tests + phpunit.xml + +on: + pull_request: + branches: [dev, main] + types: [opened, synchronize, reopened] + push: + # dev/main : porte de merge. feat|fix|ci|refactor : feedback avant la PR. + branches: [dev, main, 'feat/**', 'fix/**', 'ci/**', 'refactor/**'] + +jobs: + secret-scan: + runs-on: docker + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install tools + run: | + apt-get update -qq + apt-get install -y -qq curl ca-certificates tar >/dev/null + - name: Install gitleaks + run: | + VER=8.21.2 + curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VER}/gitleaks_${VER}_linux_x64.tar.gz" -o /tmp/gl.tgz + tar -xzf /tmp/gl.tgz -C /usr/local/bin gitleaks + gitleaks version + - name: Scan for secrets + run: gitleaks detect --config .gitleaks.toml --redact --no-banner --verbose + + php-lint: + runs-on: docker + steps: + - uses: actions/checkout@v4 + - name: Install PHP CLI + run: | + apt-get update -qq + apt-get install -y -qq php-cli >/dev/null + php --version + - name: Lint all PHP files + run: | + set -eu + files=$(find . -path ./node_modules -prune -o -name '*.php' -print) + if [ -z "$files" ]; then echo "No PHP files yet - skip"; exit 0; fi + echo "$files" | while IFS= read -r f; do + [ -z "$f" ] && continue + php -l "$f" + done + + static-tests: + runs-on: docker + # COMPOSER-LESS (decision 4 / 5, PROJECT_CONTEXT.md) : PHPStan et PHPUnit + # tournent depuis leur .phar autonome telecharge ici, jamais via Composer. + # Versions epinglees pour des CI reproductibles (pas de "latest"). + # + # Service MariaDB ephemere : le schema (db/migrations) et le seed (db/seeds) + # y sont appliques, puis PHPUnit tourne avec WAKDO_DB_TESTS=1 pour que les + # tests d'integration (tests/Integration/*DbTest) s'executent REELLEMENT. + # Sans base, ils s'auto-skippent et le SQL porteur de securite (throttle, + # RBAC is_active, audit in-transaction, FK) n'est jamais valide en CI. + # Identifiants ci-dessous : ephemeres, CI uniquement, jamais des secrets. + services: + mariadb: + image: mariadb:11.4 + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: wakdo_test + MARIADB_USER: wakdo + MARIADB_PASSWORD: wakdo + env: + PHPUNIT_VERSION: "11.5.2" + PHPSTAN_VERSION: "1.12.27" + # Connexion des tests d'integration au service `mariadb` ci-dessus + # (Database lit ces DB_* via getenv ; cf. src/app/Core/Database.php). + WAKDO_DB_TESTS: "1" + DB_HOST: mariadb + DB_PORT: "3306" + DB_NAME: wakdo_test + DB_USER: wakdo + DB_PASSWORD: wakdo + steps: + - uses: actions/checkout@v4 + - name: PHPStan (guarded) + run: | + set -eu + if [ ! -f phpstan.neon ]; then + echo "PHPStan skipped: no phpstan.neon yet (activates in P2)" + exit 0 + fi + echo "phpstan.neon detected - running PHPStan ${PHPSTAN_VERSION} via .phar" + apt-get update -qq && apt-get install -y -qq php-cli php-xml php-mbstring curl ca-certificates >/dev/null + # PHPUnit phar present pour que phpstan.neon (scanDirectories phar://phpunit.phar) + # resolve les symboles PHPUnit\Framework\* utilises sous tests/. + curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar + curl -sSL "https://github.com/phpstan/phpstan/releases/download/${PHPSTAN_VERSION}/phpstan.phar" -o phpstan.phar + php phpstan.phar --version + # memory_limit=-1 : l'analyse parallele depasse les 128M par defaut du php-cli. + php -d memory_limit=-1 phpstan.phar analyse --no-progress --error-format=raw + - name: PHPUnit (guarded, avec tests d'integration DB) + run: | + set -eu + if [ ! -d tests ] || [ ! -f phpunit.xml ]; then + echo "PHPUnit skipped: no tests/ + phpunit.xml yet (activates in P2)" + exit 0 + fi + echo "phpunit.xml + tests/ detected - running PHPUnit ${PHPUNIT_VERSION} via .phar" + # php-mysql = pilote pdo_mysql requis par les *DbTest ; mariadb-client + # pour appliquer schema + seed au service mariadb. + apt-get update -qq && apt-get install -y -qq php-cli php-xml php-mbstring php-mysql mariadb-client curl ca-certificates >/dev/null + # Attente active que le service MariaDB reponde (en plus du lien de service). + echo "Attente du service MariaDB ${DB_HOST}:${DB_PORT} ..." + ready=0 + for i in $(seq 1 30); do + if mariadb -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASSWORD}" -e "SELECT 1" "${DB_NAME}" >/dev/null 2>&1; then + echo "MariaDB pret (tentative ${i})."; ready=1; break + fi + sleep 2 + done + [ "${ready}" = 1 ] || { echo "ERREUR: MariaDB injoignable apres 60s"; exit 1; } + # Schema (db/migrations) puis seed (db/seeds), ordre lexicographique. + for f in db/migrations/*.sql; do + echo "migrate $(basename "$f")" + mariadb -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" < "$f" + done + for f in db/seeds/*.sql; do + echo "seed $(basename "$f")" + mariadb -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" < "$f" + done + curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar + php phpunit.phar --version + # --fail-on-skipped : si un *DbTest s'auto-skippe (base injoignable), la + # CI echoue au lieu de masquer le trou derriere un vert. C'est le coeur + # du correctif : plus aucun skip silencieux des chemins securite. + php phpunit.phar -c phpunit.xml --fail-on-skipped + + js-tests: + # Tests du front borne (kiosk) : node:test + jsdom, sans navigateur. + # GARDE : ne s'active que si package.json + tests/js/ existent. + runs-on: docker + steps: + - uses: actions/checkout@v4 + - name: Install Node.js 20 + run: | + set -eu + # Node 20 epingle via NodeSource (self-contained, comme les .phar/gitleaks) + # plutot que l'apt bookworm (18.x, limite basse pour jsdom). Reproductible. + apt-get update -qq && apt-get install -y -qq curl ca-certificates >/dev/null + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null + apt-get install -y -qq nodejs >/dev/null + node --version && npm --version + - name: Install deps + run kiosk JS tests + run: | + set -eu + if [ ! -f package.json ] || [ ! -d tests/js ]; then + echo "JS tests skipped: no package.json + tests/js/ yet" + exit 0 + fi + # Skip le download des browsers Playwright : ce job ne fait que node:test+jsdom. + # (@playwright/test est en devDep pour l'E2E, mais ses browsers ne servent + # qu'a tests/e2e via le conteneur officiel.) + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci + npm run test:js diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md index 9905ed8..0b72345 100644 --- a/.gitea/PULL_REQUEST_TEMPLATE.md +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -24,6 +24,17 @@ Remplis les sections, coche ce qui s'applique, supprime ce qui ne sert pas. - [ ] Docs Merise / dictionnaire a jour si le modele de donnees change - [ ] Tests ajoutes et passants si du code est touche (unit > integration > e2e) +## Checklist securite (security-by-design) + + + +- [ ] Aucun secret commite (CI gitleaks verte) ; `.env` reste gitignore +- [ ] Entrees utilisateur validees ; requetes SQL en prepared statements (anti-injection) +- [ ] Mots de passe / PIN en argon2id ; pas de donnee sensible en clair ni dans les logs +- [ ] Sorties HTML echappees (anti-XSS) ; CSRF gere sur les formulaires d'etat +- [ ] Permissions RBAC verifiees cote serveur pour toute action sensible +- [ ] Impact RGPD evalue si nouvelles donnees personnelles (retention, droit a l'effacement) + ## Bloc RNCP impacte diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..c37cb20 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# Wakdo - hook commit-msg : valide le format Conventional Commits. +# +# Active via scripts/install-hooks.sh (git config core.hooksPath .githooks). +# Recoit en $1 le chemin du fichier contenant le message de commit. +# +# Regle (PROJECT_CONTEXT section 9) : +# (): +# types : feat|fix|refactor|test|docs|chore|ci|db|perf|style +# scope : minuscules, chiffres, tirets +# interdits : emoji (Mantra IA-23) +# +# Exit codes : 0 = message conforme ; 1 = format invalide ou emoji detecte. + +set -euo pipefail + +MSG_FILE="${1:?usage: commit-msg }" + +# Premiere ligne non vide (ignore les commentaires git et les lignes vides). +SUBJECT="$(grep -m1 -vE '^\s*(#|$)' "$MSG_FILE" || true)" + +if [ -z "$SUBJECT" ]; then + echo "commit-msg: message vide." >&2 + exit 1 +fi + +# Tolerance : commits techniques de git (merge/revert/fixup) non concernes. +case "$SUBJECT" in + "Merge "*|"Revert "*|"fixup! "*|"squash! "*) exit 0 ;; +esac + +PATTERN='^(feat|fix|refactor|test|docs|chore|ci|db|perf|style)(\([a-z0-9-]+\))?!?: .{5,}' +if ! printf '%s' "$SUBJECT" | grep -qE "$PATTERN"; then + echo "commit-msg: format invalide." >&2 + echo " attendu : (): =5 car.)>" >&2 + echo " types : feat fix refactor test docs chore ci db perf style" >&2 + echo " recu : $SUBJECT" >&2 + exit 1 +fi + +# Refus des emoji (Mantra IA-23). Plage des symboles/pictogrammes courants. +# grep -P (PCRE) est requis pour les classes \x{...} ; il n'est dispo que sur le +# grep GNU (cible Linux/Alpine du projet). Si -P est absent (BSD/macOS), on saute +# ce controle plutot que de bloquer a tort (le format reste verifie ; la CI fait foi). +if printf 'a' | grep -qP 'a' 2>/dev/null; then + if printf '%s' "$SUBJECT" | grep -qP '[\x{1F000}-\x{1FAFF}\x{2600}-\x{27BF}\x{2190}-\x{21FF}\x{2B00}-\x{2BFF}]'; then + echo "commit-msg: emoji detecte dans le sujet (interdit, Mantra IA-23)." >&2 + exit 1 + fi +fi + +exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..2fa94a5 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# +# Wakdo - hook pre-commit : garde-fous locaux avant chaque commit. +# +# Active via scripts/install-hooks.sh (git config core.hooksPath .githooks). +# Defense en profondeur cote dev ; la protection de reference reste la CI Forgejo +# (secret-scan, php-lint, static-tests) et la branch protection serveur. +# +# Controles : +# 1. Refuse un commit direct sur main ou dev (PROJECT_CONTEXT regle 18.5). +# 2. Lint PHP (php -l) sur les fichiers .php indexes, si php est disponible. +# +# Exit codes : 0 = OK ; 1 = commit bloque. + +set -euo pipefail + +BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "dev" ]; then + echo "pre-commit: commit direct sur '$BRANCH' interdit (regle 18.5)." >&2 + echo " cree une branche : git checkout -b feat/ma-feature" >&2 + exit 1 +fi + +# Lint PHP des fichiers indexes (added/copied/modified), si l'outil est present. +if command -v php >/dev/null 2>&1; then + FAILED=0 + while IFS= read -r file; do + [ -n "$file" ] || continue + [ -f "$file" ] || continue + if ! php -l "$file" >/dev/null 2>&1; then + echo "pre-commit: erreur de syntaxe PHP dans $file" >&2 + php -l "$file" >&2 || true + FAILED=1 + fi + done < <(git diff --cached --name-only --diff-filter=ACM -- '*.php') + if [ "$FAILED" -ne 0 ]; then + exit 1 + fi +fi + +exit 0 diff --git a/.gitignore b/.gitignore index dffbf98..3f40d20 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ *.pem *.key +# Compose de production (propre a chaque hote : Traefik/reverse proxy, FQDN). +# Le repo ne ship que docker-compose.yml (standalone, local). Chaque hote derriere +# un proxy maintient son propre docker-compose.prod.yml (hors versionnement). +docker-compose.prod.yml + # === BYAN — plateforme (moteur), masquee === # Le code moteur des agents n'est pas part du rendu RNCP. # La methodologie appliquee (CLAUDE.md + rules + hooks) reste dans .claude/ @@ -28,8 +33,11 @@ vendor/ composer.lock composer.phar -# === Tests === +# === Tests / Analyse statique (tooling via .phar autonome, sans Composer) === .phpunit.result.cache +.phpunit.cache/ +/phpunit.phar +/phpstan.phar /tests/_output/ /tests/_support/_generated/ @@ -67,6 +75,12 @@ node_modules/ npm-debug.log yarn-error.log +# === Playwright (E2E) === +playwright-report/ +test-results/ +/blob-report/ +.last-run.json + # === Docker volumes locaux === /docker-data/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..211c793 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,31 @@ +# Wakdo - configuration gitleaks (secret-scan) +# +# Utilise par : +# - le hook pre-commit local (defense en profondeur) +# - le job CI Forgejo Actions (.forgejo/workflows/, lot D) sur chaque PR -> dev +# +# Principe : etendre le jeu de regles par defaut de gitleaks, puis ne tolerer +# QUE les faux positifs explicitement justifies ci-dessous (placeholders de doc). + +[extend] +useDefault = true + +[allowlist] +description = "Faux positifs documentes - placeholders de configuration, jamais des secrets reels" + +# Fichiers de template / doc : ne contiennent que des placeholders RFC 2606 / change_me. +paths = [ + '''\.env\.example$''', + '''\.gitleaks\.toml$''', + '''docs/.*\.md$''', +] + +# Valeurs placeholder explicites tolerees ou qu'elles apparaissent. +regexes = [ + '''change_me_strong_password''', + '''change_me_root_password''', + '''example\.com''', +] + +# Note : le vrai .env est gitignore et ne doit jamais etre commite. Ce scan est +# une defense en profondeur, pas un substitut a l'hygiene .gitignore. diff --git a/Makefile b/Makefile deleted file mode 100644 index 1c1ee94..0000000 --- a/Makefile +++ /dev/null @@ -1,218 +0,0 @@ -# -# Wakdo - Makefile d'orchestration locale -# -# Conventions : -# - Une cible = une action unitaire. Les cibles composites sont commentees. -# - Chaque cible est documentee par un `## description` pour auto-help. -# - Echec sur erreur (set -e implicite via bash recipes + pipefail). -# -# Documentation : -# make help -# - -SHELL := /usr/bin/env bash -.SHELLFLAGS := -eu -o pipefail -c - -# === Configuration === - -# Chargement du .env s'il existe (variables Make + export pour docker compose) -ifneq (,$(wildcard .env)) -include .env -export -endif - -# Prefixe du projet compose (utilise pour nommer les containers) -PROJECT := wakdo - -# Nom du fichier compose (override possible : make up COMPOSE_FILE=docker-compose.prod.yml) -COMPOSE_FILE := docker-compose.yml -COMPOSE := docker compose -f $(COMPOSE_FILE) -p $(PROJECT) - -# Services docker-compose -SERVICE_WEB := wakdo-web -SERVICE_APP := wakdo-app -SERVICE_DB := wakdo-db -SERVICE_CRON := wakdo-cron - -# === Meta === - -.DEFAULT_GOAL := help -.PHONY: help -help: ## Liste toutes les cibles disponibles avec leur description - @echo "Wakdo - cibles Make disponibles :" - @echo "" - @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[1m%-22s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort - @echo "" - -# === Orchestration principale === - -.PHONY: init -init: ## Build et demarre toute la stack en une commande (Cr RNCP 7.c.4) - @test -f .env || { echo "ERREUR: .env manquant. Executer : cp .env.example .env"; exit 1; } - @$(MAKE) --no-print-directory check-env - @echo "[init] Verification du reseau docker '$(REVERSE_PROXY_NETWORK)'..." - @docker network inspect $(REVERSE_PROXY_NETWORK) >/dev/null 2>&1 || { \ - echo "ERREUR: reseau docker '$(REVERSE_PROXY_NETWORK)' introuvable."; \ - echo " - Si un Traefik est installe sur l'hote, verifier le nom de son reseau ;"; \ - echo " - Adapter REVERSE_PROXY_NETWORK dans .env en consequence ;"; \ - echo " - Sinon creer le reseau manuellement :"; \ - echo " docker network create $(REVERSE_PROXY_NETWORK)"; \ - exit 1; } - @echo "[init] Build des images..." - @$(COMPOSE) build - @echo "[init] Demarrage des services..." - @$(COMPOSE) up -d - @echo "[init] Attente de la base de donnees..." - @$(MAKE) --no-print-directory wait-db - @echo "[init] Execution des migrations..." - @$(MAKE) --no-print-directory migrate - @echo "[init] Stack operationnelle." - @$(COMPOSE) ps - -.PHONY: up -up: ## Demarre les services sans rebuild - @$(COMPOSE) up -d - -.PHONY: down -down: ## Arrete et supprime les containers (volumes preserves) - @$(COMPOSE) down - -.PHONY: stop -stop: ## Arrete les services sans les supprimer - @$(COMPOSE) stop - -.PHONY: restart -restart: ## Redemarre tous les services - @$(COMPOSE) restart - -.PHONY: build -build: ## Build les images (utilise le cache) - @$(COMPOSE) build - -.PHONY: rebuild -rebuild: ## Rebuild complet sans cache puis restart - @$(COMPOSE) build --no-cache - @$(COMPOSE) up -d - -# === Observabilite === - -.PHONY: ps -ps: ## Affiche le statut des services - @$(COMPOSE) ps - -.PHONY: logs -logs: ## Suit les logs de tous les services (Ctrl+C pour sortir) - @$(COMPOSE) logs -f --tail=100 - -.PHONY: logs-app -logs-app: ## Suit les logs du service applicatif PHP-FPM - @$(COMPOSE) logs -f --tail=100 $(SERVICE_APP) - -.PHONY: logs-web -logs-web: ## Suit les logs du service web Apache - @$(COMPOSE) logs -f --tail=100 $(SERVICE_WEB) - -.PHONY: logs-db -logs-db: ## Suit les logs de la base de donnees - @$(COMPOSE) logs -f --tail=100 $(SERVICE_DB) - -# === Acces shell === - -.PHONY: shell-app -shell-app: ## Ouvre un shell dans le container applicatif - @$(COMPOSE) exec $(SERVICE_APP) sh - -.PHONY: shell-db -shell-db: ## Ouvre le client mariadb dans le container de base de donnees - @$(COMPOSE) exec $(SERVICE_DB) mariadb -u root -p"$${DB_ROOT_PASSWORD}" - -.PHONY: shell-cron -shell-cron: ## Ouvre un shell dans le container cron - @$(COMPOSE) exec $(SERVICE_CRON) sh - -# === Verification env === - -.PHONY: check-env -check-env: ## Verifie que les variables critiques Wakdo sont definies dans .env - @missing=""; \ - for var in DB_PASSWORD DB_ROOT_PASSWORD REVERSE_PROXY_NETWORK TRAEFIK_DOMAIN_KIOSK TRAEFIK_DOMAIN_ADMIN APP_URL_KIOSK APP_URL_ADMIN CORS_ALLOWED_ORIGIN; do \ - if [ -z "$${!var:-}" ]; then missing="$$missing $$var"; fi; \ - done; \ - if [ -n "$$missing" ]; then \ - echo "ERREUR: variables manquantes dans .env :$$missing"; \ - echo "Conseil : si vous aviez un .env pre-existant (tooling externe),"; \ - echo " merger les variables manquantes depuis .env.example au lieu"; \ - echo " d'ecraser le fichier."; \ - exit 1; \ - fi - -# === Base de donnees === - -.PHONY: wait-db -wait-db: ## Attend que la base de donnees accepte les connexions (timeout 60s) - @echo "[wait-db] En attente de MariaDB..." - @timeout 60 bash -c 'until $(COMPOSE) exec -T $(SERVICE_DB) healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; do sleep 2; done' \ - || { echo "ERREUR: MariaDB ne repond pas apres 60s"; $(COMPOSE) logs --tail=50 $(SERVICE_DB); exit 1; } - @echo "[wait-db] OK" - -.PHONY: migrate -migrate: ## Applique les migrations SQL en attente [a venir] - @echo "[migrate] Pas encore implemente. Les migrations seront dans db/migrations/." - -.PHONY: seed -seed: ## Charge les donnees de demo [a venir] - @echo "[seed] Pas encore implemente. Les seeds seront dans db/seeds/." - -.PHONY: backup -backup: ## Declenche un dump SQL horodate immediat (via le container cron) - @mkdir -p ./var/backups - @echo "[backup] Execution manuelle de /scripts/backup-db.sh dans wakdo-cron..." - @$(COMPOSE) exec -T $(SERVICE_CRON) /scripts/backup-db.sh - @echo "[backup] Dernier dump :" - @ls -lh ./var/backups/ | tail -n 1 - -.PHONY: backup-ls -backup-ls: ## Liste les dumps SQL presents dans ./var/backups/ - @ls -lh ./var/backups/ 2>/dev/null || echo "[backup-ls] Pas de backups (./var/backups/ vide ou inexistant)." - -# === Tests === - -.PHONY: test -test: ## Lance la suite complete de tests PHPUnit [a venir] - @echo "[test] Pas encore implemente. PHPUnit via .phar sera configure en P2." - -.PHONY: test-unit -test-unit: ## Lance uniquement les tests unitaires [a venir] - @echo "[test-unit] Pas encore implemente." - -.PHONY: test-integration -test-integration: ## Lance uniquement les tests d'integration [a venir] - @echo "[test-integration] Pas encore implemente." - -# === Qualite code === - -.PHONY: lint -lint: ## Lance php -l sur tous les fichiers src/ [a venir] - @echo "[lint] Pas encore implemente. PHP syntax check via php -l + outil de style en P2." - -# === Nettoyage === - -.PHONY: clean -clean: ## Stop + suppression containers + volumes (DESTRUCTIF, demande confirmation) - @read -p "Supprimer containers ET volumes (les donnees seront perdues) ? [y/N] " ans; \ - if [ "$$ans" = "y" ] || [ "$$ans" = "Y" ]; then \ - $(COMPOSE) down -v; \ - echo "[clean] Stack et volumes supprimes."; \ - else \ - echo "[clean] Annule."; \ - fi - -.PHONY: clean-force -clean-force: ## Version non interactive de clean (pour CI uniquement) - @$(COMPOSE) down -v - -# === Hooks Git === - -.PHONY: install-hooks -install-hooks: ## Installe les hooks git depuis .githooks/ [a venir] - @echo "[install-hooks] Pas encore implemente. Voir scripts/install-hooks.sh a venir." diff --git a/README.md b/README.md index 3b520cd..0687260 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Trois canaux de prise de commande : - `counter` — comptoir (un equipier saisit pour le client au guichet) - `drive` — drive-thru (equipier saisit via intercom + casque) -Quatre statuts commande : `pending` -> `preparing` -> `ready` -> `delivered` (ou `cancelled`). +Statuts commande (machine a 4 etats) : `pending_payment` -> `paid` -> `delivered`, plus `cancelled` (atteignable depuis `pending_payment` ou `paid`). La saisie du numero tient lieu de paiement : la creation passe atomiquement de `pending_payment` a `paid`. La cuisine voit la file des commandes `paid` en lecture seule ; la remise est un geste unique `paid` -> `delivered`. Scope metier complet, regles, horaires de service et fenetre de maintenance : voir `docs/PROJECT_CONTEXT.md`. @@ -55,9 +55,9 @@ Realisation avec l'assistance d'outils d'IA generative (Claude Code, BYAN), conf | Tests | PHPUnit | 11.x (`.phar` autonome, sans Composer) | | Front | HTML5 + CSS3 + JS ES6+ vanilla | — | | Conteneurisation | Docker + docker compose v2 | — | -| Orchestration locale | Makefile | — | -| CI/CD | GitHub Actions | — | -| Versioning | Git + GitHub | Conventional Commits | +| Orchestration locale | docker compose v2 (service one-shot `wakdo-migrate`) | — | +| CI/CD | Forgejo Actions | — | +| Versioning | Git + Forgejo (`git.acadenice.com`, miroir GitHub) | Conventional Commits | Detail et justifications : `docs/PROJECT_CONTEXT.md` section 6. @@ -82,86 +82,50 @@ Detail et justifications : `docs/PROJECT_CONTEXT.md` section 6. v wakdo-db (MariaDB 11.4) - wakdo-cron (backup BDD + purge sessions + stats) + wakdo-cron (backup BDD + purge audit-log + purge throttle) ``` Reseaux, volumes, services et decoupage reseau interne / reseau proxy : voir `docs/PROJECT_CONTEXT.md` section 5. --- -## Quickstart - -Ce projet tourne **sur serveur derriere un reverse proxy Traefik** : pas de binding de ports hote, pas d'acces `localhost`. L'acces public se fait par FQDN HTTPS (TLS gere automatiquement par Traefik). Les environnements `dev`, `staging` et `prod` se distinguent par des FQDN et des fichiers `.env` separes. - -### Prerequis sur l'hote - -1. Docker Engine + docker compose v2 (voir ci-dessous) -2. Un reverse proxy Traefik deja en place, avec un reseau Docker externe dedie. Le **nom du reseau** est configurable via la variable `REVERSE_PROXY_NETWORK` du `.env` (defaut : `admin_proxy` — convention de l'auteur). A adapter a votre infrastructure. -3. Les FQDN cibles pointent en DNS vers l'hote - -### Sur un hote deja equipe (Docker + Traefik) +## Quickstart (local) ```bash -git clone git@github.com:AcadeNice/wakdo_corentin.git -cd wakdo_corentin +git clone https://git.acadenice.com/AcadeNice/corentin_wakdo.git +cd corentin_wakdo cp .env.example .env -# Editer .env : DB_PASSWORD, DB_ROOT_PASSWORD, APP_URL_*, TRAEFIK_DOMAIN_* -make init +docker compose up -d ``` -> **Attention au `.env` pre-existant.** Si un fichier `.env` existe deja a la racine (tooling externe, autre plateforme installee dans le meme repertoire), **ne pas faire** `cp .env.example .env` — cela ecraserait les variables existantes. Faire un **merge manuel** a la place : ajouter les variables manquantes du template dans le `.env` actuel. Les prefixes de variables de ce projet (`APP_`, `DB_`, `SESSION_`, `CORS_`, `UPLOAD_`, `CRON_`, `TRAEFIK_`, `REVERSE_PROXY_`) sont disjoints de ceux utilises par des outils tiers courants, donc la cohabitation est safe. +Une seule commande lance la stack complete (Cr 7.c.4) : le service one-shot +`wakdo-migrate` applique les migrations puis le seed (idempotents, tables de suivi +`schema_migrations` / `seeds_applied`) avant que l'app ne serve. Ensuite : -Critere RNCP Cr 7.c.4 couvert : une seule commande (`make init`) orchestre build, demarrage, attente BDD, migrations et seed. +- Borne : http://kiosk.localhost:8080 +- Admin + API : http://admin.localhost:8080 -Services accessibles apres `make init` : -- Borne : la valeur de `TRAEFIK_DOMAIN_KIOSK` dans `.env` -- Admin + API : la valeur de `TRAEFIK_DOMAIN_ADMIN` dans `.env` +`*.localhost` resout vers `127.0.0.1` nativement ; changer le port via `HTTP_PORT` +dans `.env`. Le `.env.example` fonctionne tel quel en local (valeurs dev). -Liste complete des cibles : `make help`. +Docker non installe ? Voir https://docs.docker.com/engine/install/ -### Installation Docker sur un hote neuf (Debian / Ubuntu) +### Deploiement prod (derriere un reverse proxy Traefik) -Procedure officielle detaillee : `https://docs.docker.com/engine/install/` (selectionner la distribution). Resume pour Debian stable : +Le repo ne ship que `docker-compose.yml` (standalone). En production derriere un +reverse proxy, chaque hote maintient son **propre `docker-compose.prod.yml`** +(gitignore, hors repo, comme `.env`) : meme stack, mais exposee via Traefik (reseau +externe + labels TLS) au lieu d'un port hote. ```bash -sudo apt update -sudo apt install -y ca-certificates curl -sudo install -m 0755 -d /etc/apt/keyrings -sudo curl -fsSL https://download.docker.com/linux/debian/gpg \ - -o /etc/apt/keyrings/docker.asc -sudo chmod a+r /etc/apt/keyrings/docker.asc -echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ - https://download.docker.com/linux/debian \ - $(. /etc/os-release && echo $VERSION_CODENAME) stable" \ - | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null -sudo apt update -sudo apt install -y docker-ce docker-ce-cli containerd.io \ - docker-buildx-plugin docker-compose-plugin -sudo usermod -aG docker $USER -# Fermer et rouvrir la session pour activer le groupe docker +docker compose -f docker-compose.prod.yml up -d ``` -### Reseau externe du reverse proxy +Avec un `.env` adapte : `APP_ENV=prod`, `APP_DEBUG=false`, mots de passe forts, +`APP_HOST_*` / `APP_URL_*` / `CORS_ALLOWED_ORIGIN` en vrais FQDN HTTPS, et +`REVERSE_PROXY_NETWORK` = reseau Docker du Traefik de l'hote (doit exister avant le up). -Le `docker-compose.yml` attend un reseau Docker externe deja existant sur l'hote, dont le nom est donne par la variable `REVERSE_PROXY_NETWORK` (defaut : `admin_proxy`). - -Si vous avez deja un Traefik en place, ce reseau a generalement ete cree par son propre stack. Adaptez la variable `REVERSE_PROXY_NETWORK` dans votre `.env` au nom utilise par votre proxy. Sinon, creez-le manuellement : - -```bash -docker network create mon_reseau_proxy -# puis dans .env : -# REVERSE_PROXY_NETWORK=mon_reseau_proxy -``` - -Avant le premier `make init`, s'assurer que le reseau existe. Verification rapide : - -```bash -docker network inspect "$(grep ^REVERSE_PROXY_NETWORK .env | cut -d= -f2)" -``` - -Si la commande retourne une erreur, soit adapter `REVERSE_PROXY_NETWORK` au nom du reseau utilise par votre proxy, soit creer le reseau manuellement. La cible `make init` echoue proprement avec un message d'aide si le reseau est introuvable. - -*Section mise a jour au fil de l'implementation (migrations reelles, seed, CI/CD deploiement).* +*Deploiement detaille : section Deploiement plus bas et `scripts/deploy.sh`.* --- @@ -170,37 +134,34 @@ Si la commande retourne une erreur, soit adapter `REVERSE_PROXY_NETWORK` au nom ``` . |-- .claude/ # Methodologie BYAN (visible jury : CLAUDE.md + rules/) -|-- .github/ -| `-- workflows/ # CI/CD GitHub Actions [a venir] -|-- .githooks/ # pre-commit + commit-msg [a venir] +|-- .forgejo/workflows/ # CI Forgejo Actions (ci.yml : secret-scan, php-lint, static-tests, js-tests) +|-- .githooks/ # pre-commit (refus main/dev + php -l) + commit-msg (Conventional Commits) |-- docker/ # Dockerfiles customs par service -| |-- apache/ -| |-- php-fpm/ -| `-- cron/ +| |-- apache/ # httpd + vhosts kiosk / admin +| |-- php-fpm/ # PHP 8.3-fpm + php.ini durci +| `-- cron/ # dcron + scripts (backup, restore, purges) |-- db/ -| |-- migrations/ # DDL MariaDB versionnes [a venir] -| `-- seeds/ # Donnees de demo [a venir] +| |-- init/ # init BDD (scope du user applicatif, moindre privilege) +| |-- migrations/ # DDL MariaDB versionnes (0001_init_schema, 0002_pin_throttle, ...) +| |-- seeds/ # donnees de reference + demo (idempotents) +| `-- *.sh # runners migrate / seed |-- docs/ -| |-- PROJECT_CONTEXT.md # Source de verite projet (scope, stack, RNCP mapping) -| |-- journal/ # Retros par session et par feature (oral RNCP) -| `-- merise/ # MCD, MCT, MLD [a venir] -|-- scripts/ # backup-db, install-hooks, ... [a venir] -|-- src/ # Code applicatif [a venir] -| |-- Core/ # Router, Autoloader, DB -| |-- Controllers/ -| |-- Models/ -| |-- Views/ -| |-- Services/ -| |-- public/ # DocumentRoot Apache -| `-- bootstrap.php +| |-- PROJECT_CONTEXT.md # source de verite projet (scope, stack, mapping RNCP) +| |-- ARCHITECTURE.md # vue technique (deploiement, stack, securite) +| |-- merise/ # dictionnaire, MCD, MCT, MLD, MLT (+ diagrammes) +| |-- uml/ # use-cases, sequences, machine a etats +| `-- adr/ api/ domaines/ design/ journal/ _ref/ +|-- scripts/ # deploy, install-hooks, forgejo-* (branch-protection, pr-automerge) +|-- src/ +| |-- app/ # namespace App\ : Core, Controllers, Auth, Catalogue, Order, Views +| `-- public/ # DocumentRoots Apache : borne/ (kiosk) + admin/ (back-office + API) |-- tests/ -| |-- Unit/ # [a venir] -| `-- Integration/ # [a venir] -|-- .env.example -|-- .dockerignore -|-- .gitignore -|-- Makefile -|-- docker-compose.yml +| |-- Unit/ Integration/ # PHPUnit (.phar autonome, sans Composer ; integration sur vraie MariaDB) +| |-- js/ # node:test + jsdom (front borne) +| |-- e2e/ # Playwright (parcours borne + admin, lance a la main) +| `-- Support/ # doubles de test (Fake* / Spy*) +|-- .env.example .gitleaks.toml phpstan.neon phpunit.xml +|-- docker-compose.yml # standalone local ; prod = docker-compose.prod.yml (gitignore, par hote) `-- README.md ``` @@ -212,22 +173,35 @@ Si la commande retourne une erreur, soit adapter `REVERSE_PROXY_NETWORK` au nom - **Commits** : Conventional Commits en anglais (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`, `db`, `perf`, `style`). Format : `type(scope): description`. Voir `docs/PROJECT_CONTEXT.md` section 9. - **Branches** : `feat/*`, `fix/*`, `refactor/*`, `docs/*`, `ci/*`, `db/*`, `chore/*`, `test/*` depuis `dev`. Merge vers `dev` par PR squashee. Periodiquement `dev` -> `main` par PR avec tag semver. -- `main` et `dev` sont proteges cote GitHub (PR requise, force push bloque, resolution des conversations requise). +- `main` et `dev` sont proteges cote Forgejo (PR requise, force push bloque, checks requis : secret-scan / php-lint / static-tests). - Pas d'emoji dans le code, les commits ou les specs techniques (Mantra IA-23). -*Sections detaillees (setup env de dev, lint, tests) : a completer au fil de l'implementation.* +- **Hooks Git** : `scripts/install-hooks.sh` active `pre-commit` (refus de commit direct sur `main`/`dev`, `php -l` des fichiers indexes) et `commit-msg` (format Conventional Commits, refus emoji). +- **Verification locale** : voir la section Tests ci-dessous (PHPUnit + PHPStan via le conteneur applicatif, tests JS via node, E2E Playwright). --- ## Tests -*Section a completer. Strategie globale : PHPUnit via `.phar` autonome (sans Composer), priorite Unit > Integration > E2E, voir `docs/PROJECT_CONTEXT.md` section 6 et mantras Merise Agile.* +Trois niveaux, sans dependance Composer cote PHP (priorite Unit > Integration > E2E). + +- **PHP (PHPUnit `.phar`)** — unit + integration sur vraie MariaDB, via le conteneur applicatif : + + ```bash + docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml + docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app php -d memory_limit=-1 phpstan.phar analyse + ``` + +- **JS (node:test + jsdom)** — modules du front borne : `npm run test:js` +- **E2E (Playwright)** — parcours borne + admin, lances a la main contre une stack jetable : `tests/e2e/run.sh` + +La CI Forgejo execute secret-scan, php-lint, static-tests (PHPStan niveau 6 + PHPUnit avec service MariaDB) et js-tests sur chaque PR. --- ## Deploiement -*Section a completer. Strategie cible : CI GitHub Actions sur PR vers `dev` (lint + PHPUnit), CD automatique sur merge vers `main` via SSH + `make rebuild`, voir `docs/PROJECT_CONTEXT.md` section 7 Bloc 5.* +*CI Forgejo Actions sur PR vers `dev`/`main` (secret-scan gitleaks, php-lint, static-tests PHPStan + PHPUnit, js-tests), avec auto-merge sur CI verte. Deploiement a declenchement humain via `scripts/deploy.sh` (recupere `main` depuis Forgejo puis `docker compose build --pull && up -d` ; les images sont buildees localement depuis les Dockerfiles, le one-shot `wakdo-migrate` applique migrations + seed). L'automatisation visee est pull-based (un job cron cote hote detectant un nouveau `main`), a armer ensuite. Voir `docs/PROJECT_CONTEXT.md` section 7 Bloc 5.* --- @@ -237,7 +211,8 @@ Si la commande retourne une erreur, soit adapter `REVERSE_PROXY_NETWORK` au nom |---|---| | `docs/PROJECT_CONTEXT.md` | Source de verite projet (17 sections : scope, stack, architecture, mapping critere RNCP, planning, risques, conventions) | | `docs/journal/` | Retrospectives par session et par feature (preparation de l'oral RNCP) | -| `docs/merise/` *(a venir)* | Modelisation Merise : dictionnaire, MCD, MCT, MLD | +| `docs/merise/` | Modelisation Merise : dictionnaire, MCD, MCT, MLD, MLT (+ diagrammes) | +| `docs/ARCHITECTURE.md` / `docs/adr/` | Vue technique + decisions d'architecture (ADR) | | `.claude/CLAUDE.md` | Constitution du projet pour les agents Claude Code | | `.claude/rules/` | Protocoles appliques : fact-check, merise-agile, elo-trust, hermes-dispatcher, byan-api, byan-agents | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5c6aa3f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# Politique de securite - Wakdo + +Wakdo est un projet de fin de formation (RNCP 37805) construit en +**security-by-design** : la menace est modelisee avant le code. Ce document +resume la posture, le signalement de vulnerabilites et les garde-fous CI. + +## Modele de menace + +Le modele STRIDE complet, le registre des risques et la classification des +donnees (4 niveaux) vivent dans `docs/PROJECT_CONTEXT.md` section 19, et le flux +d'authentification durci dans `docs/uml/security-sequence.md`. + +## Mesures en place (resume) + +| Domaine | Mesure | +|---|---| +| Mots de passe | `password_hash` argon2id (cout configurable, defauts OWASP) | +| Actions sensibles | PIN equipier hashe argon2id (`pin_hash`) | +| Brute-force | double throttle : compteur par compte (`user`) + par IP (`login_throttle`), backoff degressif | +| Sessions | cookies `HttpOnly` + `Secure` + `SameSite=Strict`, regeneration d'ID a la connexion (anti-fixation), idle 4h / absolu 10h | +| Injection | PDO prepared statements exclusivement | +| Upload | validation MIME + taille, stockage hors webroot | +| En-tetes / PHP | `expose_php=Off`, `allow_url_fopen/include=Off`, `cgi.fix_pathinfo=0`, fonctions d'execution systeme desactivees | +| RGPD | retention limitee (audit ~12 mois, throttle 24h, commandes ~3 ans), droit consultation/modif/suppression | +| Secrets | `.env` gitignore, tenu hors de `.git/config` (credential helper lisant `.env`), secret-scan gitleaks en CI | + +Les seuils operationnels (couts argon2, lockout, throttle, retention) sont +documentes dans `.env.example`. + +## Garde-fous CI (Forgejo Actions) + +Chaque PR vers `dev` ou `main` declenche `.forgejo/workflows/ci.yml` : + +- **secret-scan** (gitleaks) : empeche un secret d'entrer dans l'historique +- **php-lint** : `php -l` sur tous les fichiers PHP +- **static-tests** : PHPStan + PHPUnit (s'activent quand le code PHP arrive en P2) + +La strategie de merge est **PR + auto-merge sur CI verte** (travail solo) : la +PR est obligatoire (trace de gouvernance), le merge se declenche automatiquement +une fois les checks au vert. Voir `scripts/forgejo-pr-automerge.sh` et +`scripts/forgejo-branch-protection.sh`. + +## Signaler une vulnerabilite + +Projet pedagogique non destine a la production publique. Pour signaler un +probleme de securite : ouvrir une issue sur le depot Forgejo +(`https://git.acadenice.com/AcadeNice/corentin_wakdo`) ou contacter l'auteur. +Merci de ne pas divulguer publiquement un detail exploitable avant correction. + +## Perimetre + +Couvert : authentification, autorisation (RBAC), gestion de session, validation +d'entree, integrite des donnees de commande, hygiene des secrets. +Hors perimetre : paiement reel (remplace par numero de commande), durcissement +OS de l'hote, securite physique de la borne. diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..843915e --- /dev/null +++ b/db/README.md @@ -0,0 +1,38 @@ +# Base de donnees - migrations & seeds + +Transcription executable du MLD (`docs/merise/mld.md`, 21 tables) vers MariaDB 11.4. + +## Arborescence + +``` +db/ + migrations/ migrations SQL versionnees, appliquees dans l'ordre lexicographique + 0001_init_schema.sql schema initial : 21 tables, FK, CHECK, index (InnoDB, utf8mb4) + seeds/ donnees de demonstration (a venir : roles/permissions, allergenes, catalogue) + migrate.sh runner de migrations (idempotent) +``` + +## Appliquer les migrations + +```bash +bash db/migrate.sh # applique les migrations en attente +bash db/migrate.sh --status # liste l'etat sans rien appliquer +``` + +Le runner cible le conteneur `wakdo-db` et lit les identifiants dans `.env` +(`DB_NAME`, `DB_ROOT_PASSWORD`). Il maintient une table `schema_migrations` +(une ligne par fichier applique) : relancer ne rejoue que les nouvelles +migrations. La cible `bash db/migrate.sh` est destinee a appeler ce script. + +## Conventions + +- Une migration = un fichier `NNNN_description.sql`. Un fichier deja applique en + commun n'est plus edite : on ajoute une nouvelle migration pour corriger. +- Pas de `CREATE DATABASE` / `USE` dans les fichiers : la base cible est choisie + par le runner. +- Le schema suit le MLD v0.2 a la lettre : montants en centimes (INT UNSIGNED), + `vat_rate` en pour-mille, `service_day` NON materialise (calcule applicatif, + decision D6), stock signe (survente), journaux append-only (`stock_movement`, + `audit_log`). +- Verification : le DDL a ete applique sur une instance MariaDB 11.4 reelle + (21 tables, 28 FK, 22 CHECK) sans erreur avant integration. diff --git a/db/init/10-scope-app-user.sh b/db/init/10-scope-app-user.sh new file mode 100755 index 0000000..bfe271a --- /dev/null +++ b/db/init/10-scope-app-user.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# +# Wakdo - durcissement du privilege du user applicatif (moindre privilege). +# +# L'image mariadb cree MARIADB_USER avec GRANT ALL PRIVILEGES sur la base +# MARIADB_DATABASE. C'est trop large : le code applicatif expose (back-office) +# n'a besoin que de DML, jamais de DDL (CREATE/ALTER/DROP), de GRANT OPTION ni +# de DROP. Les migrations tournent separement en root (db/migrate.sh). +# +# Ce script s'execute UNIQUEMENT au premier demarrage sur volume vierge +# (/docker-entrypoint-initdb.d). Pour une base deja initialisee, appliquer le +# meme REVOKE/GRANT manuellement en root (voir db/init/README ou la PR). +# +# Set retenu : DML (SELECT/INSERT/UPDATE/DELETE) + ce dont mysqldump peut avoir +# besoin (SHOW VIEW, TRIGGER, LOCK TABLES). Pas de DDL, pas de GRANT, pas de DROP. +set -euo pipefail + +mariadb --protocol=socket -uroot -p"${MARIADB_ROOT_PASSWORD}" <<-EOSQL + REVOKE ALL PRIVILEGES ON \`${MARIADB_DATABASE}\`.* FROM '${MARIADB_USER}'@'%'; + GRANT SELECT, INSERT, UPDATE, DELETE, SHOW VIEW, TRIGGER, LOCK TABLES + ON \`${MARIADB_DATABASE}\`.* TO '${MARIADB_USER}'@'%'; + FLUSH PRIVILEGES; +EOSQL + +echo "[init] privilege du user '${MARIADB_USER}' restreint au moindre privilege sur '${MARIADB_DATABASE}'." diff --git a/db/migrate-container.sh b/db/migrate-container.sh new file mode 100644 index 0000000..a80952b --- /dev/null +++ b/db/migrate-container.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Wakdo - runner migrations + seed IN-CONTAINER (service compose one-shot wakdo-migrate). +# +# Applique, dans l'ordre lexicographique et de maniere IDEMPOTENTE : +# 1. db/migrations/*.sql (suivi : table schema_migrations) +# 2. db/seeds/*.sql (suivi : table seeds_applied) +# Relancer ne rejoue que les fichiers en attente (tracking par nom de fichier). +# +# Contrairement a db/migrate.sh (hote, via `docker exec`), ce runner tourne DANS +# un conteneur et se connecte a la base PAR LE RESEAU compose (DB_HOST). Il est +# lance par le service `wakdo-migrate` apres que `wakdo-db` soit healthy ; les +# services applicatifs (app/web) attendent sa COMPLETION (service_completed_successfully). +# +# But : `docker compose up` amene une stack COMPLETE et utilisable (schema + donnees +# de reference, dont l'admin bootstrap) en une seule commande, sans dependance a +# l'hote (Cr 7.c.4) -> remplace `make init`. +# +# Variables injectees par docker-compose : DB_HOST, DB_PORT, DB_NAME, DB_ROOT_PASSWORD. +# Root requis : migrations = DDL, seeds = INSERT de reference. +# +set -euo pipefail + +: "${DB_HOST:?DB_HOST manquant}" +: "${DB_NAME:?DB_NAME manquant}" +: "${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD manquant}" +PORT="${DB_PORT:-3306}" + +db() { mariadb -h "$DB_HOST" -P "$PORT" -uroot -p"$DB_ROOT_PASSWORD" "$@"; } + +# Applique les *.sql d'un dossier non encore enregistres dans sa table de suivi. +apply_tracked() { + local dir="$1" table="$2" + local f base n applied=0 + + db "$DB_NAME" -e "CREATE TABLE IF NOT EXISTS ${table} ( + filename VARCHAR(255) NOT NULL PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;" + + shopt -s nullglob + local files=("$dir"/*.sql) + if [ ${#files[@]} -eq 0 ]; then + echo "[${table}] aucun fichier dans ${dir}" + return 0 + fi + + for f in "${files[@]}"; do + base="$(basename "$f")" + n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM ${table} WHERE filename='${base}';")" + if [ "$n" = "0" ]; then + echo "[${table}] application de ${base} ..." + db "$DB_NAME" < "$f" + db "$DB_NAME" -e "INSERT INTO ${table} (filename) VALUES ('${base}');" + applied=$((applied + 1)) + else + echo "[${table}] ${base} deja applique, ignore" + fi + done + echo "[${table}] termine (${applied} nouveau(x))." +} + +echo "[migrate] cible ${DB_HOST}:${PORT}/${DB_NAME}" +apply_tracked /db/migrations schema_migrations +apply_tracked /db/seeds seeds_applied +echo "[migrate] stack a jour (schema + donnees de reference)." diff --git a/db/migrate.sh b/db/migrate.sh new file mode 100755 index 0000000..5edd909 --- /dev/null +++ b/db/migrate.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# Wakdo - migration runner. +# +# Applique les fichiers db/migrations/*.sql dans l'ordre lexicographique, +# de maniere idempotente : une table schema_migrations enregistre les fichiers +# deja appliques, donc relancer ne rejoue que les nouvelles migrations. +# +# Cible : le service docker-compose `wakdo-db` (MariaDB). Lance depuis l'hote +# (usage manuel / `--status`, identifiants lus dans .env). Au boot de la stack, +# c'est le service `wakdo-migrate` (db/migrate-container.sh, via le reseau) qui +# applique migrations + seed automatiquement. +# +# Usage : +# bash db/migrate.sh # applique les migrations en attente +# bash db/migrate.sh --status # liste l'etat sans rien appliquer +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="$ROOT/.env" +CONTAINER="${WAKDO_DB_CONTAINER:-wakdo-db}" +MIGRATIONS_DIR="$ROOT/db/migrations" + +[ -f "$ENV_FILE" ] || { echo "ERREUR : .env introuvable ($ENV_FILE)" >&2; exit 1; } +DB_NAME="$(grep -E '^DB_NAME=' "$ENV_FILE" | cut -d= -f2- | tr -d '[:space:]')" +DB_ROOT_PASSWORD="$(grep -E '^DB_ROOT_PASSWORD=' "$ENV_FILE" | cut -d= -f2-)" +: "${DB_NAME:?DB_NAME absent de .env}" +: "${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD absent de .env}" + +# Client mariadb dans le conteneur (root : les migrations sont des operations DDL). +db() { docker exec -i "$CONTAINER" mariadb -uroot -p"$DB_ROOT_PASSWORD" "$@"; } + +# Le conteneur doit etre en marche. +docker exec "$CONTAINER" true 2>/dev/null || { echo "ERREUR : conteneur $CONTAINER non demarre (docker compose up -d)" >&2; exit 1; } + +# Journal des migrations appliquees. +db "$DB_NAME" -e "CREATE TABLE IF NOT EXISTS schema_migrations ( + filename VARCHAR(255) NOT NULL PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;" + +shopt -s nullglob +files=("$MIGRATIONS_DIR"/*.sql) +[ ${#files[@]} -gt 0 ] || { echo "[migrate] aucune migration dans $MIGRATIONS_DIR"; exit 0; } + +if [ "${1:-}" = "--status" ]; then + echo "[migrate] etat des migrations (base $DB_NAME) :" + for f in "${files[@]}"; do + base="$(basename "$f")" + n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM schema_migrations WHERE filename='$base';")" + [ "$n" = "0" ] && echo " PENDING $base" || echo " applied $base" + done + exit 0 +fi + +applied=0 +for f in "${files[@]}"; do + base="$(basename "$f")" + n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM schema_migrations WHERE filename='$base';")" + if [ "$n" = "0" ]; then + echo "[migrate] application de $base ..." + db "$DB_NAME" < "$f" + db "$DB_NAME" -e "INSERT INTO schema_migrations (filename) VALUES ('$base');" + applied=$((applied + 1)) + else + echo "[migrate] $base deja applique, ignore" + fi +done +echo "[migrate] termine ($applied nouvelle(s) migration(s) appliquee(s))." diff --git a/db/migrations/0001_init_schema.sql b/db/migrations/0001_init_schema.sql new file mode 100644 index 0000000..9e564d8 --- /dev/null +++ b/db/migrations/0001_init_schema.sql @@ -0,0 +1,465 @@ +-- ============================================================================= +-- Wakdo — Initial schema (DDL) +-- ============================================================================= +-- Purpose : Create the 21-table relational schema for the Wakdo fast-food +-- ordering system (catalogue, ingredients/stock, orders, RBAC, +-- security-by-design layer). +-- Source : docs/merise/mld.md (MLD v0.2 — prod-like, 21 tables) + +-- docs/merise/dictionary.md (data dictionary v0.2, types source of truth). +-- Phase : P2 — generated from the validated Logical Data Model (P1 conception). +-- Target : MariaDB 11.4 LTS, engine InnoDB, charset utf8mb4, collation +-- utf8mb4_unicode_ci. +-- +-- Notes derived from the MLD: +-- - All technical PKs are INT UNSIGNED AUTO_INCREMENT. +-- - Monetary amounts are INT UNSIGNED in cents (anti-FLOAT, dict. note 1). +-- - vat_rate stored per-mille (55 = 5.5%, 100 = 10%). +-- - service_day is NOT a stored/generated column (decision D6): computed in +-- the application layer. +-- - No CREATE DATABASE / USE here: the target DB is chosen by the runner. +-- - No seed / INSERT data here (see db/seeds/0001_demo_data.sql). +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; +SET @OLD_SQL_MODE = @@SQL_MODE; +SET SQL_MODE = 'STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION,NO_AUTO_VALUE_ON_ZERO'; +SET @OLD_FOREIGN_KEY_CHECKS = @@FOREIGN_KEY_CHECKS; +SET FOREIGN_KEY_CHECKS = 0; + +-- ----------------------------------------------------------------------------- +-- 4.1 category — root table for the Catalogue sub-domain (no FK) +-- ----------------------------------------------------------------------------- +CREATE TABLE category ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(60) NOT NULL, + slug VARCHAR(60) NOT NULL, + image_path VARCHAR(255) NULL, + display_order SMALLINT UNSIGNED NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_category_name (name), + UNIQUE KEY uk_category_slug (slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.6 ingredient — root table for Ingredients & Stock (no FK) +-- ----------------------------------------------------------------------------- +CREATE TABLE ingredient ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(120) NOT NULL, + unit VARCHAR(40) NOT NULL, + stock_quantity INT NOT NULL DEFAULT 0, + stock_capacity INT NOT NULL, + pack_size SMALLINT UNSIGNED NOT NULL DEFAULT 1, + pack_label VARCHAR(80) NULL, + low_stock_pct SMALLINT UNSIGNED NOT NULL DEFAULT 10, + critical_stock_pct SMALLINT UNSIGNED NOT NULL DEFAULT 5, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_ingredient_name (name), + CONSTRAINT chk_ingredient_stock_capacity CHECK (stock_capacity > 0), + CONSTRAINT chk_ingredient_pack_size CHECK (pack_size > 0), + CONSTRAINT chk_ingredient_low_stock_pct CHECK (low_stock_pct BETWEEN 0 AND 100), + CONSTRAINT chk_ingredient_critical_stock_pct CHECK (critical_stock_pct BETWEEN 0 AND 100), + CONSTRAINT chk_ingredient_critical_lt_low CHECK (critical_stock_pct < low_stock_pct) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.8 allergen — reference table (INCO EU 1169/2011), no FK +-- ----------------------------------------------------------------------------- +CREATE TABLE allergen ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(30) NOT NULL, + name VARCHAR(80) NOT NULL, + description TEXT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_allergen_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.10 role — root table for RBAC (no FK) +-- ----------------------------------------------------------------------------- +CREATE TABLE role ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(40) NOT NULL, + label VARCHAR(80) NOT NULL, + description TEXT NULL, + default_route VARCHAR(120) NULL, + order_source ENUM('kiosk','counter','drive') NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_role_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.13 permission — reference table, catalogue frozen at 23 codes (no FK) +-- ----------------------------------------------------------------------------- +CREATE TABLE permission ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(60) NOT NULL, + label VARCHAR(120) NOT NULL, + description TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_permission_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.21 login_throttle — per-source-IP brute-force throttle (no FK) +-- ----------------------------------------------------------------------------- +CREATE TABLE login_throttle ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + ip_address VARCHAR(45) NOT NULL, + failed_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, + window_started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + lockout_until DATETIME NULL, + last_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_login_throttle_ip_address (ip_address), + KEY idx_login_throttle_lockout_until (lockout_until) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.2 product — depends on category +-- ----------------------------------------------------------------------------- +CREATE TABLE product ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + category_id INT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + description TEXT NULL, + price_cents INT UNSIGNED NOT NULL, + vat_rate SMALLINT UNSIGNED NOT NULL DEFAULT 100, + image_path VARCHAR(255) NULL, + is_available TINYINT(1) NOT NULL DEFAULT 1, + display_order SMALLINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_product_category_available_order (category_id, is_available, display_order), + CONSTRAINT fk_product_category_id FOREIGN KEY (category_id) + REFERENCES category (id) ON DELETE RESTRICT, + CONSTRAINT chk_product_price_cents CHECK (price_cents > 0), + CONSTRAINT chk_product_vat_rate CHECK (vat_rate IN (55, 100)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.3 menu — depends on category, product +-- ----------------------------------------------------------------------------- +CREATE TABLE menu ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + category_id INT UNSIGNED NOT NULL, + burger_product_id INT UNSIGNED NOT NULL, + name VARCHAR(120) NOT NULL, + description TEXT NULL, + price_normal_cents INT UNSIGNED NOT NULL, + price_maxi_cents INT UNSIGNED NOT NULL, + image_path VARCHAR(255) NULL, + is_available TINYINT(1) NOT NULL DEFAULT 1, + display_order SMALLINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_menu_category_available_order (category_id, is_available, display_order), + CONSTRAINT fk_menu_category_id FOREIGN KEY (category_id) + REFERENCES category (id) ON DELETE RESTRICT, + CONSTRAINT fk_menu_burger_product_id FOREIGN KEY (burger_product_id) + REFERENCES product (id) ON DELETE RESTRICT, + CONSTRAINT chk_menu_price_normal_cents CHECK (price_normal_cents > 0), + CONSTRAINT chk_menu_price_maxi_cents CHECK (price_maxi_cents > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.4 menu_slot — depends on menu (no audit fields) +-- ----------------------------------------------------------------------------- +CREATE TABLE menu_slot ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + menu_id INT UNSIGNED NOT NULL, + name VARCHAR(80) NOT NULL, + slot_type ENUM('drink','side','sauce','dessert','extra') NOT NULL, + is_required TINYINT(1) NOT NULL DEFAULT 1, + display_order SMALLINT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_menu_slot_menu_order (menu_id, display_order), + CONSTRAINT fk_menu_slot_menu_id FOREIGN KEY (menu_id) + REFERENCES menu (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.5 menu_slot_option — pure join table, composite PK +-- depends on menu_slot, product +-- ----------------------------------------------------------------------------- +CREATE TABLE menu_slot_option ( + menu_slot_id INT UNSIGNED NOT NULL, + product_id INT UNSIGNED NOT NULL, + PRIMARY KEY (menu_slot_id, product_id), + KEY idx_menu_slot_option_product_id (product_id), + CONSTRAINT fk_menu_slot_option_menu_slot_id FOREIGN KEY (menu_slot_id) + REFERENCES menu_slot (id) ON DELETE CASCADE, + CONSTRAINT fk_menu_slot_option_product_id FOREIGN KEY (product_id) + REFERENCES product (id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.7 product_ingredient — join table with attributes, composite PK +-- depends on product, ingredient +-- ----------------------------------------------------------------------------- +CREATE TABLE product_ingredient ( + product_id INT UNSIGNED NOT NULL, + ingredient_id INT UNSIGNED NOT NULL, + quantity_normal SMALLINT UNSIGNED NOT NULL DEFAULT 1, + quantity_maxi SMALLINT UNSIGNED NOT NULL DEFAULT 1, + is_removable TINYINT(1) NOT NULL DEFAULT 1, + is_addable TINYINT(1) NOT NULL DEFAULT 0, + extra_price_cents INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (product_id, ingredient_id), + KEY idx_product_ingredient_ingredient_id (ingredient_id), + CONSTRAINT fk_product_ingredient_product_id FOREIGN KEY (product_id) + REFERENCES product (id) ON DELETE CASCADE, + CONSTRAINT fk_product_ingredient_ingredient_id FOREIGN KEY (ingredient_id) + REFERENCES ingredient (id) ON DELETE RESTRICT, + CONSTRAINT chk_product_ingredient_quantity_normal CHECK (quantity_normal > 0), + CONSTRAINT chk_product_ingredient_quantity_maxi CHECK (quantity_maxi >= quantity_normal), + CONSTRAINT chk_product_ingredient_extra_price CHECK (extra_price_cents >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.9 ingredient_allergen — pure join table, composite PK +-- depends on ingredient, allergen +-- ----------------------------------------------------------------------------- +CREATE TABLE ingredient_allergen ( + ingredient_id INT UNSIGNED NOT NULL, + allergen_id INT UNSIGNED NOT NULL, + PRIMARY KEY (ingredient_id, allergen_id), + KEY idx_ingredient_allergen_allergen_id (allergen_id), + CONSTRAINT fk_ingredient_allergen_ingredient_id FOREIGN KEY (ingredient_id) + REFERENCES ingredient (id) ON DELETE CASCADE, + CONSTRAINT fk_ingredient_allergen_allergen_id FOREIGN KEY (allergen_id) + REFERENCES allergen (id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.11 user — depends on role +-- ----------------------------------------------------------------------------- +CREATE TABLE user ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + email VARCHAR(254) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + pin_hash VARCHAR(255) NULL, + first_name VARCHAR(60) NOT NULL, + last_name VARCHAR(60) NOT NULL, + role_id INT UNSIGNED NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + last_login_at DATETIME NULL, + failed_login_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, + last_failed_login_at DATETIME NULL, + lockout_until DATETIME NULL, + password_reset_token_hash VARCHAR(255) NULL, + password_reset_expires_at DATETIME NULL, + anonymized_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_user_email (email), + KEY idx_user_active_role (is_active, role_id), + CONSTRAINT fk_user_role_id FOREIGN KEY (role_id) + REFERENCES role (id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.12 role_visible_source — pure join table, composite PK +-- depends on role +-- ----------------------------------------------------------------------------- +CREATE TABLE role_visible_source ( + role_id INT UNSIGNED NOT NULL, + source ENUM('kiosk','counter','drive') NOT NULL, + PRIMARY KEY (role_id, source), + CONSTRAINT fk_role_visible_source_role_id FOREIGN KEY (role_id) + REFERENCES role (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.14 role_permission — pure join table, composite PK +-- depends on role, permission +-- ----------------------------------------------------------------------------- +CREATE TABLE role_permission ( + role_id INT UNSIGNED NOT NULL, + permission_id INT UNSIGNED NOT NULL, + PRIMARY KEY (role_id, permission_id), + KEY idx_role_permission_permission_id (permission_id), + CONSTRAINT fk_role_permission_role_id FOREIGN KEY (role_id) + REFERENCES role (id) ON DELETE CASCADE, + CONSTRAINT fk_role_permission_permission_id FOREIGN KEY (permission_id) + REFERENCES permission (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.15 customer_order — depends on user (acting_user_id) +-- ----------------------------------------------------------------------------- +CREATE TABLE customer_order ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + order_number VARCHAR(20) NOT NULL, + idempotency_key VARCHAR(36) NULL, + source ENUM('kiosk','counter','drive') NOT NULL, + acting_user_id INT UNSIGNED NULL, + service_mode ENUM('dine_in','takeaway','drive') NOT NULL, + status ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment', + total_ht_cents INT UNSIGNED NOT NULL, + total_vat_cents INT UNSIGNED NOT NULL, + total_ttc_cents INT UNSIGNED NOT NULL, + paid_at DATETIME NULL, + delivered_at DATETIME NULL, + cancelled_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_customer_order_order_number (order_number), + UNIQUE KEY uk_customer_order_idempotency_key (idempotency_key), + KEY idx_customer_order_status_created (status, created_at), + KEY idx_customer_order_source_created (source, created_at), + KEY idx_customer_order_created (created_at), + CONSTRAINT fk_customer_order_acting_user_id FOREIGN KEY (acting_user_id) + REFERENCES user (id) ON DELETE SET NULL, + CONSTRAINT chk_customer_order_total_ht CHECK (total_ht_cents >= 0), + CONSTRAINT chk_customer_order_total_vat CHECK (total_vat_cents >= 0), + CONSTRAINT chk_customer_order_total_ttc CHECK (total_ttc_cents > 0), + CONSTRAINT chk_customer_order_total_coherent CHECK (total_ttc_cents = total_ht_cents + total_vat_cents), + CONSTRAINT chk_customer_order_drive_mode CHECK (source <> 'drive' OR service_mode = 'drive') +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.16 order_item — depends on customer_order, product, menu +-- polymorphic line (product XOR menu) +-- ----------------------------------------------------------------------------- +CREATE TABLE order_item ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id INT UNSIGNED NOT NULL, + item_type ENUM('product','menu') NOT NULL, + product_id INT UNSIGNED NULL, + menu_id INT UNSIGNED NULL, + format ENUM('normal','maxi') NOT NULL DEFAULT 'normal', + label_snapshot VARCHAR(120) NOT NULL, + unit_price_cents_snapshot INT UNSIGNED NOT NULL, + vat_rate_snapshot SMALLINT UNSIGNED NOT NULL, + quantity SMALLINT UNSIGNED NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_order_item_order_id (order_id), + KEY idx_order_item_product_id (product_id), + KEY idx_order_item_menu_id (menu_id), + CONSTRAINT fk_order_item_order_id FOREIGN KEY (order_id) + REFERENCES customer_order (id) ON DELETE CASCADE, + CONSTRAINT fk_order_item_product_id FOREIGN KEY (product_id) + REFERENCES product (id) ON DELETE RESTRICT, + CONSTRAINT fk_order_item_menu_id FOREIGN KEY (menu_id) + REFERENCES menu (id) ON DELETE RESTRICT, + CONSTRAINT chk_order_item_unit_price CHECK (unit_price_cents_snapshot > 0), + CONSTRAINT chk_order_item_vat_rate CHECK (vat_rate_snapshot IN (55, 100)), + CONSTRAINT chk_order_item_quantity CHECK (quantity > 0), + CONSTRAINT chk_order_item_polymorphism CHECK ( + (item_type = 'product' AND product_id IS NOT NULL AND menu_id IS NULL) + OR (item_type = 'menu' AND menu_id IS NOT NULL AND product_id IS NULL) + ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.17 order_item_selection — depends on order_item, menu_slot, product +-- ----------------------------------------------------------------------------- +CREATE TABLE order_item_selection ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + order_item_id INT UNSIGNED NOT NULL, + menu_slot_id INT UNSIGNED NOT NULL, + product_id INT UNSIGNED NOT NULL, + label_snapshot VARCHAR(120) NOT NULL, + PRIMARY KEY (id), + KEY idx_order_item_selection_order_item_id (order_item_id), + KEY idx_order_item_selection_menu_slot_id (menu_slot_id), + KEY idx_order_item_selection_product_id (product_id), + CONSTRAINT fk_order_item_selection_order_item_id FOREIGN KEY (order_item_id) + REFERENCES order_item (id) ON DELETE CASCADE, + CONSTRAINT fk_order_item_selection_menu_slot_id FOREIGN KEY (menu_slot_id) + REFERENCES menu_slot (id) ON DELETE RESTRICT, + CONSTRAINT fk_order_item_selection_product_id FOREIGN KEY (product_id) + REFERENCES product (id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.18 order_item_modifier — depends on order_item, ingredient +-- ----------------------------------------------------------------------------- +CREATE TABLE order_item_modifier ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + order_item_id INT UNSIGNED NOT NULL, + ingredient_id INT UNSIGNED NOT NULL, + action ENUM('remove','add') NOT NULL, + extra_price_cents INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY idx_order_item_modifier_order_item_id (order_item_id), + KEY idx_order_item_modifier_ingredient_id (ingredient_id), + CONSTRAINT fk_order_item_modifier_order_item_id FOREIGN KEY (order_item_id) + REFERENCES order_item (id) ON DELETE CASCADE, + CONSTRAINT fk_order_item_modifier_ingredient_id FOREIGN KEY (ingredient_id) + REFERENCES ingredient (id) ON DELETE RESTRICT, + CONSTRAINT chk_order_item_modifier_extra_price CHECK (extra_price_cents >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.19 stock_movement — append-only audit log +-- depends on ingredient, customer_order, user +-- ----------------------------------------------------------------------------- +CREATE TABLE stock_movement ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + ingredient_id INT UNSIGNED NOT NULL, + movement_type ENUM('sale','cancellation','restock','inventory_correction') NOT NULL, + delta INT NOT NULL, + order_id INT UNSIGNED NULL, + user_id INT UNSIGNED NULL, + note VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_stock_movement_ingredient_created (ingredient_id, created_at), + KEY idx_stock_movement_type_created (movement_type, created_at), + KEY idx_stock_movement_order_id (order_id), + KEY idx_stock_movement_user_id (user_id), + CONSTRAINT fk_stock_movement_ingredient_id FOREIGN KEY (ingredient_id) + REFERENCES ingredient (id) ON DELETE RESTRICT, + CONSTRAINT fk_stock_movement_order_id FOREIGN KEY (order_id) + REFERENCES customer_order (id) ON DELETE SET NULL, + CONSTRAINT fk_stock_movement_user_id FOREIGN KEY (user_id) + REFERENCES user (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 4.20 audit_log — append-only sensitive-action log +-- depends on user, role +-- ----------------------------------------------------------------------------- +CREATE TABLE audit_log ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + actor_user_id INT UNSIGNED NULL, + actor_role_id INT UNSIGNED NULL, + action_code VARCHAR(60) NOT NULL, + entity_type VARCHAR(40) NULL, + entity_id INT UNSIGNED NULL, + summary VARCHAR(255) NULL, + details JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_audit_log_actor_created (actor_user_id, created_at), + KEY idx_audit_log_entity (entity_type, entity_id), + KEY idx_audit_log_action_created (action_code, created_at), + KEY idx_audit_log_actor_role_id (actor_role_id), + CONSTRAINT fk_audit_log_actor_user_id FOREIGN KEY (actor_user_id) + REFERENCES user (id) ON DELETE SET NULL, + CONSTRAINT fk_audit_log_actor_role_id FOREIGN KEY (actor_role_id) + REFERENCES role (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================================= +-- Restore session settings +-- ============================================================================= +SET FOREIGN_KEY_CHECKS = @OLD_FOREIGN_KEY_CHECKS; +SET SQL_MODE = @OLD_SQL_MODE; diff --git a/db/migrations/0002_pin_throttle.sql b/db/migrations/0002_pin_throttle.sql new file mode 100644 index 0000000..3746fc2 --- /dev/null +++ b/db/migrations/0002_pin_throttle.sql @@ -0,0 +1,39 @@ +-- db/migrations/0002_pin_throttle.sql +-- ============================================================================= +-- Wakdo - Migration 0002 : pin_throttle (entite 22, RG-T22) +-- ============================================================================= +-- Purpose : Throttle des tentatives de PIN d'action sensible, par UTILISATEUR +-- AGISSANT (identite de session authentifiee, GuardResult->userId). +-- STRICTEMENT SEPARE des compteurs de connexion +-- (user.failed_login_attempts / user.lockout_until / login_throttle) +-- pour qu'un echec de PIN ne verrouille jamais la CONNEXION d'un +-- compte (pas d'escalade DoS sur la surface plus sensible). Sibling de +-- login_throttle (4.21) : meme forme, dimension differente (l'acteur, +-- pas l'IP). Le runner db/migrate.sh applique *.sql dans l'ordre +-- lexicographique via la table schema_migrations. +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE pin_throttle ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + actor_user_id INT UNSIGNED NOT NULL, + failed_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, + window_started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + lockout_until DATETIME NULL, + last_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_pin_throttle_actor_user_id (actor_user_id), + KEY idx_pin_throttle_lockout_until (lockout_until), + CONSTRAINT fk_pin_throttle_actor_user_id FOREIGN KEY (actor_user_id) + REFERENCES user (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Note : pas de seed. La cle est l'acteur (un user back-office authentifie), donc +-- la FK ON DELETE CASCADE est sure (contrairement a login_throttle, dont la cle +-- est une IP arbitraire et qui n'a pas de FK). La purge cron des lignes sans +-- verrou actif au-dela de THROTTLE_PURGE_AFTER_HOURS s'aligne sur login_throttle : +-- DELETE FROM pin_throttle +-- WHERE (lockout_until IS NULL OR lockout_until < NOW()) +-- AND last_attempt_at < NOW() - INTERVAL HOUR; diff --git a/db/migrations/0003_order_service_tag.sql b/db/migrations/0003_order_service_tag.sql new file mode 100644 index 0000000..13c5c98 --- /dev/null +++ b/db/migrations/0003_order_service_tag.sql @@ -0,0 +1,17 @@ +-- db/migrations/0003_order_service_tag.sql +-- ============================================================================= +-- Wakdo - Migration 0003 : service_tag (numero de chevalet) sur customer_order +-- ============================================================================= +-- Purpose : numero de chevalet pour le service EN SALLE (mode dine_in / sur place). +-- Saisi a la borne quand le client choisit "sur place" ; permet au +-- service d'apporter la commande a la bonne table (B4). NULL pour +-- takeaway / drive. Colonne additive nullable (aucune donnee existante +-- a retro-remplir). Le runner applique *.sql dans l'ordre lexicographique +-- via schema_migrations. +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE customer_order + ADD COLUMN service_tag VARCHAR(20) NULL AFTER service_mode; diff --git a/db/migrations/0005_ingredient_nutrition.sql b/db/migrations/0005_ingredient_nutrition.sql new file mode 100644 index 0000000..838233a --- /dev/null +++ b/db/migrations/0005_ingredient_nutrition.sql @@ -0,0 +1,25 @@ +-- db/migrations/0005_ingredient_nutrition.sql +-- ============================================================================= +-- Wakdo - Migration 0005 : enrichissement nutritionnel depuis une API EXTERNE +-- ============================================================================= +-- Purpose : ajoute a `ingredient` des colonnes nullables pour stocker des donnees +-- nutritionnelles importees depuis une API TIERCE (OpenFoodFacts), a la +-- demande d'un manager/admin (action explicite, pas au runtime borne). +-- Demontre l'exploitation, DANS LE MODELE de donnees, d'informations +-- externes provenant d'une API (Cr 3.a.3). Egress maitrise et opt-in : +-- aucun appel automatique ; la passerelle (App\Catalogue\ +-- OpenFoodFactsGateway) est invoquee seulement par IngredientController::enrich. +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE ingredient + ADD COLUMN energy_kcal_100g SMALLINT UNSIGNED NULL AFTER pack_label, + ADD COLUMN nutrition_source VARCHAR(120) NULL AFTER energy_kcal_100g, + ADD COLUMN nutrition_fetched_at DATETIME NULL AFTER nutrition_source; + +-- energy_kcal_100g : apport energetique pour 100 g (SMALLINT UNSIGNED suffit ; les +-- valeurs reelles restent < 1000). nutrition_source : provenance ("OpenFoodFacts"). +-- nutrition_fetched_at : horodatage de l'import, pour tracer la fraicheur. Toutes +-- nullables : un ingredient non enrichi reste valide (donnee optionnelle). diff --git a/db/migrations/0006_product_maxi_variant.sql b/db/migrations/0006_product_maxi_variant.sql new file mode 100644 index 0000000..605c71d --- /dev/null +++ b/db/migrations/0006_product_maxi_variant.sql @@ -0,0 +1,32 @@ +-- db/migrations/0006_product_maxi_variant.sql +-- ============================================================================= +-- Wakdo - Migration 0006 : variante Maxi d'un produit (accompagnement de menu) +-- ============================================================================= +-- Purpose : ajoute a `product` une auto-reference nullable vers la variante +-- servie quand un menu est commande au format Maxi. L'accompagnement +-- de menu (slot_type='side') propose la version standard (ex. Moyenne +-- Frite, Potatoes) ; au format Maxi, le serveur substitue la variante +-- Grande (Grande Frite / Grande Potatoes) sans choix supplementaire. +-- Approche data-driven : la regle vit dans la donnee, pas dans le code, +-- et le decrement de stock (consumption()) frappe alors le bon produit. +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE product + ADD COLUMN maxi_variant_product_id INT UNSIGNED NULL AFTER price_cents, + ADD CONSTRAINT fk_product_maxi_variant_product_id FOREIGN KEY (maxi_variant_product_id) + REFERENCES product (id) ON DELETE SET NULL; + +-- maxi_variant_product_id : produit servi a la place de celui-ci quand le menu est +-- au format Maxi (ex. Moyenne Frite -> Grande Frite). Place AFTER price_cents : +-- regroupe avec les attributs de commercialisation du produit. Nullable : la +-- plupart des produits n'ont pas de variante Maxi (un produit sans variante reste +-- valide et n'est jamais substitue). +-- +-- ON DELETE SET NULL (et non RESTRICT) : si la variante Grande est supprimee du +-- catalogue, le produit de base reste vendable, il perd seulement sa substitution +-- Maxi (degradation gracieuse). RESTRICT bloquerait la suppression d'une Grande +-- referencee, ce qui n'est pas souhaitable : la reference est un confort metier, +-- pas une integrite forte de commande (les commandes figent deja leurs snapshots). diff --git a/db/migrations/0007_product_size_variant.sql b/db/migrations/0007_product_size_variant.sql new file mode 100644 index 0000000..44235f7 --- /dev/null +++ b/db/migrations/0007_product_size_variant.sql @@ -0,0 +1,53 @@ +-- db/migrations/0007_product_size_variant.sql +-- ============================================================================= +-- Wakdo - Migration 0007 : variante de TAILLE d'un produit (boisson 30/50 cl) +-- ============================================================================= +-- Purpose : ajoute a `product` la dimension TAILLE des boissons fontaine (la +-- maquette borne propose 30 cl / 50 cl), modelisee comme des LIGNES +-- produit distinctes (meme approche que Moyenne/Grande Frite). Le +-- domaine commande facture deja par product_id : le flux de commande +-- reste inchange, la borne resout juste la taille choisie en product_id. +-- +-- Grouping DEDIE, distinct de maxi_variant_product_id (migration 0006) : +-- ce dernier pilote la substitution Maxi de l'accompagnement de menu +-- (resolveSelections) ; le reutiliser ferait basculer en 50 cl une +-- boisson 30 cl glissee dans un menu Maxi (effet de bord non voulu). +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Idempotence : meme garde information_schema que 0006 (re-jouable sans erreur). +-- On verifie l'absence de la colonne `size_cl` avant l'ALTER ; les deux colonnes +-- sont ajoutees ensemble, l'existence de l'une suffit donc a court-circuiter. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'product' AND column_name = 'size_cl' +); + +SET @ddl := IF( + @col_exists = 0, + 'ALTER TABLE product + ADD COLUMN size_cl SMALLINT UNSIGNED NULL AFTER price_cents, + ADD COLUMN base_product_id INT UNSIGNED NULL AFTER size_cl, + ADD CONSTRAINT fk_product_base_product_id FOREIGN KEY (base_product_id) + REFERENCES product (id) ON DELETE CASCADE', + 'DO 0' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- size_cl : volume en centilitres. NULL = le produit n'a pas de dimension taille +-- (bouteilles, produits non-boissons). La ligne de base (30) ET la variante (50) +-- portent toutes deux leur volume, pour que le picker affiche un libelle humain. +-- +-- base_product_id : auto-reference vers la ligne de base. NULL = produit de base +-- ou autonome (visible dans le catalogue) ; NON NULL = variante de taille du +-- produit reference (masquee de la grille catalogue, atteinte via le picker). +-- +-- ON DELETE CASCADE (et non SET NULL comme 0006) : une variante de taille n'a +-- AUCUN sens sans sa base (une "Coca Cola 50cl" orpheline n'est pas commercialisable), +-- alors que la substitution Maxi de 0006 est un confort optionnel survivant a la +-- perte de sa cible. Supprimer la base emporte donc ses variantes de taille. Les +-- commandes passees ne sont pas affectees (elles figent leurs snapshots, RG-T05). diff --git a/db/seed.sh b/db/seed.sh new file mode 100755 index 0000000..864ac8f --- /dev/null +++ b/db/seed.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Wakdo - seed runner. +# +# Applique les fichiers db/seeds/*.sql dans l'ordre lexicographique, de maniere +# idempotente : une table seed_history enregistre les fichiers deja charges. +# Les seeds doivent etre joues APRES les migrations (les tables doivent exister). +# +# Cible : le service docker-compose `wakdo-db`. Identifiants lus dans .env. +# +# Usage : +# bash db/seed.sh # charge les seeds en attente +# bash db/seed.sh --status # liste l'etat sans rien charger +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="$ROOT/.env" +CONTAINER="${WAKDO_DB_CONTAINER:-wakdo-db}" +SEEDS_DIR="$ROOT/db/seeds" + +[ -f "$ENV_FILE" ] || { echo "ERREUR : .env introuvable ($ENV_FILE)" >&2; exit 1; } +DB_NAME="$(grep -E '^DB_NAME=' "$ENV_FILE" | cut -d= -f2- | tr -d '[:space:]')" +DB_ROOT_PASSWORD="$(grep -E '^DB_ROOT_PASSWORD=' "$ENV_FILE" | cut -d= -f2-)" +: "${DB_NAME:?DB_NAME absent de .env}" +: "${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD absent de .env}" + +db() { docker exec -i "$CONTAINER" mariadb -uroot -p"$DB_ROOT_PASSWORD" "$@"; } + +docker exec "$CONTAINER" true 2>/dev/null || { echo "ERREUR : conteneur $CONTAINER non demarre (docker compose up -d)" >&2; exit 1; } + +if [ ! -d "$SEEDS_DIR" ]; then + echo "[seed] aucun repertoire db/seeds/ - rien a charger" + exit 0 +fi + +db "$DB_NAME" -e "CREATE TABLE IF NOT EXISTS seed_history ( + filename VARCHAR(255) NOT NULL PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;" + +shopt -s nullglob +files=("$SEEDS_DIR"/*.sql) +[ ${#files[@]} -gt 0 ] || { echo "[seed] aucun fichier seed dans $SEEDS_DIR"; exit 0; } + +if [ "${1:-}" = "--status" ]; then + echo "[seed] etat des seeds (base $DB_NAME) :" + for f in "${files[@]}"; do + base="$(basename "$f")" + n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seed_history WHERE filename='$base';")" + [ "$n" = "0" ] && echo " PENDING $base" || echo " loaded $base" + done + exit 0 +fi + +loaded=0 +for f in "${files[@]}"; do + base="$(basename "$f")" + n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seed_history WHERE filename='$base';")" + if [ "$n" = "0" ]; then + echo "[seed] chargement de $base ..." + db "$DB_NAME" < "$f" + db "$DB_NAME" -e "INSERT INTO seed_history (filename) VALUES ('$base');" + loaded=$((loaded + 1)) + else + echo "[seed] $base deja charge, ignore" + fi +done +echo "[seed] termine ($loaded nouveau(x) seed(s) charge(s))." diff --git a/db/seeds/0001_rbac_and_reference.sql b/db/seeds/0001_rbac_and_reference.sql new file mode 100644 index 0000000..0a4f17c --- /dev/null +++ b/db/seeds/0001_rbac_and_reference.sql @@ -0,0 +1,190 @@ +-- ============================================================================= +-- Wakdo — Seed 0001 : RBAC + reference data + admin user +-- ============================================================================= +-- Purpose : Seed the foundational rows the back-office cannot boot without: +-- the 5 RBAC roles, the frozen catalogue of 23 permissions, the +-- default role/permission matrix, per-role visible order sources, +-- the 14 EU INCO allergens, and a single bootstrap admin user. +-- Source : docs/merise/dictionary.md (3.8 allergen, 3.15 role, 3.16 +-- role_visible_source, 3.17 permission catalogue + default grants, +-- 3.18 role_permission), docs/merise/mct.md (operations 1-28), +-- docs/PROJECT_CONTEXT.md section 7 (role responsibilities) and +-- decision D5 (admin gets order.create / order.deliver ; manager +-- does NOT get order.cancel). +-- Phase : P2 — demo/reference seed, applied AFTER db/migrations/0001_init_schema.sql. +-- Target : MariaDB 11.4 LTS. Fed by db/seed.sh into the already-selected DB. +-- +-- Notes: +-- - Statements are ordered so every FK resolves: role and permission first, +-- then role_permission / role_visible_source, then user (FK -> role). +-- - role_permission rows use subqueries on role.code and permission.code so +-- no surrogate ids are hardcoded (robust to AUTO_INCREMENT gaps). +-- - admin/manager get no role_visible_source rows: they have a global view of +-- all sources (the absence of rows means "no source filter applied"). +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 1. role (5) — dictionary.md 3.15 +-- order_source: counter/drive auto-tag their own source; admin/manager NULL +-- (they may create on behalf of any channel); kitchen NULL (read-only on +-- orders, never creates one). +-- ----------------------------------------------------------------------------- +INSERT INTO role (code, label, description, default_route, order_source, is_active) VALUES + ('admin', 'Administrator', 'Full back-office access: complete catalogue CRUD (incl. deletes), user/role/permission (RBAC) management, stock, stats, order create/deliver/cancel.', '/admin/dashboard', NULL, 1), + ('manager', 'Manager', 'Catalogue create/update, ingredient and stock management (restock + inventory), statistics. No user/RBAC administration, no order cancellation.', '/admin/stats', NULL, 1), + ('kitchen', 'Kitchen Staff', 'Read-only kitchen display (KDS) of paid orders sorted by paid_at ascending, plus inventory counting. Performs no order status transition.', '/kitchen/display', NULL, 1), + ('counter', 'Counter Staff', 'Takes orders at the counter, delivers them to the customer, can cancel. Inventory counting. source auto-tagged as counter.', '/counter/orders', 'counter', 1), + ('drive', 'Drive Staff', 'Takes orders at the drive-thru (intercom + headset), delivers them, can cancel. Inventory counting. source auto-tagged as drive.', '/drive/orders', 'drive', 1); + +-- ----------------------------------------------------------------------------- +-- 2. permission (23) — frozen catalogue, dictionary.md 3.17. +-- code format .. The catalogue is fixed at the seed and +-- never created through the UI (only assigned to roles via MANAGE_RBAC). +-- ----------------------------------------------------------------------------- +INSERT INTO permission (code, label, description) VALUES + ('product.create', 'Create product', 'Create a new catalogue product.'), + ('product.read', 'Read products', 'View products in the back-office and on order screens.'), + ('product.update', 'Update product', 'Edit an existing product (name, price, VAT, availability, etc.).'), + ('product.delete', 'Delete product', 'Permanently delete a product when no FK references block it.'), + ('menu.create', 'Create menu', 'Create a new menu with its slot configuration.'), + ('menu.read', 'Read menus', 'View menus, slots and slot options.'), + ('menu.update', 'Update menu', 'Edit an existing menu and its slot configuration.'), + ('menu.delete', 'Delete menu', 'Permanently delete a menu when no historical order references it.'), + ('category.manage', 'Manage categories', 'Create, update or deactivate product/menu categories.'), + ('ingredient.manage', 'Manage ingredients', 'Manage ingredients, product composition and allergen mapping.'), + ('stock.read', 'Read stock', 'View ingredient stock levels and movement history.'), + ('stock.count', 'Count stock', 'Record a physical inventory count (inventory correction).'), + ('stock.manage', 'Manage stock', 'Record restocks (pack deliveries) and manage stock parameters.'), + ('order.read', 'Read orders', 'View orders and the preparation display.'), + ('order.create', 'Create order', 'Create an order at the counter or drive-thru.'), + ('order.deliver', 'Deliver order', 'Mark a paid order as delivered (single-gesture handover).'), + ('order.cancel', 'Cancel order', 'Cancel a pending or paid order (restocks ingredients if paid).'), + ('user.create', 'Create user', 'Create a new back-office user.'), + ('user.read', 'Read users', 'View the list and details of back-office users.'), + ('user.update', 'Update user', 'Edit a back-office user (incl. password reset, RGPD anonymisation).'), + ('user.deactivate', 'Deactivate user', 'Deactivate a back-office user without deleting the row.'), + ('role.manage', 'Manage roles and RBAC', 'Manage roles, role/permission assignments and visible sources.'), + ('stats.read', 'Read statistics', 'Access the statistics / KPI dashboard.'); + +-- ----------------------------------------------------------------------------- +-- 3. role_permission — default matrix, dictionary.md 3.17 grants + PROJECT_CONTEXT +-- section 7 + decision D5. Subqueries on role.code / permission.code avoid +-- hardcoded ids. +-- ----------------------------------------------------------------------------- + +-- admin: ALL 23 permissions (cross join the admin role with the whole catalogue). +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r +CROSS JOIN permission p +WHERE r.code = 'admin'; + +-- manager: catalogue create/update + category/ingredient + full stock + stats. +-- NO order.* (incl. no order.cancel per D5), NO user/role admin. +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r +JOIN permission p ON p.code IN ( + 'product.create', 'product.read', 'product.update', + 'menu.create', 'menu.read', 'menu.update', + 'category.manage', 'ingredient.manage', + 'stock.read', 'stock.count', 'stock.manage', + 'user.read', + 'stats.read' +) +WHERE r.code = 'manager'; + +-- kitchen: read-only orders + read-only catalogue + inventory (read + count). +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r +JOIN permission p ON p.code IN ( + 'product.read', 'menu.read', + 'stock.read', 'stock.count', + 'order.read' +) +WHERE r.code = 'kitchen'; + +-- counter: read catalogue + full order lifecycle (read/create/deliver/cancel) +-- + inventory (read + count). +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r +JOIN permission p ON p.code IN ( + 'product.read', 'menu.read', + 'stock.read', 'stock.count', + 'order.read', 'order.create', 'order.deliver', 'order.cancel' +) +WHERE r.code = 'counter'; + +-- drive: identical grant set to counter (read catalogue + full order lifecycle +-- + inventory). The source differs (auto-tagged drive), not the rights. +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r +JOIN permission p ON p.code IN ( + 'product.read', 'menu.read', + 'stock.read', 'stock.count', + 'order.read', 'order.create', 'order.deliver', 'order.cancel' +) +WHERE r.code = 'drive'; + +-- ----------------------------------------------------------------------------- +-- 4. role_visible_source — dictionary.md 3.16. +-- kitchen sees all 3 sources; counter sees kiosk+counter; drive sees drive. +-- admin/manager: no rows -> global view (no source filter). +-- ----------------------------------------------------------------------------- +INSERT INTO role_visible_source (role_id, source) +SELECT r.id, s.source +FROM role r +JOIN ( + SELECT 'kitchen' AS role_code, 'kiosk' AS source UNION ALL + SELECT 'kitchen', 'counter' UNION ALL + SELECT 'kitchen', 'drive' UNION ALL + SELECT 'counter', 'kiosk' UNION ALL + SELECT 'counter', 'counter' UNION ALL + SELECT 'drive', 'drive' +) s ON s.role_code = r.code; + +-- ----------------------------------------------------------------------------- +-- 5. allergen (14) — EU INCO Regulation (EU) No 1169/2011, Annex II. +-- dictionary.md 3.8. code = machine code (en), name = French display label. +-- ----------------------------------------------------------------------------- +INSERT INTO allergen (code, name, description) VALUES + ('gluten', 'Gluten', 'Cereales contenant du gluten (ble, seigle, orge, avoine, epeautre, kamut) et produits a base de ces cereales.'), + ('crustaceans', 'Crustaces', 'Crustaces et produits a base de crustaces.'), + ('eggs', 'Oeufs', 'Oeufs et produits a base d''oeufs.'), + ('fish', 'Poisson', 'Poissons et produits a base de poissons.'), + ('peanuts', 'Arachides', 'Arachides et produits a base d''arachides.'), + ('soybeans', 'Soja', 'Soja et produits a base de soja.'), + ('milk', 'Lait', 'Lait et produits a base de lait (y compris le lactose).'), + ('nuts', 'Fruits a coque', 'Fruits a coque : amandes, noisettes, noix, noix de cajou, de pecan, du Bresil, pistaches, noix de Macadamia.'), + ('celery', 'Celeri', 'Celeri et produits a base de celeri.'), + ('mustard', 'Moutarde', 'Moutarde et produits a base de moutarde.'), + ('sesame', 'Graines de sesame', 'Graines de sesame et produits a base de graines de sesame.'), + ('sulphites', 'Anhydride sulfureux et sulfites', 'Anhydride sulfureux et sulfites en concentration superieure a 10 mg/kg ou 10 mg/l (exprimes en SO2).'), + ('lupin', 'Lupin', 'Lupin et produits a base de lupin.'), + ('molluscs', 'Mollusques', 'Mollusques et produits a base de mollusques.'); + +-- ----------------------------------------------------------------------------- +-- 6. user (1) — bootstrap administrator. dictionary.md 3.14. +-- role_id resolved from role.code = 'admin'. pin_hash NULL (no PIN set yet). +-- +-- DEV password: WakdoAdmin2026! (argon2id hash below, generated via +-- `docker exec wakdo-app php -r 'echo password_hash("WakdoAdmin2026!", +-- PASSWORD_ARGON2ID);'`). MUST be changed in production — this is a known +-- demo credential and must never reach a real deployment as-is. +-- ----------------------------------------------------------------------------- +INSERT INTO user (email, password_hash, pin_hash, first_name, last_name, role_id, is_active) +SELECT + 'admin@wakdo.local', + '$argon2id$v=19$m=65536,t=4,p=1$V3dVMi55cDVBYVZPMU1TRw$8iMoNyfC12t7V2CU+YgqwvEb3xNywm7PUSIoNMgRdvc', + NULL, + 'Wakdo', + 'Admin', + r.id, + 1 +FROM role r +WHERE r.code = 'admin'; diff --git a/db/seeds/0002_catalogue.sql b/db/seeds/0002_catalogue.sql new file mode 100644 index 0000000..c5c498c --- /dev/null +++ b/db/seeds/0002_catalogue.sql @@ -0,0 +1,195 @@ +-- ============================================================================= +-- Wakdo — Seed 0002 : Catalogue (reference / demo data) +-- ============================================================================= +-- Purpose : Populate the Catalogue sub-domain (category, product, menu, +-- menu_slot, menu_slot_option) from the school JSON sources. +-- Sources : docs/merise/_sources/categories.json (9 categories) +-- docs/merise/_sources/produits.json (menus + 53 products) +-- src/public/borne/data/produits.json (cents prices + clean paths, +-- used for cross-check) +-- Phase : P2 — demo seed, assumes a fresh schema (0001_init_schema.sql). +-- +-- Conventions: +-- - Monetary amounts are INT in CENTS (euros float x 100, rounded). +-- - vat_rate is per-mille: 100 = 10% (default), 55 = 5.5% for products in +-- resealable containers (bottled water, bottled juices) — dictionary note 9. +-- - image_path is a relative path under the public root, normalised to +-- assets/images/produits//.png (dictionary note 8). +-- - Menus go to the `menu` table (NOT `product`); every other category goes +-- to `product`. The "burgers" category items are the anchor products that +-- menus reference via burger_product_id. +-- - price_maxi_cents = price_normal_cents + 150 (Maxi format, +1.50 EUR). +-- - Foreign keys are resolved by subquery on natural keys (slug / name) +-- rather than hardcoded ids. +-- - Insertion order respects FK dependencies: +-- category -> product -> menu -> menu_slot -> menu_slot_option. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 1. category (9) — root table, source order = display_order +-- ----------------------------------------------------------------------------- +INSERT INTO category (name, slug, image_path, display_order, is_active) VALUES + ('menus', 'menus', 'assets/images/categories/menus.png', 1, 1), + ('boissons', 'boissons', 'assets/images/categories/boissons.png', 2, 1), + ('burgers', 'burgers', 'assets/images/categories/burgers.png', 3, 1), + ('frites', 'frites', 'assets/images/categories/frites.png', 4, 1), + ('encas', 'encas', 'assets/images/categories/encas.png', 5, 1), + ('wraps', 'wraps', 'assets/images/categories/wraps.png', 6, 1), + ('salades', 'salades', 'assets/images/categories/salades.png', 7, 1), + ('desserts', 'desserts', 'assets/images/categories/desserts.png', 8, 1), + ('sauces', 'sauces', 'assets/images/categories/sauces.png', 9, 1); + +-- ----------------------------------------------------------------------------- +-- 2. product — every non-menu item (53 rows) +-- category_id resolved via subquery on category.slug. +-- display_order follows source order within each category. +-- vat_rate defaults to 100; 55 only for resealable-container drinks +-- (Eau, Jus d'Orange, Jus de Pommes Bio) per dictionary note 9. +-- ----------------------------------------------------------------------------- + +-- 2.a burgers (anchor products for menus) +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='burgers'), 'Le 280', 680, 100, 'assets/images/produits/burgers/280.png', 1, 1), + ((SELECT id FROM category WHERE slug='burgers'), 'Big Tasty', 860, 100, 'assets/images/produits/burgers/big-tasty-1-viande.png', 1, 2), + ((SELECT id FROM category WHERE slug='burgers'), 'Big Tasty Bacon', 890, 100, 'assets/images/produits/burgers/big-tasty-bacon-1-viande.png', 1, 3), + ((SELECT id FROM category WHERE slug='burgers'), 'Big Mac', 600, 100, 'assets/images/produits/burgers/bigmac.png', 1, 4), + ((SELECT id FROM category WHERE slug='burgers'), 'CBO', 890, 100, 'assets/images/produits/burgers/cbo.png', 1, 5), + ((SELECT id FROM category WHERE slug='burgers'), 'MC Chicken', 730, 100, 'assets/images/produits/burgers/mcchicken.png', 1, 6), + ((SELECT id FROM category WHERE slug='burgers'), 'MC Crispy', 530, 100, 'assets/images/produits/burgers/mccrispy.png', 1, 7), + ((SELECT id FROM category WHERE slug='burgers'), 'MC Fish', 485, 100, 'assets/images/produits/burgers/mcfish.png', 1, 8), + ((SELECT id FROM category WHERE slug='burgers'), 'Royal Bacon', 510, 100, 'assets/images/produits/burgers/royalbacon.png', 1, 9), + ((SELECT id FROM category WHERE slug='burgers'), 'Royal Cheese', 440, 100, 'assets/images/produits/burgers/royalcheese.png', 1, 10), + ((SELECT id FROM category WHERE slug='burgers'), 'Royal Deluxe', 540, 100, 'assets/images/produits/burgers/royaldeluxe.png', 1, 11), + ((SELECT id FROM category WHERE slug='burgers'), 'Signature BBQ Beef 2 viandes', 1140, 100, 'assets/images/produits/burgers/signature-bbq-beef-2-viandes.png', 1, 12), + ((SELECT id FROM category WHERE slug='burgers'), 'Signature Beef BBQ', 1030, 100, 'assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png', 1, 13); + +-- 2.b boissons (Eau + the two bottled juices are resealable-container = vat_rate 55) +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='boissons'), 'Coca Cola', 190, 100, 'assets/images/produits/boissons/coca-cola.png', 1, 1), + ((SELECT id FROM category WHERE slug='boissons'), 'Coca Sans Sucres', 190, 100, 'assets/images/produits/boissons/coca-sans-sucres.png', 1, 2), + ((SELECT id FROM category WHERE slug='boissons'), 'Eau', 100, 55, 'assets/images/produits/boissons/eau.png', 1, 3), + ((SELECT id FROM category WHERE slug='boissons'), 'Fanta Orange', 190, 100, 'assets/images/produits/boissons/fanta.png', 1, 4), + ((SELECT id FROM category WHERE slug='boissons'), 'Ice Tea Peche', 190, 100, 'assets/images/produits/boissons/ice-tea-peche.png', 1, 5), + ((SELECT id FROM category WHERE slug='boissons'), 'Ice Tea Citron', 190, 100, 'assets/images/produits/boissons/the-vert-citron-sans-sucres.png', 1, 6), + ((SELECT id FROM category WHERE slug='boissons'), 'Jus d''Orange', 210, 55, 'assets/images/produits/boissons/jus-orange.png', 1, 7), + ((SELECT id FROM category WHERE slug='boissons'), 'Jus de Pommes Bio', 230, 55, 'assets/images/produits/boissons/jus-pomme-bio.png', 1, 8); + +-- 2.c frites +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='frites'), 'Petite Frite', 145, 100, 'assets/images/produits/frites/petite-frite.png', 1, 1), + ((SELECT id FROM category WHERE slug='frites'), 'Moyenne Frite', 275, 100, 'assets/images/produits/frites/moyenne-frite.png', 1, 2), + ((SELECT id FROM category WHERE slug='frites'), 'Grande Frite', 350, 100, 'assets/images/produits/frites/grande-frite.png', 1, 3), + ((SELECT id FROM category WHERE slug='frites'), 'Potatoes', 215, 100, 'assets/images/produits/frites/potatoes.png', 1, 4), + ((SELECT id FROM category WHERE slug='frites'), 'Grande Potatoes', 340, 100, 'assets/images/produits/frites/grande-potatoes.png', 1, 5); + +-- 2.d encas +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='encas'), 'Cheeseburger', 260, 100, 'assets/images/produits/encas/cheeseburger.png', 1, 1), + ((SELECT id FROM category WHERE slug='encas'), 'Croc MCdo', 320, 100, 'assets/images/produits/encas/croc-mc-do.png', 1, 2), + ((SELECT id FROM category WHERE slug='encas'), 'Nuggets x4', 420, 100, 'assets/images/produits/encas/nuggets-4.png', 1, 3), + ((SELECT id FROM category WHERE slug='encas'), 'Nuggets x20', 1300, 100, 'assets/images/produits/encas/nuggets-20.png', 1, 4); + +-- 2.e wraps +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='wraps'), 'MC Wrap Chevre', 310, 100, 'assets/images/produits/wraps/mcwrap-chevre.png', 1, 1), + ((SELECT id FROM category WHERE slug='wraps'), 'MC Wrap Poulet Bacon', 330, 100, 'assets/images/produits/wraps/mcwrap-poulet-bacon.png', 1, 2), + ((SELECT id FROM category WHERE slug='wraps'), 'Ptit Wrap Chevre', 260, 100, 'assets/images/produits/wraps/ptit-wrap-chevre.png', 1, 3), + ((SELECT id FROM category WHERE slug='wraps'), 'Ptit Wrap Ranch', 260, 100, 'assets/images/produits/wraps/ptit-wrap-ranch.png', 1, 4); + +-- 2.f salades +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='salades'), 'Petite Salade', 330, 100, 'assets/images/produits/salades/petite-salade.png', 1, 1), + ((SELECT id FROM category WHERE slug='salades'), 'Cesar Classic', 880, 100, 'assets/images/produits/salades/salade-classic-caesar.png', 1, 2), + ((SELECT id FROM category WHERE slug='salades'), 'Italienne Mozza', 880, 100, 'assets/images/produits/salades/salade-italian-mozza.png', 1, 3); + +-- 2.g desserts +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='desserts'), 'Brownie', 260, 100, 'assets/images/produits/desserts/brownies.png', 1, 1), + ((SELECT id FROM category WHERE slug='desserts'), 'Cheesecake chocolat M&M''S', 310, 100, 'assets/images/produits/desserts/cheesecake-choconuts-m&m-s.png', 1, 2), + ((SELECT id FROM category WHERE slug='desserts'), 'Cheesecake Fraise', 310, 100, 'assets/images/produits/desserts/cheesecake-fraise.png', 1, 3), + ((SELECT id FROM category WHERE slug='desserts'), 'Cookie', 320, 100, 'assets/images/produits/desserts/cookie.png', 1, 4), + ((SELECT id FROM category WHERE slug='desserts'), 'Donut', 260, 100, 'assets/images/produits/desserts/doghnut.png', 1, 5), + ((SELECT id FROM category WHERE slug='desserts'), 'Macarons', 270, 100, 'assets/images/produits/desserts/macarons.png', 1, 6), + ((SELECT id FROM category WHERE slug='desserts'), 'MC Fleury', 440, 100, 'assets/images/produits/desserts/mcfleury.png', 1, 7), + ((SELECT id FROM category WHERE slug='desserts'), 'Muffin', 360, 100, 'assets/images/produits/desserts/muffin.png', 1, 8), + ((SELECT id FROM category WHERE slug='desserts'), 'Sunday', 100, 100, 'assets/images/produits/desserts/sunday.png', 1, 9); + +-- 2.h sauces +INSERT INTO product (category_id, name, price_cents, vat_rate, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='sauces'), 'Classic Barbecue', 70, 100, 'assets/images/produits/sauces/classic-barbecue.png', 1, 1), + ((SELECT id FROM category WHERE slug='sauces'), 'Classic Moutarde', 70, 100, 'assets/images/produits/sauces/classic-moutarde.png', 1, 2), + ((SELECT id FROM category WHERE slug='sauces'), 'Creamy Deluxe', 70, 100, 'assets/images/produits/sauces/cremy-deluxe.png', 1, 3), + ((SELECT id FROM category WHERE slug='sauces'), 'Ketchup', 70, 100, 'assets/images/produits/sauces/ketchup.png', 1, 4), + ((SELECT id FROM category WHERE slug='sauces'), 'Chinoise', 70, 100, 'assets/images/produits/sauces/sauce-chinoise.png', 1, 5), + ((SELECT id FROM category WHERE slug='sauces'), 'Curry', 70, 100, 'assets/images/produits/sauces/sauce-curry.png', 1, 6), + ((SELECT id FROM category WHERE slug='sauces'), 'Pommes Frites', 70, 100, 'assets/images/produits/sauces/sauce-pommes-frite.png', 1, 7); + +-- ----------------------------------------------------------------------------- +-- 3. menu (13) — the "menus" category items. +-- category_id = the menus category. +-- burger_product_id resolved by matching the anchor burger name +-- ("Menu Le 280" -> product "Le 280", etc.). +-- price_normal_cents from source; price_maxi_cents = normal + 150. +-- ----------------------------------------------------------------------------- +INSERT INTO menu (category_id, burger_product_id, name, price_normal_cents, price_maxi_cents, image_path, is_available, display_order) VALUES + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Le 280'), 'Menu Le 280', 880, 1030, 'assets/images/produits/burgers/280.png', 1, 1), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Tasty'), 'Menu Big Tasty', 1060, 1210, 'assets/images/produits/burgers/big-tasty-1-viande.png', 1, 2), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Tasty Bacon'), 'Menu Big Tasty Bacon', 1090, 1240, 'assets/images/produits/burgers/big-tasty-bacon-1-viande.png', 1, 3), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Big Mac'), 'Menu Big Mac', 800, 950, 'assets/images/produits/burgers/bigmac.png', 1, 4), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='CBO'), 'Menu CBO', 1090, 1240, 'assets/images/produits/burgers/cbo.png', 1, 5), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Chicken'), 'Menu MC Chicken', 930, 1080, 'assets/images/produits/burgers/mcchicken.png', 1, 6), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Crispy'), 'Menu MC Crispy', 720, 870, 'assets/images/produits/burgers/mccrispy.png', 1, 7), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='MC Fish'), 'Menu MC Fish', 720, 870, 'assets/images/produits/burgers/mcfish.png', 1, 8), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Bacon'), 'Menu Royal Bacon', 705, 855, 'assets/images/produits/burgers/royalbacon.png', 1, 9), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Cheese'), 'Menu Royal Cheese', 640, 790, 'assets/images/produits/burgers/royalcheese.png', 1, 10), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Royal Deluxe'), 'Menu Royal Deluxe', 740, 890, 'assets/images/produits/burgers/royaldeluxe.png', 1, 11), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), 'Menu Signature BBQ Beef 2 viandes', 1350, 1500, 'assets/images/produits/burgers/signature-bbq-beef-2-viandes.png', 1, 12), + ((SELECT id FROM category WHERE slug='menus'), (SELECT id FROM product WHERE name='Signature Beef BBQ'), 'Menu Signature Beef BBQ', 1190, 1340, 'assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png', 1, 13); + +-- ----------------------------------------------------------------------------- +-- 4. menu_slot — three standard slots per menu: +-- drink (required), side (required), sauce (optional). +-- One INSERT per slot_type, fanning out over all 13 menus via SELECT. +-- ----------------------------------------------------------------------------- +INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order) +SELECT m.id, 'Boisson', 'drink', 1, 1 +FROM menu m +JOIN category c ON c.id = m.category_id AND c.slug = 'menus'; + +INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order) +SELECT m.id, 'Accompagnement', 'side', 1, 2 +FROM menu m +JOIN category c ON c.id = m.category_id AND c.slug = 'menus'; + +INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order) +SELECT m.id, 'Sauce', 'sauce', 0, 3 +FROM menu m +JOIN category c ON c.id = m.category_id AND c.slug = 'menus'; + +-- ----------------------------------------------------------------------------- +-- 5. menu_slot_option — eligible products per slot: +-- drink slot -> all products in category 'boissons' +-- side slot -> all products in category 'frites' +-- sauce slot -> all products in category 'sauces' +-- Composite PK (menu_slot_id, product_id) is naturally satisfied: each +-- (slot, product) pair is unique because slots are unique per menu. +-- ----------------------------------------------------------------------------- +INSERT INTO menu_slot_option (menu_slot_id, product_id) +SELECT ms.id, p.id +FROM menu_slot ms +JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='boissons') +WHERE ms.slot_type = 'drink'; + +INSERT INTO menu_slot_option (menu_slot_id, product_id) +SELECT ms.id, p.id +FROM menu_slot ms +JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='frites') +WHERE ms.slot_type = 'side'; + +INSERT INTO menu_slot_option (menu_slot_id, product_id) +SELECT ms.id, p.id +FROM menu_slot ms +JOIN product p ON p.category_id = (SELECT id FROM category WHERE slug='sauces') +WHERE ms.slot_type = 'sauce'; diff --git a/db/seeds/0003_ingredients_recipes.sql b/db/seeds/0003_ingredients_recipes.sql new file mode 100644 index 0000000..4cfe3c5 --- /dev/null +++ b/db/seeds/0003_ingredients_recipes.sql @@ -0,0 +1,238 @@ +-- ============================================================================= +-- Wakdo — Seed 0003 : Ingredients + recettes (product_ingredient) +-- ============================================================================= +-- Active le decrement de stock (RG-T20) au paiement et la dispo calculee +-- (RG-T21). Catalogue d'ingredients ferme + une recette par produit (53). +-- FK resolus par sous-requete sur le nom (convention seed 0002). INSERT IGNORE +-- => idempotent (UNIQUE name sur ingredient ; PK (product_id,ingredient_id)). +-- Quantites : burger/wrap/salade normal==maxi ; frites/boissons maxi>=normal. +-- Lignes quantity_normal=0 (extras hors base) ecartees : CHECK quantity_normal>0. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 1. ingredient (catalogue, stock plein) +INSERT IGNORE INTO ingredient (name, unit, stock_quantity, stock_capacity) VALUES + ('Pain burger', 'piece', 300, 300), + ('Pain sesame', 'piece', 300, 300), + ('Pain signature', 'piece', 250, 250), + ('Tortilla', 'piece', 200, 200), + ('Steak hache', 'piece', 400, 400), + ('Filet de poulet pane', 'piece', 300, 300), + ('Galette de poisson', 'piece', 150, 150), + ('Tranche de bacon', 'piece', 500, 500), + ('Nugget de poulet', 'piece', 1500, 1500), + ('Jambon', 'tranche', 200, 200), + ('Cheddar', 'tranche', 600, 600), + ('Fromage de chevre', 'portion', 150, 150), + ('Mozzarella', 'portion', 150, 150), + ('Emmental', 'tranche', 250, 250), + ('Salade', 'portion', 600, 600), + ('Tomate', 'rondelle', 400, 400), + ('Oignon', 'portion', 600, 600), + ('Cornichon', 'rondelle', 800, 800), + ('Roquette', 'portion', 200, 200), + ('Sauce Big Mac', 'dose', 1000, 1000), + ('Sauce ranch', 'dose', 1000, 1000), + ('Sauce barbecue', 'dose', 1000, 1000), + ('Sauce deluxe', 'dose', 1000, 1000), + ('Pomme de terre frite', 'portion', 3000, 3000), + ('Galette de pomme de terre', 'portion', 1000, 1000), + ('Dose Coca', 'dose', 1500, 1500), + ('Dose Coca Zero', 'dose', 1000, 1000), + ('Dose Eau', 'dose', 800, 800), + ('Dose Fanta', 'dose', 1000, 1000), + ('Dose Ice Tea Peche', 'dose', 1000, 1000), + ('Dose Ice Tea Citron', 'dose', 800, 800), + ('Dose Jus d Orange', 'dose', 400, 400), + ('Dose Jus de Pomme', 'dose', 400, 400), + ('Gobelet', 'piece', 3000, 3000), + ('Brownie', 'piece', 150, 150), + ('Cheesecake', 'piece', 150, 150), + ('Cookie', 'piece', 150, 150), + ('Donut', 'piece', 150, 150), + ('Macaron', 'piece', 200, 200), + ('Glace McFleury', 'piece', 200, 200), + ('Muffin', 'piece', 150, 150), + ('Glace sundae', 'piece', 200, 200), + ('Topping chocolat', 'dose', 300, 300), + ('Dosette Barbecue', 'dosette', 1500, 1500), + ('Dosette Moutarde', 'dosette', 1500, 1500), + ('Dosette Deluxe', 'dosette', 1500, 1500), + ('Dosette Ketchup', 'dosette', 2000, 2000), + ('Dosette Chinoise', 'dosette', 1000, 1000), + ('Dosette Curry', 'dosette', 1500, 1500), + ('Dosette Pommes Frites', 'dosette', 1500, 1500); + +-- 2. product_ingredient (recettes ; FK par nom) +INSERT IGNORE INTO product_ingredient (product_id, ingredient_id, quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) VALUES + ((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Pain signature'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Cheddar'), 2, 2, 1, 1, 60), + ((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Le 280'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Emmental'), 2, 2, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 2, 2, 1, 1, 80), + ((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Emmental'), 2, 2, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Tasty Bacon'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Steak hache'), 2, 2, 0, 0, 0), + ((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60), + ((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Cornichon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Big Mac'), (SELECT id FROM ingredient WHERE name='Sauce Big Mac'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Pain signature'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 2, 2, 1, 1, 80), + ((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Emmental'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='CBO'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Chicken'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Chicken'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Chicken'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Chicken'), (SELECT id FROM ingredient WHERE name='Sauce ranch'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Crispy'), (SELECT id FROM ingredient WHERE name='Pain signature'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Crispy'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Crispy'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Crispy'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Crispy'), (SELECT id FROM ingredient WHERE name='Sauce deluxe'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Fish'), (SELECT id FROM ingredient WHERE name='Pain burger'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Fish'), (SELECT id FROM ingredient WHERE name='Galette de poisson'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Fish'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60), + ((SELECT id FROM product WHERE name='MC Fish'), (SELECT id FROM ingredient WHERE name='Sauce deluxe'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 2, 2, 1, 1, 80), + ((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60), + ((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Royal Bacon'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Cheddar'), 2, 2, 1, 1, 60), + ((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Cornichon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Royal Cheese'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Pain sesame'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60), + ((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Cornichon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Royal Deluxe'), (SELECT id FROM ingredient WHERE name='Sauce deluxe'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Pain signature'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Steak hache'), 2, 2, 0, 0, 0), + ((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Cheddar'), 2, 2, 1, 1, 60), + ((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 2, 2, 1, 1, 80), + ((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Signature BBQ Beef 2 viandes'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Pain signature'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60), + ((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Signature Beef BBQ'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Coca Cola'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Coca Cola'), (SELECT id FROM ingredient WHERE name='Dose Coca'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Coca Sans Sucres'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Coca Sans Sucres'), (SELECT id FROM ingredient WHERE name='Dose Coca Zero'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Eau'), (SELECT id FROM ingredient WHERE name='Dose Eau'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Fanta Orange'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Fanta Orange'), (SELECT id FROM ingredient WHERE name='Dose Fanta'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Ice Tea Peche'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Ice Tea Peche'), (SELECT id FROM ingredient WHERE name='Dose Ice Tea Peche'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Ice Tea Citron'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Ice Tea Citron'), (SELECT id FROM ingredient WHERE name='Dose Ice Tea Citron'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Jus d''Orange'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Jus d''Orange'), (SELECT id FROM ingredient WHERE name='Dose Jus d Orange'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Jus de Pommes Bio'), (SELECT id FROM ingredient WHERE name='Gobelet'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Jus de Pommes Bio'), (SELECT id FROM ingredient WHERE name='Dose Jus de Pomme'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Petite Frite'), (SELECT id FROM ingredient WHERE name='Pomme de terre frite'), 1, 2, 0, 0, 0), + ((SELECT id FROM product WHERE name='Moyenne Frite'), (SELECT id FROM ingredient WHERE name='Pomme de terre frite'), 2, 3, 0, 0, 0), + ((SELECT id FROM product WHERE name='Grande Frite'), (SELECT id FROM ingredient WHERE name='Pomme de terre frite'), 3, 4, 0, 0, 0), + ((SELECT id FROM product WHERE name='Potatoes'), (SELECT id FROM ingredient WHERE name='Galette de pomme de terre'), 2, 3, 0, 0, 0), + ((SELECT id FROM product WHERE name='Grande Potatoes'), (SELECT id FROM ingredient WHERE name='Galette de pomme de terre'), 3, 4, 0, 0, 0), + ((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Pain burger'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Steak hache'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 1, 60), + ((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Cornichon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Cheeseburger'), (SELECT id FROM ingredient WHERE name='Sauce Big Mac'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Croc MCdo'), (SELECT id FROM ingredient WHERE name='Pain burger'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Croc MCdo'), (SELECT id FROM ingredient WHERE name='Jambon'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Croc MCdo'), (SELECT id FROM ingredient WHERE name='Emmental'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Nuggets x4'), (SELECT id FROM ingredient WHERE name='Nugget de poulet'), 4, 4, 0, 0, 0), + ((SELECT id FROM product WHERE name='Nuggets x20'), (SELECT id FROM ingredient WHERE name='Nugget de poulet'), 20, 20, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Tortilla'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Fromage de chevre'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Sauce ranch'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Tortilla'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='MC Wrap Poulet Bacon'), (SELECT id FROM ingredient WHERE name='Sauce barbecue'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Ptit Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Tortilla'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Ptit Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Ptit Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Fromage de chevre'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Ptit Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Ptit Wrap Chevre'), (SELECT id FROM ingredient WHERE name='Sauce ranch'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Tortilla'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Cheddar'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Oignon'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Ptit Wrap Ranch'), (SELECT id FROM ingredient WHERE name='Sauce ranch'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Petite Salade'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Petite Salade'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Petite Salade'), (SELECT id FROM ingredient WHERE name='Roquette'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Filet de poulet pane'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Emmental'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Sauce ranch'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Cesar Classic'), (SELECT id FROM ingredient WHERE name='Tranche de bacon'), 1, 1, 1, 1, 80), + ((SELECT id FROM product WHERE name='Italienne Mozza'), (SELECT id FROM ingredient WHERE name='Salade'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Italienne Mozza'), (SELECT id FROM ingredient WHERE name='Mozzarella'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Italienne Mozza'), (SELECT id FROM ingredient WHERE name='Tomate'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Italienne Mozza'), (SELECT id FROM ingredient WHERE name='Roquette'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Italienne Mozza'), (SELECT id FROM ingredient WHERE name='Sauce deluxe'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Brownie'), (SELECT id FROM ingredient WHERE name='Brownie'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Cheesecake chocolat M&M''S'), (SELECT id FROM ingredient WHERE name='Cheesecake'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Cheesecake chocolat M&M''S'), (SELECT id FROM ingredient WHERE name='Topping chocolat'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Cheesecake Fraise'), (SELECT id FROM ingredient WHERE name='Cheesecake'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Cookie'), (SELECT id FROM ingredient WHERE name='Cookie'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Donut'), (SELECT id FROM ingredient WHERE name='Donut'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Macarons'), (SELECT id FROM ingredient WHERE name='Macaron'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Fleury'), (SELECT id FROM ingredient WHERE name='Glace McFleury'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='MC Fleury'), (SELECT id FROM ingredient WHERE name='Topping chocolat'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Muffin'), (SELECT id FROM ingredient WHERE name='Muffin'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Sunday'), (SELECT id FROM ingredient WHERE name='Glace sundae'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Sunday'), (SELECT id FROM ingredient WHERE name='Topping chocolat'), 1, 1, 1, 0, 0), + ((SELECT id FROM product WHERE name='Classic Barbecue'), (SELECT id FROM ingredient WHERE name='Dosette Barbecue'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Classic Moutarde'), (SELECT id FROM ingredient WHERE name='Dosette Moutarde'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Creamy Deluxe'), (SELECT id FROM ingredient WHERE name='Dosette Deluxe'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Ketchup'), (SELECT id FROM ingredient WHERE name='Dosette Ketchup'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Chinoise'), (SELECT id FROM ingredient WHERE name='Dosette Chinoise'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Curry'), (SELECT id FROM ingredient WHERE name='Dosette Curry'), 1, 1, 0, 0, 0), + ((SELECT id FROM product WHERE name='Pommes Frites'), (SELECT id FROM ingredient WHERE name='Dosette Pommes Frites'), 1, 1, 0, 0, 0); + diff --git a/db/seeds/0004_menu_side_maxi.sql b/db/seeds/0004_menu_side_maxi.sql new file mode 100644 index 0000000..79946a2 --- /dev/null +++ b/db/seeds/0004_menu_side_maxi.sql @@ -0,0 +1,61 @@ +-- ============================================================================= +-- Wakdo — Seed 0004 : accompagnement de menu = variante Maxi automatique +-- ============================================================================= +-- Purpose : cabler la regle metier "accompagnement Maxi" sur les donnees seedees +-- par 0002_catalogue.sql, sans toucher au code : +-- 1. lier chaque accompagnement standard a sa variante Grande +-- (Moyenne Frite -> Grande Frite, Potatoes -> Grande Potatoes) ; +-- 2. restreindre les options du slot 'side' des menus aux deux seuls +-- choix conformes a la maquette (ecran 4) : Moyenne Frite + Potatoes. +-- Phase : P4 — depend du schema 0006 (product.maxi_variant_product_id) et du +-- catalogue 0002 (produits frites + menu_slot 'side'). +-- +-- Etat initial (0002_catalogue.sql, section 5) : le slot 'side' recoit TOUS les +-- produits de la categorie 'frites', soit les 5 : Petite Frite, Moyenne Frite, +-- Grande Frite, Potatoes, Grande Potatoes. Ce seed retire Petite Frite, Grande +-- Frite et Grande Potatoes des options de menu (elles restent a la carte dans la +-- categorie frites) : le DELETE n'est donc PAS un no-op sur une base 0002. +-- +-- Conventions: +-- - Aucun id en dur : toutes les references sont resolues par sous-requete sur +-- le nom du produit / le type de slot (memes noms que 0002_catalogue.sql). +-- - IDEMPOTENT : UPDATE convergent (repositionne la meme valeur) et DELETE par +-- appartenance (re-supprimer des options deja absentes ne fait rien) ; rejouer +-- ce seed laisse la base dans le meme etat. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 1. Lier chaque accompagnement standard a sa variante Grande. +-- Le SELECT cible la table `product`, que l'UPDATE modifie aussi : MariaDB/ +-- MySQL interdit de lire et d'ecrire la meme table dans une seule requete +-- sans niveau de derivation. La sous-requete est donc enveloppee dans une +-- table derivee (SELECT ... FROM (SELECT ...) x) qui materialise l'id avant +-- l'UPDATE, contournant l'erreur "can't specify target table for update". +-- ----------------------------------------------------------------------------- +UPDATE product +SET maxi_variant_product_id = ( + SELECT id FROM (SELECT id FROM product WHERE name = 'Grande Frite') x +) +WHERE name = 'Moyenne Frite'; + +UPDATE product +SET maxi_variant_product_id = ( + SELECT id FROM (SELECT id FROM product WHERE name = 'Grande Potatoes') x +) +WHERE name = 'Potatoes'; + +-- ----------------------------------------------------------------------------- +-- 2. Restreindre les options du slot 'side' des menus aux deux choix de la +-- maquette. On supprime des slots 'side' toute option qui n'est ni Moyenne +-- Frite ni Potatoes (Petite Frite, Grande Frite, Grande Potatoes). Les autres +-- slots (drink, sauce) et les produits a la carte ne sont pas touches. +-- Idempotent : sur une base deja restreinte, ces lignes n'existent plus, le +-- DELETE affecte 0 ligne. +-- ----------------------------------------------------------------------------- +DELETE FROM menu_slot_option +WHERE menu_slot_id IN (SELECT id FROM menu_slot WHERE slot_type = 'side') + AND product_id IN ( + SELECT id FROM product WHERE name IN ('Petite Frite', 'Grande Frite', 'Grande Potatoes') + ); diff --git a/db/seeds/0005_drink_sizes.sql b/db/seeds/0005_drink_sizes.sql new file mode 100644 index 0000000..7c5111c --- /dev/null +++ b/db/seeds/0005_drink_sizes.sql @@ -0,0 +1,86 @@ +-- ============================================================================= +-- Wakdo — Seed 0005 : tailles a la carte des boissons fontaine (30 / 50 cl) +-- ============================================================================= +-- Purpose : cabler la dimension TAILLE (schema 0007) sur les boissons fontaine +-- seedees par 0002_catalogue.sql, sans toucher au code : +-- 1. la ligne existante de chaque soda devient la BASE 30 cl ; +-- 2. une ligne VARIANTE 50 cl est inseree par soda (base_product_id -> +-- la base, prix = base + 50c par defaut, +50 cl) ; +-- 3. la recette (product_ingredient) de la base est dupliquee sur la +-- variante, pour que le decrement de stock (consumption) frappe +-- aussi la 50 cl. +-- +-- Perimetre : seules les boissons fontaine ont deux tailles (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) restent mono-taille (size_cl laisse NULL, +-- aucune variante). +-- +-- Phase : R4 — depend du schema 0007 (product.size_cl + base_product_id) et du +-- catalogue 0002 (lignes boissons) ; la duplication de recette depend de +-- 0003 (product_ingredient des sodas). +-- +-- Conventions: +-- - Aucun id en dur : toutes les references sont resolues par sous-requete sur +-- le nom du produit (memes noms que 0002_catalogue.sql). +-- - IDEMPOTENT : UPDATE convergent (repositionne la meme valeur) ; INSERT gardes +-- par WHERE NOT EXISTS (re-jouer n'insere pas de doublon). La sous-requete qui +-- lit `product` dans un INSERT INTO product est enveloppee en table derivee +-- pour contourner l'erreur MariaDB 1093 (technique de 0004_menu_side_maxi.sql). +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 1. Marquer chaque soda fontaine comme BASE 30 cl. UPDATE convergent (rejouer +-- repose 30) -> idempotent. Le nom de base reste propre ("Coca Cola") : la +-- tuile catalogue garde le nom court, le picker affiche "30 cl" / "50 cl". +-- ----------------------------------------------------------------------------- +UPDATE product +SET size_cl = 30 +WHERE base_product_id IS NULL + AND name IN ('Coca Cola', 'Coca Sans Sucres', 'Fanta Orange', 'Ice Tea Peche', 'Ice Tea Citron'); + +-- ----------------------------------------------------------------------------- +-- 2. Inserer la VARIANTE 50 cl de chaque soda. category_id / vat_rate / image +-- copies de la base ; price = base + 50c (defaut sensible, a confirmer) ; +-- base_product_id -> id de la base ; size_cl = 50 ; is_available = 1. +-- L'INSERT lit ET ecrit `product` : la sous-requete est enveloppee en table +-- derivee (b) pour contourner l'erreur 1093. WHERE NOT EXISTS garde le doublon +-- a la re-execution (une variante 50 cl de cette base existe deja -> 0 ligne). +-- ----------------------------------------------------------------------------- +INSERT INTO product (category_id, name, price_cents, size_cl, base_product_id, vat_rate, image_path, is_available, display_order) +SELECT b.category_id, b.name_50, b.price_cents + 50, 50, b.id, b.vat_rate, b.image_path, 1, b.display_order +FROM ( + SELECT id, category_id, CONCAT(name, ' 50cl') AS name_50, price_cents, vat_rate, image_path, display_order + FROM product + WHERE base_product_id IS NULL + AND name IN ('Coca Cola', 'Coca Sans Sucres', 'Fanta Orange', 'Ice Tea Peche', 'Ice Tea Citron') +) b +WHERE NOT EXISTS ( + SELECT 1 FROM (SELECT base_product_id FROM product WHERE base_product_id IS NOT NULL) v + WHERE v.base_product_id = b.id +); + +-- ----------------------------------------------------------------------------- +-- 3. Dupliquer la recette de chaque base 30 cl sur sa variante 50 cl, pour que +-- le decrement de stock frappe aussi la 50 cl. Memes ingredients / quantites +-- que la base (simplification assumee : R4 vise le flux de commande, pas une +-- consommation volumetrique exacte). Une base sans recette (ex. theorique) ne +-- produit aucune ligne pour sa variante. +-- PK composite (product_id, ingredient_id) : WHERE NOT EXISTS garde la +-- re-execution (les lignes de la variante existent deja -> 0 ligne inseree). +-- ----------------------------------------------------------------------------- +INSERT INTO product_ingredient (product_id, ingredient_id, quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) +SELECT v.id, src.ingredient_id, src.quantity_normal, src.quantity_maxi, src.is_removable, src.is_addable, src.extra_price_cents +FROM product v +JOIN ( + SELECT pi.product_id AS base_id, pi.ingredient_id, pi.quantity_normal, pi.quantity_maxi, + pi.is_removable, pi.is_addable, pi.extra_price_cents + FROM product_ingredient pi +) src ON src.base_id = v.base_product_id +WHERE v.base_product_id IS NOT NULL + AND v.size_cl = 50 + AND NOT EXISTS ( + SELECT 1 FROM (SELECT product_id, ingredient_id FROM product_ingredient) e + WHERE e.product_id = v.id AND e.ingredient_id = src.ingredient_id + ); diff --git a/docker-compose.yml b/docker-compose.yml index d131af4..5b8bfef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,88 +1,19 @@ -# -# Wakdo - orchestration des 4 services de la stack (Bloc 5 DevOps, Cr 7.c.3 / 7.c.4). -# -# Services : -# wakdo-web : Apache httpd, reverse proxy FastCGI -> wakdo-app, expose 80 via Traefik -# wakdo-app : PHP-FPM 8.3, execute le code PHP back-office + API -# wakdo-db : MariaDB 11.4, persistance des donnees metier -# wakdo-cron : Alpine + dcron, backups nocturnes et taches planifiees -# -# Reseaux : -# default (nom = wakdo_internal) : bridge interne pour l'inter-service, -# non expose a l'hote. app/db/cron ne sont PAS joignables publiquement. -# : reseau externe (ex. traefik_proxy) partage -# avec le Traefik de l'hote. Seul wakdo-web y est attache. -# -# Volumes : -# wakdo_db_data : named volume pour /var/lib/mysql (persistance BDD) -# wakdo_uploads : named volume pour les uploads produits back-office -# ./var/backups : bind-mount lecture-ecriture pour les dumps SQL -# -# Variables d'env consommees depuis .env (chargees par make via `include .env` -# et transmises ici par docker compose qui fait l'expansion automatique). -# -# Persistance : un `make down` preserve les named volumes. Seul `make clean` -# (interactif) ou `docker compose down -v` supprime les donnees. -# - name: wakdo networks: - # Reseau interne : isolement des services applicatifs. - # wakdo-app, wakdo-db et wakdo-cron n'y sont accessibles que via les autres - # conteneurs de la stack. Aucun port hote expose. wakdo_internal: driver: bridge - internal: false - # internal: false (par defaut) car wakdo-app et wakdo-cron ont besoin - # de sortir sur internet (pour telecharger des packages au build et pour - # de potentiels appels API externes futurs). L'isolation vient du fait - # qu'aucun port hote n'est binde ici. - # - # Subnet explicite (RFC 1918) : l'auto-allocateur Docker du daemon hote - # est sature (15 /16 + 15 /20 deja alloues par d'autres stacks), il ne - # peut plus creer de reseau bridge sans subnet explicite. 192.168.148.0/24 - # est dans le gap libre 192.168.144-159 (256 IP, largement suffisant pour - # 4 services), aucune collision avec les /24 acquagest voisins (150/154/ - # 155/157). Choix defendable : right-sizing + isolation des fluctuations - # d'allocation auto sur cet hote mutualise. - ipam: - driver: default - config: - - subnet: 192.168.148.0/24 - - # Reseau du reverse proxy (Traefik) pre-existant sur l'hote. - # Son nom est configurable via REVERSE_PROXY_NETWORK dans .env pour - # supporter differents setups (traefik_proxy, traefik_public, proxy, ...). - reverse_proxy: - name: ${REVERSE_PROXY_NETWORK} - external: true volumes: wakdo_db_data: - # Named volume MariaDB. Permissions gerees par Docker (UID mysql=999 - # dans le conteneur), zero souci cote hote. Survit a `docker compose down`. - # Pour remise a zero : `make clean` (interactif, confirme) ou - # `docker compose down -v` (destructif direct). - wakdo_uploads: - # Images produits uploadees par les equipiers depuis le back-office. - # Named volume pour les memes raisons que wakdo_db_data : permissions - # propres (www-data UID 82 en Alpine) et pas de pollution du repo git - # par des binaires. services: - # ======================================================================= - # wakdo-db : MariaDB 11.4 LTS - # ======================================================================= wakdo-db: image: mariadb:11.4 container_name: wakdo-db restart: unless-stopped - - # Variables d'env d'initialisation (ne s'appliquent qu'au premier demarrage - # sur volume vide - voir docs/notes/docker-volumes-vs-bind-mounts.md). environment: MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} MARIADB_DATABASE: ${DB_NAME} @@ -90,18 +21,11 @@ services: 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 - - # Pas de ports exposes a l'hote : seuls wakdo-app et wakdo-cron peuvent - # joindre wakdo-db:3306 via le reseau interne. - - # Healthcheck officiel fourni par l'image mariadb : le script bundled - # healthcheck.sh teste la connexion et l'init innodb. healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s @@ -109,16 +33,30 @@ services: retries: 6 start_period: 30s - # ======================================================================= - # wakdo-app : PHP-FPM 8.3 (execute le code back-office + API) - # ======================================================================= + 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} @@ -134,131 +72,78 @@ services: SESSION_LIFETIME_ABSOLUTE: ${SESSION_LIFETIME_ABSOLUTE} SESSION_NAME: ${SESSION_NAME} CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN} - PASSWORD_ALGO: ${PASSWORD_ALGO} + 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} - volumes: - # Bind-mount du code source pour le hot-reload en dev. - # En prod, cette ligne est remplacee par un COPY dans l'image - # via docker-compose.prod.yml (override a venir en P7). - ./src:/var/www/html - - # Named volume pour les uploads, plus specifique que le bind-mount - # parent : les uploads ne vont pas dans ./src, ils restent dans le - # volume Docker et survivent aux `make down`. - wakdo_uploads:/var/www/html/public/uploads - networks: - wakdo_internal - depends_on: + wakdo-migrate: + condition: service_completed_successfully wakdo-db: condition: service_healthy - # Healthcheck defini dans le Dockerfile (php -r exit 0). - - # ======================================================================= - # wakdo-web : Apache httpd (reverse FastCGI vers wakdo-app, expose via Traefik) - # ======================================================================= wakdo-web: build: context: ./docker/apache dockerfile: Dockerfile container_name: wakdo-web restart: unless-stopped - environment: - # Noms de domaine injectes dans les vhosts (ServerName ${TRAEFIK_DOMAIN_*}). - TRAEFIK_DOMAIN_KIOSK: ${TRAEFIK_DOMAIN_KIOSK} - TRAEFIK_DOMAIN_ADMIN: ${TRAEFIK_DOMAIN_ADMIN} - + APP_HOST_KIOSK: ${APP_HOST_KIOSK} + APP_HOST_ADMIN: ${APP_HOST_ADMIN} + ports: + - "${HTTP_PORT:-8080}:80" volumes: - # Meme bind-mount que wakdo-app : Apache sert les fichiers statiques - # (HTML, CSS, JS, images) depuis ce dossier. PHP ne passe pas par - # ce chemin ici, il passe par le proxy FastCGI vers wakdo-app. - ./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 : deux routers (kiosk + admin) sur le meme conteneur === - # Le Traefik de l'hote decouvre ces labels automatiquement (provider docker). - # On ne configure PAS le certresolver ici : le Traefik hote le gere via - # sa propre config (acme.json, resolver par defaut). - labels: - - "traefik.enable=true" - - "traefik.docker.network=${REVERSE_PROXY_NETWORK}" - - # --- Router kiosk (borne client) --- - - "traefik.http.routers.wakdo-kiosk.rule=Host(`${TRAEFIK_DOMAIN_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" - - # --- Router admin (back-office + API) --- - - "traefik.http.routers.wakdo-admin.rule=Host(`${TRAEFIK_DOMAIN_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" - - # --- Middleware : redirection HTTP -> HTTPS --- - # Applique aux 2 hosts via un router "catch-all" sur entrypoints=web. - - "traefik.http.routers.wakdo-kiosk-http.rule=Host(`${TRAEFIK_DOMAIN_KIOSK}`)" - - "traefik.http.routers.wakdo-kiosk-http.entrypoints=web" - - "traefik.http.routers.wakdo-kiosk-http.middlewares=wakdo-to-https" - - "traefik.http.routers.wakdo-admin-http.rule=Host(`${TRAEFIK_DOMAIN_ADMIN}`)" - - "traefik.http.routers.wakdo-admin-http.entrypoints=web" - - "traefik.http.routers.wakdo-admin-http.middlewares=wakdo-to-https" - - "traefik.http.middlewares.wakdo-to-https.redirectscheme.scheme=https" - - "traefik.http.middlewares.wakdo-to-https.redirectscheme.permanent=true" - - # ======================================================================= - # wakdo-cron : taches planifiees (backup, purge, agregations) - # ======================================================================= wakdo-cron: build: context: ./docker/cron dockerfile: Dockerfile container_name: wakdo-cron restart: unless-stopped - # init: true -> Docker injecte tini comme PID 1. dcron exige un init - # parent pour pouvoir setpgid() sur ses jobs (sinon "Operation not - # permitted" en boucle car un PID 1 sans init ne peut pas changer les - # groupes de processus). Cf. busybox-utils issue tracker. init: true - environment: - # Credentials BDD pour mysqldump (lecture seule via USER applicatif, - # PAS le root password). Le user applicatif doit avoir SELECT + - # LOCK TABLES + SHOW VIEW sur la BDD (migrations P2). 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: - # Bind-mount vers l'hote pour les dumps : inspectables par ls, scp-able - # hors docker. Le dossier ./var/backups est gitignore. - ./var/backups:/backups - networks: - wakdo_internal - depends_on: wakdo-db: condition: service_healthy diff --git a/docker/apache/vhost.conf b/docker/apache/vhost.conf index d0411f2..05e18f6 100644 --- a/docker/apache/vhost.conf +++ b/docker/apache/vhost.conf @@ -2,8 +2,8 @@ # Wakdo - vhosts applicatifs # # Un seul conteneur Apache derriere Traefik sert **les deux** FQDN : -# - TRAEFIK_DOMAIN_KIOSK -> /var/www/html/public/borne (borne client, Bloc 1) -# - TRAEFIK_DOMAIN_ADMIN -> /var/www/html/public/admin (back-office + API, Bloc 2) +# - APP_HOST_KIOSK -> /var/www/html/public/borne (borne client, Bloc 1) +# - APP_HOST_ADMIN -> /var/www/html/public/admin (back-office + API, Bloc 2) # # Comme Traefik termine TLS en amont et communique en HTTP clair avec Apache # sur le reseau docker, les vhosts ecoutent sur :80 et font confiance aux @@ -39,8 +39,8 @@ # === Borne client (Bloc 1 - front vanilla HTML/CSS/JS) === - # Hostname injecte par la var d'env TRAEFIK_DOMAIN_KIOSK au runtime. - ServerName ${TRAEFIK_DOMAIN_KIOSK} + # Hostname injecte par la var d'env APP_HOST_KIOSK au runtime. + ServerName ${APP_HOST_KIOSK} DocumentRoot "/var/www/html/public/borne" @@ -55,7 +55,10 @@ # SPA-like fallback : toute URL non-fichier -> index.html # (pour permettre de bookmarker un chemin profond dans la borne). + # Exclusion /api/ : ces requetes sont relayees a l'API (cf. + # plus bas) et ne doivent JAMAIS retomber sur index.html. RewriteEngine On + RewriteCond %{REQUEST_URI} !^/api/ RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.html [L] @@ -66,6 +69,28 @@ Require all denied + # === API en MEME origine (P4 - passerelle same-origin) === + # La borne consomme l'API publique (/api/*) sur SA PROPRE origine : ce vhost + # relaie ces requetes au front controller admin via PHP-FPM. data.js garde + # donc ses URLs relatives (/api/categories...) -> aucune requete cross-origin + # cote borne -> CORS inutile pour ce parcours (le middleware reste en place + # cote API comme defense en profondeur). SEUL /api est relaye : le back-office + # (/login, /admin/*) n'est PAS joignable depuis l'origine borne. + # + # Le chemin apres host:port dans l'URL fcgi EST le SCRIPT_FILENAME envoye a + # FPM : on le force sur le front controller admin (un .php REEL). Sans ca, FPM + # recevrait un chemin sous le docroot borne sans extension .php et rejetterait + # (security.limit_extensions par defaut = .php -> reponse "Access denied"). + # ProxyPassMatch intercepte des la phase translate -> le faux chemin + # /.../borne/api/... n'est jamais calcule. REQUEST_URI (=/api/categories) et la + # query string sont preserves -> le Router (qui lit REQUEST_URI) route correctement. + ProxyPassMatch "^/api(/.*)?$" "fcgi://wakdo-app:9000/var/www/html/public/admin/index.php" + # mod_proxy_fcgi derive un SCRIPT_FILENAME corrompu (prefixe proxy: + chemin + # original colle apres index.php) -> FPM rejette (extension != .php). On force + # la valeur sur le front controller admin (un .php REEL) ; REQUEST_URI reste + # intact, donc le Router route correctement. + ProxyFCGISetEnvIf "true" SCRIPT_FILENAME "/var/www/html/public/admin/index.php" + # Compression text/html, css, js, json (Cr 1.e.8 temps de chargement). AddOutputFilterByType DEFLATE text/html text/css text/javascript \ @@ -89,7 +114,7 @@ # === Back-office + API REST (Bloc 2 - PHP from scratch + MVC) === - ServerName ${TRAEFIK_DOMAIN_ADMIN} + ServerName ${APP_HOST_ADMIN} DocumentRoot "/var/www/html/public/admin" @@ -128,7 +153,7 @@ # CORS : l'API admin sous /api/* doit accepter les requetes venant - # de la borne kiosk (TRAEFIK_DOMAIN_KIOSK). Wildcard interdit. + # de la borne kiosk (APP_HOST_KIOSK). Wildcard interdit. # La vraie valeur vient de CORS_ALLOWED_ORIGIN dans .env, lue cote PHP. # Ici on pose juste les headers de prealable OPTIONS. diff --git a/docker/cron/crontab b/docker/cron/crontab index 1a20d5e..afd27c8 100644 --- a/docker/cron/crontab +++ b/docker/cron/crontab @@ -16,6 +16,12 @@ # 03h00 : dump BDD complet, compresse et rotate (garde 14 derniers). 0 3 * * * /scripts/backup-db.sh 2>&1 +# 04h15 : purge de retention du journal d'audit (mlt.md 13.4, AUDIT_LOG_RETENTION_DAYS). +15 4 * * * /scripts/purge-audit-log.sh 2>&1 + +# 04h45 : purge des compteurs de throttle sans verrou actif (mlt.md 13.5, THROTTLE_PURGE_AFTER_HOURS). +45 4 * * * /scripts/purge-throttle.sh 2>&1 + # Toutes les 15 min pendant la fenetre de maintenance : purge des sessions # PHP expirees cote BDD (pas les sessions systeme qui sont en /tmp du conteneur # wakdo-app, donc ephemeres par nature). A activer quand la table sessions diff --git a/docker/cron/scripts/backup-db.sh b/docker/cron/scripts/backup-db.sh index 57a17db..141e944 100644 --- a/docker/cron/scripts/backup-db.sh +++ b/docker/cron/scripts/backup-db.sh @@ -13,8 +13,11 @@ # - DB_USER (on utilise le user applicatif, pas root) # - DB_PASSWORD # -# Le USER applicatif doit avoir SELECT + LOCK TABLES + SHOW VIEW sur wakdo. -# (GRANT donnes dans les migrations a venir en P2.) +# Le USER applicatif a un privilege restreint (moindre privilege) : DML +# (SELECT/INSERT/UPDATE/DELETE) + SHOW VIEW, TRIGGER, LOCK TABLES sur wakdo, +# sans DDL ni GRANT OPTION. mysqldump --single-transaction (ci-dessous) n'exige +# que SELECT (+ SHOW VIEW/TRIGGER pour ces objets). Privileges poses par +# db/init/10-scope-app-user.sh (volume vierge) ou manuellement (base existante). # # Exit codes : # 0 - backup OK diff --git a/docker/cron/scripts/purge-audit-log.sh b/docker/cron/scripts/purge-audit-log.sh new file mode 100755 index 0000000..fc475cc --- /dev/null +++ b/docker/cron/scripts/purge-audit-log.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Wakdo - purge de retention du journal d'audit (mlt.md 13.4). +# +# Supprime les lignes audit_log plus anciennes que AUDIT_LOG_RETENTION_DAYS +# (interet legitime / tracabilite fiscale, configurable). L'imputabilite recente +# est preservee. C'est l'unique exception documentee a l'append-only de audit_log +# (RG-T14) : une purge de retention planifiee, jamais une mutation applicative. +# +# Variables d'env (injectees par docker-compose depuis .env) : +# DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD +# AUDIT_LOG_RETENTION_DAYS (defaut 365) +# +# Exit codes : 0 OK | 1 env manquant/invalide | 2 requete SQL echouee +set -euo pipefail + +log() { echo "[purge-audit-log $(date -Iseconds)] $*" >&2; } + +for var in DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD; do + if [ -z "${!var:-}" ]; then log "ERROR: variable $var vide ou non definie"; exit 1; fi +done + +DAYS="${AUDIT_LOG_RETENTION_DAYS:-365}" +case "$DAYS" in + ''|*[!0-9]*) log "ERROR: AUDIT_LOG_RETENTION_DAYS non entier ('$DAYS')"; exit 1 ;; +esac + +if ! n="$(mariadb --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" --password="$DB_PASSWORD" \ + --default-character-set=utf8mb4 -N -B "$DB_NAME" \ + -e "DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL ${DAYS} DAY; SELECT ROW_COUNT();")"; then + log "ERROR: purge audit_log a echoue" + exit 2 +fi +log "audit_log: ${n} ligne(s) purgee(s) (> ${DAYS} jours)" diff --git a/docker/cron/scripts/purge-throttle.sh b/docker/cron/scripts/purge-throttle.sh new file mode 100755 index 0000000..a94f567 --- /dev/null +++ b/docker/cron/scripts/purge-throttle.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Wakdo - purge des compteurs de throttle sans verrou actif (mlt.md 13.5). +# +# Borne la croissance de login_throttle (per-IP, RG-8) et pin_throttle +# (per-acteur, RG-T22) : supprime les lignes dont le verrou n'est plus actif +# ET dont la derniere tentative est plus ancienne que THROTTLE_PURGE_AFTER_HOURS. +# Les lignes servant encore un verrou actif sont conservees. +# +# Variables d'env (injectees par docker-compose depuis .env) : +# DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD +# THROTTLE_PURGE_AFTER_HOURS (defaut 24) +# +# Exit codes : 0 OK | 1 env manquant/invalide | 2 requete SQL echouee +set -euo pipefail + +log() { echo "[purge-throttle $(date -Iseconds)] $*" >&2; } + +for var in DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD; do + if [ -z "${!var:-}" ]; then log "ERROR: variable $var vide ou non definie"; exit 1; fi +done + +HOURS="${THROTTLE_PURGE_AFTER_HOURS:-24}" +case "$HOURS" in + ''|*[!0-9]*) log "ERROR: THROTTLE_PURGE_AFTER_HOURS non entier ('$HOURS')"; exit 1 ;; +esac + +db() { + mariadb --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" --password="$DB_PASSWORD" \ + --default-character-set=utf8mb4 -N -B "$DB_NAME" -e "$1" +} + +# login_throttle et pin_throttle partagent le meme predicat (mlt.md 13.5). +for table in login_throttle pin_throttle; do + if ! n="$(db "DELETE FROM ${table} WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL ${HOURS} HOUR; SELECT ROW_COUNT();")"; then + log "ERROR: purge ${table} a echoue" + exit 2 + fi + log "${table}: ${n} ligne(s) purgee(s) (sans verrou actif, > ${HOURS}h)" +done diff --git a/docker/cron/scripts/restore-db.sh b/docker/cron/scripts/restore-db.sh new file mode 100644 index 0000000..d960a69 --- /dev/null +++ b/docker/cron/scripts/restore-db.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# Wakdo - restauration de la BDD depuis un dump produit par backup-db.sh. +# +# Operation MANUELLE (pas un job cron) : restaurer ecrase les donnees courantes. +# A lancer dans le conteneur disposant du client mysql et du reseau de la BDD, p.ex. +# docker compose run --rm -v "$PWD/var/backups:/backups" wakdo-cron \ +# /scripts/restore-db.sh /backups/wakdo_YYYYMMDD_HHMMSS.sql.gz --force +# +# Variables d'env lues (memes que backup-db.sh) : +# DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD +# Note : un dump complet contient des DROP/CREATE TABLE ; le compte utilise doit +# donc avoir les privileges DDL. Le user applicatif (DML seul) ne suffit pas : +# fournir un compte privilegie via DB_USER/DB_PASSWORD pour la restauration. +# +# Usage : +# restore-db.sh [--force] +# Sans --force, le script demande une confirmation interactive. +# +# Exit codes : +# 0 - restauration OK +# 1 - variables env manquantes / mauvais usage / fichier absent +# 2 - restauration mysql a echoue +# 3 - confirmation refusee + +set -euo pipefail + +DUMP_FILE="${1:-}" +FORCE="${2:-}" + +log() { + echo "[restore-db $(date -Iseconds)] $*" >&2 +} + +if [ -z "$DUMP_FILE" ]; then + log "usage: restore-db.sh [--force]" + exit 1 +fi + +if [ ! -f "$DUMP_FILE" ]; then + log "ERROR: fichier de dump introuvable : $DUMP_FILE" + exit 1 +fi + +for var in DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD; do + if [ -z "${!var:-}" ]; then + log "ERROR: variable $var vide ou non definie" + exit 1 + fi +done + +# Garde-fou : la restauration ecrase la base cible. Confirmation requise sauf --force. +if [ "$FORCE" != "--force" ]; then + printf 'Restaurer %s dans la base "%s" sur %s:%s ? Les donnees actuelles seront ecrasees. [oui/NON] ' \ + "$DUMP_FILE" "$DB_NAME" "$DB_HOST" "$DB_PORT" >&2 + read -r answer + if [ "$answer" != "oui" ]; then + log "restauration annulee." + exit 3 + fi +fi + +# Decompression a la volee si le dump est gzippe. +reader=(cat "$DUMP_FILE") +case "$DUMP_FILE" in + *.gz) reader=(gzip -dc "$DUMP_FILE") ;; +esac + +log "restauration de ${DB_NAME} depuis ${DUMP_FILE}" +if ! "${reader[@]}" | mysql \ + --host="${DB_HOST}" \ + --port="${DB_PORT}" \ + --user="${DB_USER}" \ + --password="${DB_PASSWORD}" \ + --default-character-set=utf8mb4 \ + "${DB_NAME}"; then + log "ERROR: la restauration mysql a echoue" + exit 2 +fi + +log "restauration OK : ${DB_NAME}" +exit 0 diff --git a/docker/php-fpm/php.ini b/docker/php-fpm/php.ini index bc210d1..dc53cee 100644 --- a/docker/php-fpm/php.ini +++ b/docker/php-fpm/php.ini @@ -41,8 +41,31 @@ session.cookie_secure = 1 ; Persistance inter-container non necessaire : chaque session est liee a une ; instance unique du service wakdo-app (pas de scale horizontal pour ce projet). -; --- Expose_php = Off : ne pas leak la version PHP dans l'entete HTTP --- +; session.gc_maxlifetime : filet de securite cote serveur (l'idle reel est +; pilote par l'appli via SESSION_LIFETIME_IDLE). 4h. +session.gc_maxlifetime = 14400 +; IDs de session longs et a forte entropie (anti-prediction/fixation). +session.sid_length = 48 +session.sid_bits_per_character = 6 +; Pas de cache navigateur sur les pages avec session (anti-fuite via cache). +session.cache_limiter = nocache + +; --- Durcissement general (security-by-design, cf. PROJECT_CONTEXT 19) --- +; Expose_php = Off : ne pas leak la version PHP dans l'entete HTTP. expose_php = Off +; Anti RFI/SSRF : interdire l'ouverture d'URL distantes et leur inclusion. +allow_url_fopen = Off +allow_url_include = Off +; FPM : ne pas deviner le script a partir du PATH_INFO (anti exploitation +; d'upload mal route vers l'interpreteur). Le routage passe par le front controller. +cgi.fix_pathinfo = 0 +; Interdire le chargement dynamique d'extensions au runtime. +enable_dl = Off +; Ne pas inclure les arguments dans les stack traces (anti-fuite de secrets). +zend.exception_ignore_args = On +; Desactiver les fonctions d'execution systeme : l'appli n'en a aucun usage +; legitime (anti-RCE en cas d'injection). Les scripts d'ops vivent cote cron/host. +disable_functions = exec,passthru,shell_exec,system,proc_open,popen ; --- OPcache (perf + stabilite) --- [opcache] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..526d8af --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,270 @@ +# Architecture — Wakdo + +Vue d'ensemble technique du projet (borne de commande fast-food, certification RNCP 37805). +Point d'entree pour comprendre la stack, le decoupage et les choix de conception. + +- Scope metier, planning, mapping RNCP : `docs/PROJECT_CONTEXT.md`. +- Modelisation detaillee (entites, operations, regles) : `docs/merise/` (dictionary, mcd, mct, mlt). +- Decisions tracees : `docs/adr/` et `docs/journal/`. + +**Auteur : BYAN** (formalisation ; arbitrage et validation par l'auteur du projet). + +--- + +## 1. Vue d'ensemble + +Wakdo simule une borne de commande tactile de restauration rapide, avec back-office +d'administration, workflow cuisine et API REST interne. Deux surfaces applicatives : + +- **Borne (kiosk)** — front statique (HTML/CSS/JS vanilla ES6) servi par Apache, + consommant des donnees (JSON statique en P5, API DB-backed au swap P4). +- **Back-office + API** — application PHP rendue serveur (MVC maison) + endpoints + `/api/*`, derriere authentification et RBAC. + +Trois canaux de commande (`source`) : `kiosk`, `counter`, `drive`. Le cycle de vie +d'une commande et la machine a etats sont decrits dans `docs/merise/` (domaine +commande = phase **P4**, schema en base mais workflow applicatif a venir). + +--- + +## 2. Stack technique + +| Couche | Techno | Note | +|---|---|---| +| Langage back | PHP 8.3 | from scratch, sans framework | +| Autoloader | PSR-4 manuel (`spl_autoload_register`) | namespace `App\` -> `src/app/` | +| Base de donnees | MariaDB 11.4 | PDO, requetes preparees uniquement | +| Serveur web | Apache httpd 2.4 (Alpine) | reverse FastCGI -> PHP-FPM | +| Serveur app | PHP-FPM 8.3 (Alpine) | execute le code back-office + API | +| Front borne | HTML5 + CSS3 + JS ES6 (modules) | vanilla, sans build | +| Conteneurisation | Docker + docker compose v2 | `docker compose up` = stack complete | +| Tests PHP | PHPUnit 11 (`.phar`, sans Composer) | unit + integration DB | +| Tests front | node:test + jsdom | harnais kiosk (`tests/js/`) | +| Analyse statique | PHPStan niveau 6 (`.phar`) | | +| CI/CD | Forgejo Actions | secret-scan, lint, tests ; merge natif sur CI verte | +| Versioning | Git + Forgejo (`git.acadenice.com`, miroir GitHub) | Conventional Commits | + +Justifications (composer-less, from-scratch, etc.) : `docs/PROJECT_CONTEXT.md` section 6. + +--- + +## 3. Topologie de deploiement + +Cinq services Docker. Deux modes, par fichier compose : + +- **`docker-compose.yml`** (versionne) — standalone : tourne en local sans configuration. + `wakdo-web` publie un port hote (`${HTTP_PORT:-8080}`), reseau interne seul. +- **`docker-compose.prod.yml`** (gitignore, propre a chaque hote) — meme stack exposee + via un reverse proxy Traefik (reseau externe + labels TLS), sans port hote. + +``` + [ docker compose up -d ] + | + wakdo-db (MariaDB 11.4, healthcheck) + | service_healthy + v + wakdo-migrate (one-shot : migrations + seed idempotents, puis sort) + | service_completed_successfully + +---------------+----------------+ + v v + wakdo-app (PHP-FPM 8.3) wakdo-web (Apache) + ^ FastCGI :9000 <-----------/ publie ${HTTP_PORT}:80 (mode local) + | ou labels Traefik (mode prod) + | + wakdo-db <-- PDO + + wakdo-cron (dcron) : backup BDD + purges retention (RGPD) +``` + +- **Reseau** : `wakdo_internal` (bridge) isole les services ; aucun port hote en mode + prod (acces par le proxy). En mode local, seul `wakdo-web` publie un port. +- **Volumes** : `wakdo_db_data` (persistance MariaDB), `wakdo_uploads` (images produits) ; + bind-mount `./var/backups` pour les dumps. +- **`wakdo-cron`** utilise `init: true` (tini comme PID 1 : dcron a besoin d'un init + parent pour `setpgid` sur ses jobs). +- Choix d'un **subnet RFC 1918 explicite** sur `wakdo_internal` cote prod : l'hote + mutualise a un allocateur Docker sature ; le subnet evite l'echec d'allocation auto. + +Detail reseaux/volumes : `docs/PROJECT_CONTEXT.md` section 5. + +--- + +## 4. Demarrage : une commande (Cr 7.c.4) + +`docker compose up -d` amene une stack complete et utilisable : + +1. `wakdo-db` demarre, devient *healthy* (script `healthcheck.sh` de l'image). +2. `wakdo-migrate` (service one-shot) applique, par le reseau et de maniere + **idempotente** : + - `db/migrations/*.sql` — suivi dans la table `schema_migrations` ; + - `db/seeds/*.sql` — suivi dans la table `seeds_applied`. + Relancer ne rejoue que les fichiers en attente. Le runner : `db/migrate-container.sh`. +3. `wakdo-app` et `wakdo-web` attendent la **completion** de `wakdo-migrate` + (`depends_on: service_completed_successfully`) avant de servir. + +Le schema (DDL) et les donnees de reference (roles, permissions, catalogue, admin +bootstrap) sont donc en place sans etape manuelle. `db/migrate.sh` (hote, via +`docker exec`) reste disponible pour l'usage manuel / `--status`. + +> Migration de mecanisme : sur une base **deja seedee** avant l'introduction du suivi +> (`seeds_applied` absente), back-filler la table avant le premier `up` (sinon re-seed +> -> conflits d'unicite). Volume vierge : aucun souci. Cf. +> `docs/journal/2026-06-17--makefile-to-compose-migrate.md`. + +--- + +## 5. Structure du code + +Namespace `App\` -> `src/app/` (PSR-4 manuel). Front controller du vhost admin : +`src/public/admin/index.php` (Apache reecrit tout vers ce fichier ; le routeur voit +le `REQUEST_URI` intact). + +``` +src/app/ + Core/ Autoloader, Config, Database (PDO), Request, Response, Router + Auth/ AuthService, SessionManager, SessionGuard, Authorizer, PinVerifier, + PinThrottle, ThrottlePolicy, PasswordHasher, Csrf, PasswordResetService, + UserRepository, RoleRepository, UserDirectory, Mailer/LogMailer + Catalogue/ Category / Product / Menu / Ingredient / Stats Repository + Controllers/ Admin (base), Authenticated (base), Auth, PasswordReset, Profile, Me, + Dashboard, Stats, Category, Product, Menu, Ingredient, User, Role, + Health, Home + Views/ admin/* (pages back-office rendues serveur), auth/* (login/reset) +src/public/ + admin/ front controller + assets (CSS/JS) du back-office + borne/ front kiosk statique (index, categories, products, product, cart, + payment, confirmation) + assets JS modules + data JSON +``` + +Conventions transverses : controleurs non-`final` (seam de test : sous-classe injectant +des doubles via `db()` / `sessionManager()`) ; repository sur `DatabaseInterface` ; +chaque mutation passe par CSRF + validation serveur + allowlist (voir section 7). + +--- + +## 6. Flux d'une requete back-office + +``` +Navigateur --(HTTPS via Traefik | HTTP local)--> wakdo-web (Apache) + | vhost par ServerName (APP_HOST_KIOSK -> public/borne, APP_HOST_ADMIN -> public/admin) + | PHP -> FastCGI :9000 + v +wakdo-app (PHP-FPM) : src/public/admin/index.php + | Router (methode + chemin) -> [Controller, action] + v +Controller (extends AdminController) + | guard(permission) -> SessionGuard (RG-6/RG-T02 : session valide ?) + | + Authorizer::can(role, permission) (RG-T03, recharge DB) + | (mutation) Csrf::validate + validation serveur (RG-T18) + allowlist (RG-T16) + | (action sensible) PinVerifier + throttle, audit_log dans la meme transaction + v +Repository -> PDO (prepared) -> MariaDB + | + v +Vue rendue dans admin/layout (sorties echappees, RG-T15) | ou JSON pour /api/* +``` + +La borne (kiosk) est servie en statique par Apache ; ses pages consomment les donnees +via `fetch` (JSON statique en P5 ; bascule sur `/api/*` DB-backed au swap P4). + +--- + +## 7. Securite (security-by-design) + +Couche transverse, regles `RG-T*` definies dans `docs/merise/mlt.md`. Synthese : + +- **Authentification** : mot de passe hache **argon2id** (cout configurable, defauts + OWASP) ; sessions PHP avec regeneration d'ID au login, idle 4h + absolu 10h ; cookie + nomme `WAKDO_SID`. +- **RBAC** : `Authorizer::can(role_id, permission_code)` teste une **permission** (pas + un nom de role), rechargee depuis la base a chaque verification. 5 roles seedes, 23 + permissions figees, matrice `role_permission` editable (back-office, voir domaine 10). +- **PIN d'action sensible (RG-T13)** : les operations sensibles (annulation, prix/TVA, + suppressions, inventaire, gestion utilisateur, RBAC, effacement PII) exigent une + re-autorisation par PIN equipier (argon2id). L'`acting_user_id` resolu par le PIN est + ecrit dans `audit_log` (RG-T14) dans la **meme transaction** que l'effet (RG-T08). Les + operations de stock tracent via `stock_movement.user_id` (pas de double-journal). +- **Throttling** (backoff degressif, pas de verrou definitif) : + - login par compte (`user.failed_login_attempts` / `lockout_until`) + par IP + (`login_throttle`, RG-8/9) ; + - PIN d'action sensible (`pin_throttle`, RG-T22) — compteur **separe** du login, par + utilisateur agissant. +- **Entrees / sorties** : validation serveur bornee (RG-T18) ; allowlist d'affectation + de masse (RG-T16, empeche d'injecter `role_id`/`price_cents`/`is_active`...) ; toutes + les sorties HTML echappees (RG-T15) ; front borne CSP-safe (pas de script inline cote + code projet). +- **Conventions HTTP** : conflit d'etat (unicite, FK RESTRICT) -> **409** ; validation + qui echoue -> **422** ; CSRF/permission -> **403**. +- **RGPD** : anonymisation (mlt 10.5) qui conserve la ligne (tombstone) pour preserver + les FK et la trace d'audit, en vidant la PII ; purges de retention par `wakdo-cron` + (audit_log, throttle, sessions, commandes). +- **Isolation** : pas de port hote en mode prod (acces par le proxy) ; user applicatif + MariaDB en moindre privilege (DDL reserve au runner migrate root ; cf. + `db/init/10-scope-app-user.sh`). + +Threat model STRIDE + classification des donnees : `docs/PROJECT_CONTEXT.md` section 19. + +--- + +## 8. Modele de donnees + +22 tables (DDL `db/migrations/`), regroupees par domaine : + +- **Catalogue** : `category`, `product`, `menu`, `menu_slot`, `menu_slot_option`, + `ingredient`, `product_ingredient`, `allergen`, `ingredient_allergen`, `stock_movement`. +- **RBAC / comptes** : `user`, `role`, `permission`, `role_permission`, + `role_visible_source`. +- **Commande (P4, schema pret)** : `customer_order`, `order_item`, + `order_item_selection`, `order_item_modifier`. +- **Transverses** : `audit_log` (journal immuable), `login_throttle`, `pin_throttle`. + +Quelques derivations **calculees, non stockees** : + +- **Stock en pourcentage** (mcd 5.3) : `stock_pct = round(stock_quantity / stock_capacity + * 100)` ; 3 bandes (normal / alerte / critique) selon `low_stock_pct` / + `critical_stock_pct`. `stock_quantity` est signe (survente assumee). +- **Disponibilite produit (RG-T21)** : un produit est commandable si `is_available = 1` + ET chaque ingredient non retirable de sa composition est au-dessus de la bande + critique. Pas de cascade ni de colonne stockee. +- **`service_day`** : journee de service (coupure a 10:00) pour les agregations stats, + expression SQL non materialisee. + +MCD / MLD / dictionnaire : `docs/merise/`. + +--- + +## 9. Tests & qualite + +- **PHPUnit** (`.phar`, sans Composer) : tests *unit* (controleurs via double + `FakeDatabase`, logique pure) + *integration* contre une vraie MariaDB (auto-skip si + `WAKDO_DB_TESTS != 1`). Lancement : + `docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml`. +- **Front borne** : `node --test` + jsdom (`tests/js/`). +- **PHPStan niveau 6** (`.phar`). +- **CI Forgejo Actions** (`.forgejo/workflows/ci.yml`) : `secret-scan` (gitleaks), + `php-lint`, `static-tests` (PHPStan + PHPUnit avec service MariaDB ephemere migre + + seede), `js-tests` (Node 20). Fusion par auto-merge NATIF Forgejo (squash, + `merge_when_checks_succeed`) des que les checks requis sont verts — pas de job de merge. +- **Branch protection** : `dev` et `main` proteges (PR requise, force-push bloque, + checks requis). + +Pyramide visee : Unit > Integration > E2E. Les tests E2E navigateur (Playwright) sont +une initiative a venir. + +--- + +## 10. Methodologie & tracabilite + +Projet developpe avec l'appui de **BYAN** (agents IA custom, Merise Agile + 64 mantras) +et d'outils d'IA generative, conformement a l'autorisation du centre de formation. + +- Decisions d'architecture, scope et design : prises par l'auteur. +- Code, tests, doc : co-rediges et valides par l'auteur avant commit. +- **Pas de trailer `Co-Authored-By`** sur les commits : la transparence vit dans le + README et `docs/PROJECT_CONTEXT.md` section 17, pas dans les metadonnees git. +- Tracabilite : `docs/journal/` (retros par session et par feature). + +--- + +*Document vivant — mis a jour au fil de l'implementation. Source de verite scope/RNCP : +`docs/PROJECT_CONTEXT.md`.* diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md new file mode 100644 index 0000000..72884ed --- /dev/null +++ b/docs/DEVELOPER.md @@ -0,0 +1,144 @@ +# Guide developpeur — Wakdo + +Comment lancer, tester et contribuer. Pour l'architecture (stack, services, modele, +securite), voir `docs/ARCHITECTURE.md`. Pour le scope et le mapping RNCP, +`docs/PROJECT_CONTEXT.md`. + +--- + +## 1. Prerequis + +- Docker Engine + docker compose v2 (https://docs.docker.com/engine/install/). +- Node 20+ (uniquement pour les tests front borne ; pas requis pour faire tourner l'app). + +Aucune installation de PHP / Composer / PHPUnit sur l'hote : tout passe par les +conteneurs et des `.phar` autonomes. + +--- + +## 2. Lancer en local + +```bash +cp .env.example .env +docker compose up -d +``` + +- Borne : http://kiosk.localhost:8080 +- Admin + API : http://admin.localhost:8080 + +`*.localhost` resout vers `127.0.0.1`. Changer le port via `HTTP_PORT` dans `.env`. +Le `.env.example` fonctionne tel quel en local (valeurs dev). Au boot, le service +`wakdo-migrate` applique migrations + seed (admin bootstrap inclus) avant que l'app +ne serve. + +Commandes utiles : + +```bash +docker compose ps # etat des services +docker compose logs -f wakdo-app # logs PHP-FPM +docker compose down # arret (volumes preserves) +docker compose down -v # arret + suppression des donnees +``` + +Deploiement derriere un reverse proxy : voir le `README.md` (section prod) + +`docker-compose.prod.yml` (gitignore, propre a l'hote). + +--- + +## 3. Base de donnees : migrations & seed + +- `db/migrations/*.sql` (DDL) et `db/seeds/*.sql` (donnees de reference) sont appliques + de maniere **idempotente** par `wakdo-migrate` (suivi `schema_migrations` / + `seeds_applied`). Relancer `docker compose up` ne rejoue que les fichiers en attente. +- **Ajouter une migration** : creer `db/migrations/000N_description.sql` (ordre + lexicographique). Appliquee au prochain `docker compose up`, ou a la main : + +```bash +bash db/migrate.sh # applique les migrations en attente (hote) +bash db/migrate.sh --status # liste l'etat sans rien appliquer +``` + +- Idem pour un seed : `db/seeds/000N_description.sql`. + +--- + +## 4. Tests & analyse statique + +Les tests PHP tournent dans l'image applicative (PHPUnit `.phar`). La stack doit etre +demarree pour les tests d'integration (ils ciblent le service `wakdo-db`). + +```bash +# Tests unitaires (sans base) +docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app \ + php phpunit.phar -c phpunit.xml --testsuite unit + +# Tests d'integration (vraie MariaDB ; auto-skip si WAKDO_DB_TESTS != 1) +docker run --rm --network wakdo_wakdo_internal --env-file .env -e WAKDO_DB_TESTS=1 \ + -v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml + +# Analyse statique PHPStan niveau 6 +docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app \ + php -d memory_limit=512M phpstan.phar analyse -c phpstan.neon --no-progress +``` + +Tests front borne (Node + jsdom) : + +```bash +npm install # une fois (devDependency jsdom) +npm run test:js # node --test tests/js/ +``` + +> Le nom de reseau `wakdo_wakdo_internal` et l'image `wakdo-wakdo-app` derivent du +> nom de projet compose (`name: wakdo`). Les `.phar` (phpunit, phpstan) sont +> gitignores ; les retelecharger si absents (voir `docs/journal/`). + +--- + +## 5. Conventions de code + +- **PSR-4 manuel** : namespace `App\` -> `src/app/`. Pas de framework. +- **Controleurs** : non-`final` (seam de test ; les tests sous-classent et injectent + des doubles via `db()` / `sessionManager()`). Heritent de `AdminController` + (back-office) ou `AuthenticatedController`. +- **Acces donnees** : un repository par entite, dependant de `DatabaseInterface` + (PDO en prod, `FakeDatabase` en test). Requetes preparees uniquement. +- **Mutations** : CSRF (`Csrf::validate`) + validation serveur bornee (RG-T18) + + allowlist de colonnes (RG-T16). Sorties HTML echappees (RG-T15). +- **Actions sensibles** : PIN equipier (`PinVerifier`) + `audit_log` dans la meme + transaction ; throttle PIN (`PinThrottle`). Voir `docs/ARCHITECTURE.md` section 7. +- **Statuts HTTP** : conflit -> 409 ; validation -> 422 ; CSRF/permission -> 403. +- **Pas d'emoji** dans le code, les commits, les specs (Mantra IA-23). + +Detail par entite : `docs/merise/` et `docs/domaines/` (a venir). + +--- + +## 6. Git & CI + +- **Conventional Commits** (anglais) : `type(scope): description` — types `feat`, `fix`, + `docs`, `refactor`, `test`, `chore`, `ci`, `db`, `perf`, `style`. +- **Branches** depuis `dev` : `feat/*`, `fix/*`, `docs/*`, `chore/*`, `ci/*`, `db/*`, + `refactor/*`, `test/*`. Merge vers `dev` par **PR squashee**. Periodiquement + `dev -> main` avec tag semver. +- **Auto-merge** : l'ouverture de la PR programme la fusion squash automatique des que + les checks requis passent (auto-merge NATIF Forgejo `merge_when_checks_succeed`, sans + label ni job CI). Script : `scripts/forgejo-pr-automerge.sh`. +- **Pas de trailer `Co-Authored-By`** : la transparence sur l'usage de l'IA vit dans le + `README.md` et `docs/PROJECT_CONTEXT.md` section 17. + +--- + +## 7. Ou trouver quoi + +| Besoin | Emplacement | +|---|---| +| Architecture, stack, securite, modele | `docs/ARCHITECTURE.md` | +| Scope metier, planning, mapping RNCP | `docs/PROJECT_CONTEXT.md` | +| Modelisation Merise (dictionnaire, MCD/MCT/MLT, regles RG-T*) | `docs/merise/` | +| Decisions d'architecture (le pourquoi) | `docs/adr/` | +| Retros par session / feature | `docs/journal/` | +| Methodologie agents | `.claude/CLAUDE.md` + `.claude/rules/` | + +--- + +*Document vivant — mis a jour au fil de l'implementation.* diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 2efbaaf..fe6632e 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -26,12 +26,19 @@ Wakdo est une **borne de commande tactile** pour un restaurant de restauration r ### Acteurs -| Acteur | Role | Interface | +| Acteur | Role RBAC | Interface | |---|---|---| -| **Client** | Passe sa commande sur la borne | Borne tactile (Bloc 1) | -| **Accueil** | Saisit commandes au **comptoir** (client au guichet) ou au **drive** (client en voiture via intercom + casque equipier), remet les commandes livrees aux clients | Back-office (Bloc 2) | -| **Preparation** | Voit les commandes a preparer triees par heure croissante, les declare "preparees" | Back-office (Bloc 2) | -| **Administration** | CRUD sur donnees (produits, menus, prix, images) + gestion utilisateurs + stats | Back-office (Bloc 2) | +| **Client** | (non authentifie) | Borne tactile (Bloc 1, canal `kiosk`) | +| **Counter** | `counter` | Back-office : saisit les commandes au **comptoir**, les remet au client, peut annuler | +| **Drive** | `drive` | Back-office : saisit les commandes au **drive** (intercom + casque), les remet, peut annuler | +| **Kitchen** | `kitchen` | Back-office : voit la file des commandes `paid` triees par `paid_at` croissant, en **lecture seule** (KDS visuel, aucune transition) | +| **Manager** | `manager` | Back-office : catalogue (create/update), stock/reappro, statistiques | +| **Administration** | `admin` | Back-office : catalogue complet (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats | + +> Modele v0.2 : 5 roles RBAC (`admin`, `manager`, `kitchen`, `counter`, `drive`) +> + Customer non authentifie. RBAC permission-driven (le code teste une +> permission, pas un nom de role) ; catalogue de 23 permissions fige au seed. +> Voir `docs/merise/dictionary.md` 3.15-3.18 et `docs/uml/use-cases.md`. ### Processus metier cle @@ -46,21 +53,21 @@ Client Borne (Bloc 1) API (Bloc 2) BDD │ │─POST /api/orders─────▶│───INSERT──────────▶│ │ │◀──────────201─────────│ │ │─recupere au comptoir │ │ │ - Preparation voit commande pending - → declare "preparee" - Accueil voit commande prete - → declare "livree" + Kitchen voit la file des commandes paid (lecture seule, KDS) + Counter / Drive remettent au client + → declarent "livree" (geste unique paid -> delivered) ``` ### Regles metier (MCT - a modeliser en Merise) -- Un **menu** = burger + accompagnement (frites OU salade) + boisson + sauce -- Les **accompagnements** et **boissons** ont **2 tailles** (normale / grande) -- **Grande taille** = +0,50 € sur le prix de base -- Une **commande** a un **numero** saisi par le client (remplace le paiement dans le cadre de l'exam) -- Statuts commande : `pending` -> `preparing` -> `ready` -> `delivered` (ou `cancelled`) +- Un **menu** = burger fixe + slots a choix (boisson, accompagnement, sauce). Modele relationnel `menu_slot` + `menu_slot_option` (voir `dictionary.md` 3.4-3.5) +- Format **Normal / Maxi** au niveau du menu (deux prix : `price_normal_cents`, `price_maxi_cents`) ; le Maxi agrandit accompagnement + boisson uniquement +- **Personnalisation des ingredients** (retirer = gratuit, ajouter = supplement) sur les sandwichs composes, via le configurateur (`ingredient`, `product_ingredient`, `order_item_modifier`) +- **TVA portee par le produit** (`vat_rate` : 10% defaut, 5,5% contenant conservable), calculee ligne par ligne et snapshotee sur `order_item` (fact-check BOFiP, voir `dictionary.md` note 9) +- Une **commande** a un **numero** saisi par le client, prefixe par canal `K`/`C`/`D` (remplace le paiement dans le cadre de l'exam) +- Statuts commande (machine a **4 etats**) : `pending_payment` -> `paid` -> `delivered` (+ `cancelled`). La transition `pending_payment -> paid` est **atomique** a la creation (saisie du numero = substitut de paiement). `cancelled` est atteignable depuis `pending_payment` et `paid` (pas depuis `delivered`). Plus de `preparing` / `ready` : la cuisine est en lecture seule, la remise est un geste unique - **Source commande** (trace sur chaque commande) : `kiosk` (borne autonome) | `counter` (comptoir) | `drive` (drive-thru) -- La preparation voit les commandes triees par **heure de livraison croissante** (tous canaux confondus) +- Le canal de prepa (`kitchen`/`counter`/`drive`) voit la file des commandes `paid` triee par `paid_at` **croissant**, filtree par `role_visible_source` (kitchen voit tout ; counter voit kiosk+counter ; drive voit drive) - **Horaires service** : 10h00 → 01h00 du matin (service continu 15h, pas de fermeture intermediaire) - **Pas de notion de "session de service" a modeliser** : les equipiers se relaient, chacun se connecte a sa prise de poste et se deconnecte a la fin. Pas de "shift" a tracer dans la BDD (hors scope RNCP) - **Fenetre de maintenance systeme** : 01h30 → 09h30 (crons lourds, backups, agregations) — evite toute interference avec le service actif @@ -106,13 +113,13 @@ Client Borne (Bloc 1) API (Bloc 2) BDD **Un seul codebase, deux FQDN d'exposition publique.** Le front Bloc 1 et le back Bloc 2 coexistent dans la meme arborescence. Une bascule mode JSON-seuls (Bloc 1 isole) vs mode API-connecte doit rester possible via configuration. -**Pourquoi pas strategie A (deux rendus isoles)** : le Bloc 5 DevOps impose une conteneurisation **unique** qui lance la stack complete avec `make init` en une commande (Cr 7.c.4). Deux codebases isolees seraient incoherentes avec cette exigence. +**Pourquoi pas strategie A (deux rendus isoles)** : le Bloc 5 DevOps impose une conteneurisation **unique** qui lance la stack complete avec `docker compose up` en une commande (Cr 7.c.4). Deux codebases isolees seraient incoherentes avec cette exigence. ### Compatibilite evaluation par bloc - **Jury Bloc 1** : voit le front seul ; le front peut tomber en fallback sur JSON statiques fournis (`src/public/borne/data/*.json`) si l'API est indisponible. - **Jury Bloc 2** : voit le back-office + teste l'API via curl/Postman de maniere autonome, sans dependre du front. -- **Jury Bloc 5** : lance `make init` ou `docker compose up`, verifie la CI/CD, les crons, l'archi, les scripts. +- **Jury Bloc 5** : lance `docker compose up` ou `docker compose up`, verifie la CI/CD, les crons, l'archi, les scripts. --- @@ -135,7 +142,7 @@ Client Borne (Bloc 1) API (Bloc 2) BDD │ (admin_proxy network) │ └──────────┬────────────────────────────┬─────────────────────────┘ │ │ - wakdo.acadenice.fr wakdo-admin.acadenice.fr + corentin-wakdo.stark.a3n.fr corentin-wakdo-admin.stark.a3n.fr │ │ ▼ ▼ ┌──────────────────────────────────────────┐ @@ -186,10 +193,10 @@ Reseaux : | Reverse proxy | Traefik (deja en place) | existant | `admin_proxy` network | | TLS | Let's Encrypt via Traefik | auto | `acme.json` existant | | Conteneurisation | Docker + docker compose | v2 | Cr 7.c | -| Orchestration locale | Makefile | — | Cr 7.b (script) + Cr 7.c.4 (une commande) | -| CI/CD | GitHub Actions | — | Cr 7.d | -| Versioning | Git + GitHub | — | Cr 4.f (collaboration) | -| Hooks Git | pre-commit + commit-msg | versionnes dans `.githooks/` | Conventional Commits | +| Orchestration locale | docker compose (service wakdo-migrate) | — | Cr 7.b (script) + Cr 7.c.4 (une commande) | +| CI/CD | Forgejo Actions (act_runner auto-heberge) | — | Cr 7.d | +| Versioning | Git + Forgejo auto-heberge (push-mirror GitHub) | — | Cr 4.f (collaboration) | +| Hooks Git | pre-commit (refus main/dev + php -l) + commit-msg (format Conventional Commits) | versionnes dans `.githooks/`, actives via `scripts/install-hooks.sh` | Conventional Commits | --- @@ -220,10 +227,11 @@ Reseaux : **IN scope — Back-office :** - Authentification sessions securisees (hash bcrypt/argon2, protection CSRF, fixation session) — duree de session adaptee a un poste complet d'equipier (idle timeout 4h, absolute timeout 10h) -- 3 roles RBAC : `admin`, `preparation`, `accueil` -- **Admin** : CRUD categories, produits (nom, description, prix, image, dispo), menus (composition + options), utilisateurs -- **Preparation** : liste commandes a preparer triees par heure livraison croissante, bouton "declarer preparee" -- **Accueil** : saisir commande manuellement (comptoir ou drive-thru via casque/intercom), bouton "declarer livree" ; champ `source` enregistre sur chaque commande (`counter` ou `drive`) +- 5 roles RBAC seed : `admin`, `manager`, `kitchen`, `counter`, `drive` (RBAC permission-driven, 23 permissions figees au seed ; roles personnalises possibles) +- **Admin** : CRUD complet catalogue (+ suppressions), gestion utilisateurs, roles et permissions (RBAC), stats +- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; utilisateurs en **lecture seule** (`user.read`, pas de creation/modification/desactivation), pas d'acces RBAC +- **Kitchen** : file des commandes `paid` triee par `paid_at` croissant, en **lecture seule** (KDS visuel) ; inventaire +- **Counter** / **Drive** : saisir une commande (comptoir / drive-thru via casque/intercom), bouton "declarer livree" (geste unique `paid -> delivered`), annuler ; `source` auto-tague depuis `role.order_source` ; inventaire - Upload images produits (validation type MIME + taille + stockage dans volume `wakdo_uploads`) - Historique commandes par statut - Stats de base (commandes du jour, CA jour, produits top) @@ -254,16 +262,18 @@ Reseaux : **IN scope :** - Dockerfile custom PHP-FPM avec extensions -- `docker-compose.yml` orchestrant les 4 services (web, app, db, cron) -- `Makefile` avec cible `make init` qui lance tout en une commande (Cr 7.c.4) -- Scripts Bash d'automatisation (backup, deploy, migrate) -- **Cron tab** avec au moins 3 jobs planifies dans la fenetre de maintenance (01h30-09h30) : +- `docker-compose.yml` orchestrant 5 services : 4 longs (web, app, db, cron) + 1 one-shot (`wakdo-migrate`) +- `docker compose up` lance toute la stack (service one-shot `wakdo-migrate` : migrations + seed idempotents) en une commande (Cr 7.c.4) +- Scripts Bash d'automatisation (backup, restore, deploy, migrate) +- **Cron tab** avec 3 jobs actifs planifies dans la fenetre de maintenance (01h30-09h30) : - `0 3 * * *` — backup BDD quotidien a 03h00 (entre fin service 01h et ouverture 10h) - - `*/15 * * * *` — purge sessions expirees toutes les 15 min (leger, peut tourner en service) - - `30 4 * * *` — agregation stats commandes a 04h30 sur le **jour de service** ecoule (10h J-1 → 01h J) -- **CI GitHub Actions** : lint PHP + PHPUnit sur PR -> dev -- **CD GitHub Actions** : deploy auto sur merge main (SSH + pull + `make rebuild`) -- `.env.example` documente, secrets hors du repo + - `15 4 * * *` — purge du journal d'audit au-dela de la fenetre de retention (~12 mois) + - `45 4 * * *` — purge des compteurs de throttle expires + - Differes (templates commentes dans `docker/cron/crontab`, a activer plus tard) : purge des sessions expirees, agregation des stats sur le jour de service +- **CI Forgejo Actions** (act_runner auto-heberge) : lint PHP + PHPStan + PHPUnit + secret-scan (gitleaks) + js-tests sur PR -> dev +- **CD : deploiement scripte a declenchement humain** (`scripts/deploy.sh` : recupere `main` depuis Forgejo puis `docker compose build --pull && up -d` -- les images wakdo sont buildees localement depuis les Dockerfiles, pas de registre). Choix solo dev sur un environnement de prod unique. L'automatisation visee est **pull-based** : un job cron cote hote qui detecte un nouveau `main` et lance `deploy.sh` (a armer ensuite, reutilise le meme script) +- `.env.example` documente (parametres securite : argon2id, lockout, seuils throttle, retention RGPD), secrets hors du repo +- `php.ini` durci (expose_php off, session cookies httponly/secure/samesite, upload limite) - Healthcheck Traefik + readiness probes - Logs centralises (stdout des conteneurs) - Documentation deploiement + architecture (schemas dans `docs/`) @@ -294,21 +304,21 @@ Reseaux : | Cr 2.a.1-5 | JS ES6+ + DOM + animations | Modules ES6, classes, async/await, pas de jQuery | | Cr 2.b.1-3 | Validation formulaires | Validation client temps reel (regex) + validation serveur | | Cr 2.c.1-4 | Ajax async | `fetch()` avec gestion erreurs, pas d'exposition donnees sensibles | -| Cr 2.d.1-3 | Librairies externes | **Non applicable** (zero dep JS) — argumenter "developpement sans lib externe" | +| Cr 2.d.1-3 | Librairies externes | Choix de stack assume : **zero lib JS** (vanilla). Cr 2.d.1-3 restent du tronc commun evaluable -> a argumenter a l'oral ; ce n'est pas une dispense du referentiel | ### Bloc 2 | Critere | Libelle court | Feature Wakdo couvrant | |---|---|---| -| Cr 3.a.1-4 | Analyse + modele donnees | Dictionnaire + MCD + cardinalites | -| Cr 3.a.3 | Exploiter donnees externes d'API | API interne consommee par le front (auto-consommation) | +| Cr 3.a.2-4 | Analyse + modele donnees (3 criteres ; le referentiel ne contient PAS de Cr 3.a.1) | Dictionnaire + MCD + cardinalites | +| Cr 3.a.3 | Exploiter donnees externes d'API | Enrichissement nutritionnel depuis **OpenFoodFacts** (API tierce) importe DANS le modele (`ingredient.energy_kcal_100g`), a la demande admin (opt-in, sans egress runtime) ; + auto-consommation de l'API interne par la borne | | Cr 3.b.1-3 | Construction BDD | MCD → MLD → DDL MariaDB, FK + typage coherent | | Cr 3.c.1-3 | Requetes SQL optimisees | PDO prepared, index sur FK, LIMIT/tri explicites | | Cr 3.d.1-4 | RGPD | hash mdp, droit acces/modif/suppr, info utilisation donnees | | Cr 4.a.1-4 | Conceptualisation | Schema fonctionnel des vues + interactions | | Cr 4.b.1-6 | Syntaxe + indentation + erreurs | PSR-12 style, try/catch cibles, logs | -| Cr 4.c.1-3 | POO + heritage + namespaces | `BaseModel` -> `Product`, `BaseController` -> `AdminController`, PSR-4 | -| Cr 4.d.1-3 | MVC | `src/Models/`, `src/Views/`, `src/Controllers/`, separation stricte | +| Cr 4.c.1-3 | POO + heritage + namespaces | heritage de controleurs (`Controller` -> `AuthenticatedController` -> `AdminController` -> ...), couche modele en Repository pattern, autoloader PSR-4 manuel | +| Cr 4.d.1-3 | MVC | `src/app/Controllers/`, `src/app/Views/`, couche modele = Repositories (`*Repository`) + `Core/Database` ; separation stricte (pas de dossier `Models/` : Repository pattern) | | Cr 4.e.1-3 | Securite | PDO prepared (anti-SQLi), sessions regeneration, role-based middleware | | Cr 4.f.2 | Maitrise outil collaboratif (artefact) | Commits Conventional, branches `feat/*`, PR descriptions, squash merge, hooks Git | | Cr 4.f.1, 4.f.3, 4.f.4 | Soft skills (evalues a l'oral) | Partage de savoir-faire (4.f.1), auto-evaluation avant PR (4.f.3), compte-rendu de la participation individuelle (4.f.4) — demontres pendant la soutenance | @@ -319,16 +329,16 @@ Reseaux : | Critere | Libelle court | Feature Wakdo couvrant | |---|---|---| | Cr 7.a.1-3 | Analyse infra + securite | Audit code + proposition automatisation documentee | -| Cr 7.b.1 | Langage de script | Bash (deploy, backup) + Makefile | -| Cr 7.b.2 | Automatisation fiabilisee | Makefile avec exit codes, retries, logs | +| Cr 7.b.1 | Langage de script | Bash (db/*.sh migrate/seed, scripts/forgejo-*.sh, entrypoints, backup) | +| Cr 7.b.2 | Automatisation fiabilisee | Scripts Bash (set -euo pipefail, exit codes, logs) + service compose wakdo-migrate idempotent | | Cr 7.b.3 | **Cron tab** | `wakdo-cron` service avec crontab : backup BDD, purge sessions, stats | | Cr 7.c.1 | VM operationnelle | Serveur existant Acadenice | | Cr 7.c.2 | OS conteneur installe | Docker Engine | -| Cr 7.c.3 | App conteneurisee complete | 4 services (web, app, db, cron) | -| Cr 7.c.4 | **Une ligne de commande** | `make init` lance toute la stack + migrate + seed | +| Cr 7.c.3 | App conteneurisee complete | 5 services : 4 longs (web, app, db, cron) + 1 one-shot (wakdo-migrate) | +| Cr 7.c.4 | **Une ligne de commande** | `docker compose up` lance toute la stack + migrate + seed | | Cr 7.d.1 | Architecture serveur | Traefik reverse + reseaux segmentes documentes | -| Cr 7.d.2 | Tests avant deploy | CI PHPUnit + lint sur PR | -| Cr 7.d.3 | Integration/deploiement continus | GitHub Actions deploy automatique sur merge main | +| Cr 7.d.2 | Tests avant deploy | CI PHPUnit + PHPStan + secret-scan sur PR (Forgejo Actions) | +| Cr 7.d.3 | Integration/deploiement continus | CI complete sur PR ; deploiement scripte a declenchement humain (`scripts/deploy.sh`). Auto-CD sur merge main non arme (choix solo dev, a argumenter) | --- @@ -344,13 +354,13 @@ main ← production (tag vX.Y.Z sur chaque release) fix/* ← corrections refactor/* ← refactos docs/* ← doc seulement - ci/* ← GitHub Actions + ci/* ← Forgejo Actions db/* ← migrations / schema BDD chore/* ← tooling, config test/* ← ajout de tests ``` -Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direct autorise. Hook pre-commit local les bloque egalement. +Les branches `main` et `dev` sont **protegees** cote Forgejo (push direct interdit, force-push bloque, PR obligatoire via l'API `branch_protections`). Hook pre-commit local les bloque egalement. **Flow :** 1. `git checkout -b feat/menu-composition` (depuis `dev`) @@ -430,11 +440,12 @@ Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direc | 8 | 2 FQDN | Separation claire borne publique / admin+API interne, defensible jury | | 9 | API sous `/api` sur le FQDN admin | Simplicite d'exploitation, CORS explicite gere | | 10 | Service cron dedie | Cr 7.b.3 explicite + realiste prod | -| 11 | Makefile avec `make init` | Cr 7.c.4 + demonstration DevOps | +| 11 | Orchestration `docker compose up` (service wakdo-migrate) | Cr 7.c.4 + demonstration DevOps | | 12 | Conventional Commits + hooks | Cr 4.f.x + discipline de versioning | -| 13 | Branches feat/* -> dev -> main | Pipeline propre pour jury, GitHub PR trace | -| 14 | CI/CD GitHub Actions | Cr 7.d explicite dans referentiel | +| 13 | Branches feat/* -> dev -> main | Pipeline propre pour jury, PR tracee (Forgejo, mirror GitHub) | +| 14 | CI/CD Forgejo Actions (act_runner auto-heberge) | Cr 7.d explicite ; forge + CI maitrisees de bout en bout (argument Bloc 5) | | 15 | RGPD implemente minimal | Cr 3.d.1-4 evaluees meme projet ecole | +| 16 | Security-by-design (threat model STRIDE + classification donnees) | Audit Cr 7.a ; stock en %, throttle brute-force, retention RGPD documentes en amont du code | --- @@ -442,18 +453,18 @@ Les branches `main` et `dev` sont **protegees** cote GitHub. Pas de commit direc | Phase | Scope | Budget (h) | Deadline intermediaire | |---|---|---|---| -| **P0 - Setup** | PC, arborescence, Docker, hooks, CI squelette, init Git/GitHub | 20 | Semaine 1 | -| **P1 - Conception Merise** | Dictionnaire, MCD, MCT, MLD, schemas fonctionnels, DDL | 30 | Semaine 3 | +| **P0 - Setup** | PC, arborescence, Docker, hooks, CI squelette, migration Forgejo + act_runner | 22 | Semaine 1 | +| **P1 - Conception Merise + Security-by-design** | Dictionnaire, MCD, MCT, MLD, schemas fonctionnels, DDL, threat model STRIDE + classification donnees + sequence securite | 38 | Semaine 3 | | **P2 - Back squelette** | POO base (Core, Router, Autoloader, DB), auth + roles | 30 | Semaine 6 | | **P3 - Back CRUD admin** | Produits, menus, utilisateurs, views | 40 | Semaine 10 | | **P4 - API REST** | Endpoints + CORS + tests | 20 | Semaine 12 | | **P5 - Front borne** | Integration maquette, Ajax, accessibilite, responsive | 60 | Semaine 16 | | **P6 - Tests + finition** | PHPUnit, tests E2E borne, corrections | 25 | Semaine 18 | -| **P7 - DevOps finalisation** | CI/CD deploy auto, crons, docs argumentation | 20 | Semaine 19 | +| **P7 - DevOps finalisation** | Forgejo Actions CI/CD (PHPUnit + PHPStan + secret-scan + deploy auto), crons, SECURITY.md, docs argumentation | 22 | Semaine 19 | | **P8 - Prep soutenance** | README pour jury, schemas finaux, repetitions, modifs en direct | 15 | Semaine 20 | -| **TOTAL** | | **260** | **Semaine 20 = fin aout 2026** | +| **TOTAL** | | **272** | **Semaine 20 = fin aout 2026** | -Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/semaine**. +Buffer : ~8 h pour imprevus. Cible effective : ~264 h sur 20 semaines = **~13 h/semaine**. --- @@ -480,8 +491,8 @@ Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/ **Bloc 5 :** - `docker-compose.yml` commente - Dockerfiles customs commentes -- `Makefile` avec `make help` -- `.github/workflows/` avec CI + CD +- Orchestration via `docker compose` (service one-shot `wakdo-migrate` : migrate + seed) +- `.forgejo/workflows/` avec CI (PHPUnit + PHPStan + secret-scan) + CD - Crontab documente - Script de backup/restore teste - Architecture serveur decrite (`docs/architecture/deployment.md`) @@ -490,7 +501,7 @@ Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/ - **README.md** synthetique (quick start + liens docs) - **Presentation** (slides ou live) argumentant les choix -- **Demo** live : borne + back-office + API (Postman/curl) + `make init` +- **Demo** live : borne + back-office + API (Postman/curl) + `docker compose up` - **Capacite modification en direct** (Cr 4.a.1) : code structure pour permettre modifs sans casser --- @@ -501,7 +512,7 @@ Buffer : ~20 h pour imprevus. Cible effective : ~240 h sur 20 semaines = **12 h/ |---|---|---|---| | Sous-estimation temps front (accessibilite RGAA stricte) | Haute | Moyen | 60 h budgetees + tests W3C/axe-core pendant le dev, pas a la fin | | Complexite MCT (statuts commande) mal modelisee | Moyenne | Fort | Valider MCT avec un pair ou prof avant d'implementer Bloc 2 | -| Dockerfile PHP extensions manquantes decouvert tard | Moyenne | Faible | Tester `make up` + un vrai appel BDD des P0 | +| Dockerfile PHP extensions manquantes decouvert tard | Moyenne | Faible | Tester `docker compose up -d` + un vrai appel BDD des P0 | | Conflit reseau Docker `wakdo_internal` existant | Faible | Faible | Verifie au setup, fallback nom `wakdo_backend` | | CORS mal configure bloque la borne | Moyenne | Moyen | Test immediat apres setup 2 FQDN | | Performance borne sur ecran tactile reel | Faible | Fort | Optimiser images + lazy loading + tests sur device tactile si possible | @@ -604,7 +615,7 @@ L'auteur peut recourir ponctuellement a d'autres outils IA (completion IDE, assi - **Choix du scope fonctionnel** : defini par l'auteur a partir du brief RNCP. L'IA n'ajoute ni ne retire de fonctionnalite sans instruction explicite. - **Modelisation Merise** (MCD, MCT, MLD) : formalisation produite par l'IA a partir du dictionnaire de donnees et des user stories ; arbitrage, validation et corrections par l'auteur. Chaque cardinalite, chaque relation et chaque transition de statut est validee par l'auteur avant integration. Le livrable final reflete ses decisions. - **Validation des livrables** : reservee au jury. L'IA n'emet pas de jugement final sur la conformite RNCP. -- **Deploiements** : declenchement humain uniquement, y compris sur `make init` local. Aucune action sur environnement serveur sans instruction explicite. +- **Deploiements** : declenchement humain uniquement, y compris sur `docker compose up` local. Aucune action sur environnement serveur sans instruction explicite. - **Commit en son nom** : aucun trailer `Co-Authored-By: Claude...` n'est appose sur les commits. Voir section 17.7. - **Decisions de securite critiques** : tous les choix de type hash mdp, CORS, RBAC, politique sessions sont valides par l'auteur meme si l'IA en propose la mise en oeuvre. @@ -670,9 +681,143 @@ Ces regles tiennent lieu de garde-fous pendant toute la duree du projet. Les enf 6. **Zero requete SQL sans prepared statement** (anti-SQLi) 7. **Zero hash mdp en clair** (bcrypt ou argon2) 8. **Zero CORS `*`** (origine explicite uniquement) -9. **Zero deployment manuel** en condition normale (CI/CD) +9. **Deploiement scripte et trace** (`scripts/deploy.sh`), declenche par l'exploitant ; pas de modification manuelle ad hoc en prod 10. **Zero feature hors scope** sans mise a jour de ce document --- -*Document vivant — version 1.1 — 2026-04-24 (ajout section 17 transparence IA). A mettre a jour a chaque decision structurante.* +## 19. Security threat model and data classification + +Cette section formalise la couche **security-by-design** ajoutee au modele Merise v0.2 +(voir `docs/merise/dictionary.md` note 13, `docs/merise/mlt.md` section 2 pour les regles +transverses RG-T13 a RG-T21). Elle se lit a deux niveaux : un **registre des risques** de +synthese pour une lecture gestion, suivi d'une **analyse STRIDE par element** pour la +profondeur technique, puis une **matrice de classification des donnees** en 4 niveaux. Tous +les claims securite sont rattaches a un mecanisme concret (une regle RG-T, une colonne, une +entite) plutot qu'enonces comme des absolus. + +### 19.1 Perimetre et frontieres de confiance + +Le systeme expose cinq frontieres de confiance (trust boundaries), correspondant aux points +d'entree analyses ci-dessous : + +- **E1 — Borne kiosk (public anonyme)** : `POST /api/orders`, consultation du catalogue. + Aucune authentification ; la borne est anonyme par conception (les commandes kiosk ont + `customer_order.acting_user_id = NULL`). Surface la plus exposee, donc traitee sans + hypothese de confiance sur l'entree. +- **E2 — Back-office admin (staff authentifie, poste partage + PIN par equipier)** : CRUD + catalogue/menus/ingredients, RBAC, gestion utilisateurs, stock, annulation de commande, + stats. Session partagee par poste pour le flux courant ; un PIN par equipier + (`user.pin_hash`) re-autorise l'ensemble sensible (RG-T13). +- **E3 — Surface d'authentification** : login (`AUTHENTICATE_USER`, op 25, `mlt.md` 12.1) et + reinitialisation de mot de passe (`RESET_PASSWORD`, op 28, `mlt.md` 12.3). +- **E4 — Couche donnees / BDD** : acces PDO, requetes preparees (RG-T06), allowlists + (RG-T16/RG-T17), integrite transactionnelle (RG-T08/RG-T11), snapshots immuables (RG-T05). +- **E5 — Stock / inventaire** : decrement de vente, reappro, comptage d'inventaire, avec un + journal append-only `stock_movement` et attribution de l'acteur ; la correction d'inventaire + est PIN-gated (RG-T13, `mlt.md` 9.2) car elle peut masquer de la demarque (shrinkage). + +Hors perimetre de cette section : la securite reseau/infra (Traefik, segmentation Docker, +TLS), couverte en section 5 ; le durcissement CORS, couvert en section 5. + +### 19.2 Registre des risques (risk register) + +Synthese gestion. Likelihood et residual risk sont evalues a dire d'expert pour ce projet +fictif (`[REASONING]`, non quantifies par benchmark) ; chaque mitigation cite une regle RG-T +et/ou une entite reelle du modele. + +| # | Actif | Menace | Impact | Likelihood | Mitigation (regle / entite) | Risque residuel | +|---|---|---|---|---|---|---| +| R1 | Recette (cash) sur commande payee | Un equipier annule une commande `paid` pour detourner l'encaissement (fraude interne) | Fort | Moyenne | `CANCEL_ORDER` (`mlt.md` 7.1) PIN-gated (RG-T13) + ecriture `audit_log` dans la meme transaction (RG-T14, RG-T11) ; acteur capture via `audit_log.actor_user_id` | Faible — l'annulation reste possible mais devient nominative et tracee ; dissuasion plus que blocage | +| R2 | `product.price_cents` / `vat_rate` / `role_id` | Falsification via un champ de formulaire injecte (mass-assignment) | Fort | Moyenne | Allowlist de colonnes par operation (RG-T16) sur `UPDATE_PRODUCT` (`mlt.md` 8.2) et `UPDATE_USER` (10.2) ; seules les colonnes autorisees sont bindees | Faible — les champs hors allowlist sont ignores ; un changement de prix reste audite (RG-T14) | +| R3 | Comptes back-office (`user.password_hash`) | Brute-force sur le login staff | Moyen | Haute | Backoff degressif par compte (`user.failed_login_attempts` / `lockout_until`) + par IP (`login_throttle`, entite 21) ; gate avant verification (`mlt.md` 12.1 PRE-3, RG-8) | Faible — ralentissement sans lock indefini ; un service de 15h n'est pas bloque par une saisie maladroite | +| R4 | Vues kiosk et admin (texte stocke) | XSS stocke via `product.name` / `ingredient.name` / `user.first_name` | Moyen | Moyenne | Echappement au rendu (RG-T15) : `htmlspecialchars(..., ENT_QUOTES)` cote admin, injection via `textContent` (pas `innerHTML`) cote kiosk vanilla-JS | Faible — l'echappement reduit le risque d'execution de script injecte | +| R5 | `ingredient.stock_quantity` | Survente (oversell) sous concurrence multi-borne | Moyen | Moyenne | Decrement atomique auto-verrouillant (RG-T20) sans read-gate + disponibilite calculee (RG-T21) ; `stock_quantity` signe, la magnitude de survente est remontee aux managers | Moyen accepte — le systeme ne bloque pas une commande sur le stock ; la survente est mesuree, pas empechee (decision metier) | +| R6 | Commande payee | Double-charge sur retry reseau de `POST /api/orders` | Moyen | Moyenne | Idempotence (RG-T19) : `customer_order.idempotency_key` UNIQUE ; un retry renvoie la commande existante au lieu d'en creer une seconde | Faible — la cle UNIQUE deduplique les rejeux ; depend d'une cle client correctement generee | +| R7 | PII utilisateur (`user.email`/`first_name`/`last_name`) | Demande d'effacement RGPD non honoree, ou rupture de l'integrite referentielle a la suppression | Fort (conformite) | Faible | Anonymisation (`ERASE_USER_PII`, `mlt.md` 10.5) : la ligne est conservee, PII remplacees par un placeholder `anon-@wakdo.invalid`, credentials invalides, `anonymized_at` pose ; `audit_log` retient sa propre fenetre | Faible — effacement et tracabilite coexistent ; les FK (`audit_log.actor_user_id`, `customer_order.acting_user_id`, `stock_movement.user_id`) restent valides | +| R8 | Matrice RBAC (`role_permission`) | Elevation de privilege via modification de role non controlee | Fort | Faible | `MANAGE_RBAC` (`mlt.md` 10.4) PIN-gated (RG-T13) + `audit_log` du diff de permissions (RG-T14, RG-6) ; `role_id` derriere l'allowlist (RG-T16) | Faible — tout gain/perte de capacite est nominatif et trace | +| R9 | `stock_movement` (demarque) | Correction d'inventaire masquant une demarque | Moyen | Moyenne | `INVENTORY_COUNT` (`mlt.md` 9.2) PIN-gated (RG-T13) ; le `user_id` capture par PIN est ecrit dans `stock_movement.user_id` (append-only) | Faible — la correction devient attribuable a une personne meme sur poste partage | + +### 19.3 Analyse STRIDE par element + +Un bloc par categorie STRIDE, mappe aux controles reels du modele (verifies contre +`mlt.md` section 2). + +**Spoofing (usurpation d'identite).** L'authentification back-office repose sur argon2id +(`user.password_hash`, `mlt.md` 12.1 RG-2) avec regeneration de session a la connexion +(`session_regenerate(true)`, RG-3) pour contrer la fixation. Le login est enumeration-safe : +meme erreur generique que l'email existe ou non, avec un `password_verify` leurre pour garder +le timing comparable (RG-2). Sur un poste partage, un PIN par equipier (`user.pin_hash`, +RG-T13) re-authentifie l'acteur reel pour les actions sensibles. La reinitialisation de mot de +passe (`RESET_PASSWORD`, `mlt.md` 12.3) stocke le token hashe (`password_reset_token_hash`), +n'envoie le token brut qu'une seule fois, l'expire a 1h et le rend a usage unique (RG-2/RG-3) ; +la phase requete renvoie une reponse neutre identique que le compte existe ou non (RG-1, +enumeration-safe). La borne kiosk est anonyme +par conception, donc hors perimetre d'usurpation (pas de compte a usurper). + +**Tampering (alteration).** L'allowlist de mass-assignment (RG-T16) limite les colonnes +bindees aux champs autorises par operation, protegeant `price_cents`, `vat_rate`, `role_id`, +`is_active`, `status`. La validation cote serveur (RG-T18) re-verifie type, plage, longueur, +appartenance ENUM et existence des FK independamment du client. Les requetes preparees PDO +(RG-T06) traitent les valeurs hors de la chaine SQL, ce qui ferme l'injection SQL par valeur. +Les identifiants SQL dynamiques (colonne et direction d'un `ORDER BY`/`GROUP BY`) sont resolus +contre une allowlist fixe avant construction de la requete (RG-T17), car un identifiant ne +peut pas etre bind comme une valeur. +Les snapshots de commande (`order_item.label_snapshot`, `unit_price_cents_snapshot`, +`vat_rate_snapshot`) sont immuables apres INSERT (RG-T05), preservant l'integrite historique +des commandes placees. La re-validation serveur des modifiers (`mlt.md` 3.3 RG-9) rejette un +`POST` forge ajoutant un ingredient non-`is_addable`. + +**Repudiation (deni d'action).** Le journal `audit_log` (entite 20, RG-T14) enregistre les +actions sensibles non-stock avec `actor_user_id` (capture par PIN, RG-T13), `actor_role_id` +(denormalise pour survivre a l'anonymisation), `action_code`, `entity_type`/`entity_id` et un +`summary` non-personnel ; pas d'UPDATE/DELETE applicatif. L'attribution des commandes +comptoir/drive passe par `customer_order.acting_user_id` (`mlt.md` 4.1 RG-5) et celle du stock +par `stock_movement.user_id` (`mlt.md` 9.1/9.2). Les actions stock ne sont pas doublement +journalisees : `stock_movement` (append-only) fournit deja la piste. + +**Information disclosure (divulgation).** La matrice de classification (19.4) borne ce qui +sort des logs et des reponses API. Les erreurs d'auth sont generiques (RG-2, pas de +distinction email inconnu / mot de passe faux). L'`audit_log` stocke des **noms de champs** +modifies, pas les valeurs PII (`audit_log.details`, RG-T14). L'attribution de stock +(`stock_movement.user_id`) n'est visible que pour manager/admin ; le staff de ligne voit les +deltas sans l'identite de l'acteur (`mlt.md` 9.3 RG-4). Les credentials (`password_hash`, +`pin_hash`, `password_reset_token_hash`) sont tenus hors logs et hors reponses API. + +**Denial of service.** Le throttling de login est degressif (backoff exponentiel plafonne) +plutot qu'un lock indefini, dans les deux dimensions compte (`user.lockout_until`) et IP +(`login_throttle.lockout_until`, `mlt.md` 12.1 RG-8) : une saisie maladroite ne bloque pas une +cuisine en plein service de 15h continu. L'idempotence (RG-T19) absorbe les doubles-soumissions +de retry reseau sur le kiosk anonyme. Le decrement de stock atomique (RG-T20) evite tout +contentieux de verrou (pas de `SELECT ... FOR UPDATE`, pas d'ordre de deadlock). + +**Elevation of privilege.** Le RBAC est permission-driven : le code teste une permission, pas +un nom de role (catalogue de 23 permissions fige au seed, `dictionary.md` 3.17). Les +changements de role passent par `MANAGE_RBAC` (`mlt.md` 10.4) PIN-gated (RG-T13) et audites +avec le diff de permissions (RG-T14, RG-6). `role_id` est derriere l'allowlist de +mass-assignment (RG-T16) sur `UPDATE_USER` (10.2). Les permissions sont rechargees depuis la +BDD a chaque verification (`mlt.md` 10.4 RG-3), donc un changement de droits prend effet sans +re-login force. + +### 19.4 Matrice de classification des donnees (4 niveaux) + +Les 21 entites du modele (`dictionary.md` 3.1-3.21) sont reparties en quatre niveaux. La +classification suit l'entite ; quelques colonnes sont surclassees explicitement (credentials, +PII). + +| Niveau | Definition | Entites / colonnes | Regle de manipulation | +|---|---|---|---| +| **RESTRICTED** (secrets / credentials) | Secrets d'authentification ; tenus hors de toute exposition | Colonnes de `user` (14) : `password_hash`, `pin_hash`, `password_reset_token_hash` | Hors logs et hors reponses API ; argon2id ; invalides a l'anonymisation (`mlt.md` 10.5 RG-1) ; exclus de `audit_log.details` qui ne retient que des noms de champs (RG-T14) | +| **CONFIDENTIAL** (PII, RGPD) | Donnees a caractere personnel d'un staff identifiable | Colonnes de `user` (14) : `email`, `first_name`, `last_name` | Sujet a l'anonymisation a l'effacement (`ERASE_USER_PII`, op 27) ; `audit_log` stocke les noms de champs, pas les valeurs ; echappement au rendu (RG-T15) | +| **INTERNAL** (sensible metier) | Donnees d'exploitation, non publiques, a acces restreint par RBAC | `customer_order` (10), `order_item` (11), `order_item_selection` (12), `order_item_modifier` (13), `stock_movement` (19), `audit_log` (20), `login_throttle` (21, contient l'IP source), `role` (15), `permission` (17), `role_permission` (18), `role_visible_source` (16) ; sorties de stats (`READ_STATS`, op 24) | Acces filtre par permission (RG-T03) ; attribution stock visible manager/admin seulement (`mlt.md` 9.3 RG-4) ; integrite par snapshots (RG-T05) et transactions (RG-T08/RG-T11) | +| **PUBLIC** (catalogue, face kiosk) | Donnees servies a la borne anonyme | `category` (1), `product` (2), `menu` (3), `menu_slot` (4), `menu_slot_option` (5), `ingredient` (6, nom + dispo calculee), `product_ingredient` (7), `allergen` (8), `ingredient_allergen` (9) | Lecture publique via `LOAD_CATALOGUE` (op 1) ; ecriture reservee admin/manager (RG-T03) ; texte echappe au rendu (RG-T15) ; disponibilite calculee (RG-T21) | + +**Couverture** : 21/21 entites classifiees (9 PUBLIC, 11 INTERNAL incluant les deux entites +security-by-design `audit_log` et `login_throttle`, plus `user` dont les colonnes sont +reparties entre RESTRICTED, CONFIDENTIAL et — pour `is_active`, `role_id`, `last_login_at`, +les compteurs de throttle — INTERNAL). L'entite `user` (14) est la seule a porter trois +niveaux simultanement, d'ou son traitement par colonne. + +--- + +*Document vivant — version 1.3 — 2026-06-15 (drift GitHub -> Forgejo Actions corrige, CI securite PHPStan/secret-scan, planning rechiffre pour la couche security-by-design). A mettre a jour a chaque decision structurante.* diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..a560d56 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,104 @@ +# Strategie de test — Wakdo + +> Comment le projet est teste, comment lancer chaque niveau, ce qui tourne en CI, +> comment mesurer la couverture, et pourquoi les tests E2E ne tournent pas en CI. +> Priorite : Unit > Integration > E2E. Cote PHP, aucune dependance Composer +> (PHPUnit en `.phar` autonome). + +--- + +## 1. Niveaux de test + +| Niveau | Outil | Perimetre | Ou | +|---|---|---|---| +| Unitaire PHP | PHPUnit (`.phar`) | logique (Auth, RBAC, PIN, throttle, calcul commande, controleurs via doubles) | CI + local | +| Integration PHP | PHPUnit + vraie MariaDB | requetes SQL preparees, contraintes, RBAC `is_active`, audit, FK | CI + local | +| Analyse statique | PHPStan niveau 6 | typage, erreurs potentielles sur `src/` + `tests/` | CI + local | +| Unitaire JS | `node:test` + jsdom | modules du front borne (panier, composeur, checkout, allergenes, a11y, validation) | CI + local | +| E2E | Playwright | parcours borne + admin de bout en bout | **local / manuel** (voir section 5) | + +--- + +## 2. Lancer les tests PHP (sans Composer) + +Le binaire `php` n'est pas requis sur l'hote : on passe par l'image applicative. + +```bash +# Unitaire + integration (la vraie base est utilisee si WAKDO_DB_TESTS=1) : +docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml + +# Integration DB explicite (vraie MariaDB du reseau interne) : +docker run --rm --network wakdo_wakdo_internal --env-file .env -e WAKDO_DB_TESTS=1 \ + -v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml + +# Analyse statique : +docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app \ + php -d memory_limit=-1 phpstan.phar analyse --no-progress +``` + +Les `*.phar` (phpunit 11.5.2, phpstan 1.12.27) sont gitignores ; les retelecharger si absents. +Les tests d'integration s'auto-skippent hors `WAKDO_DB_TESTS=1` ; la CI force `--fail-on-skipped` +pour qu'aucun test de securite (throttle, RBAC, audit, FK) ne soit silencieusement saute. + +--- + +## 3. Lancer les tests JS + +```bash +npm run test:js # node --test tests/js/ (jsdom en devDependency) +``` + +--- + +## 4. Couverture de code + +Le pilote de couverture (pcov ou Xdebug) n'est **pas** embarque dans l'image de +production (surcout au runtime, sans interet en prod). La couverture se mesure en +dev/CI, avec un PHP equipe d'un pilote de couverture : + +```bash +# avec pcov ou xdebug actif dans le PHP utilise : +php -d pcov.enabled=1 phpunit.phar -c phpunit.xml --coverage-text --coverage-html var/coverage +``` + +`phpunit.xml` declare deja la source a mesurer (`src`). +A ce stade, la couverture est produite **a la demande** : aucun seuil n'est impose +en CI (pas de gate de pourcentage). L'ajout d'un pilote de couverture a l'etape CI +`static-tests` et d'un seuil minimal est une evolution identifiee (decision exploitant : +elle suppose un PHP de CI equipe de pcov). + +--- + +## 5. E2E (Playwright) — execution manuelle, hors CI + +Les parcours E2E (borne : accueil -> commande -> chevalet -> confirmation ; admin : +login -> dashboard -> logout) se lancent **a la main**, contre une stack jetable : + +```bash +tests/e2e/run.sh +``` + +Le script monte une stack isolee (`docker-compose.yml` + `tests/e2e/docker-compose.e2e.yml`), +attend migrate + healthcheck, puis lance Playwright dans le conteneur officiel. + +**Pourquoi pas en CI ?** Decision assumee : le runner Forgejo de production execute les +jobs sans acces au socket Docker (pas de docker-in-docker), et les jobs sont repartis sur +plusieurs runners. Monter une stack Docker complete + Playwright dans ce contexte n'est pas +fiable. Les E2E restent donc un filet **manuel** (lance avant une livraison sensible), tandis +que la CI couvre l'unitaire, l'integration DB, l'analyse statique, le lint et le scan de secrets. +A l'oral, c'est la position a defendre : E2E reels et reproductibles, mais declenches a la main. + +--- + +## 6. Ce que la CI execute (Forgejo Actions, sur PR) + +`.forgejo/workflows/ci.yml`, sur `pull_request` vers `dev`/`main` : + +| Job | Verifie | +|---|---| +| `secret-scan` | gitleaks (aucun secret dans le diff/historique) | +| `php-lint` | `php -l` sur tous les fichiers `.php` | +| `static-tests` | PHPStan niveau 6 + PHPUnit (unit + integration sur service MariaDB, `--fail-on-skipped`) | +| `js-tests` | `node --test tests/js/` (jsdom) | + +L'auto-merge ne se declenche que lorsque ces checks requis sont verts (branch protection). diff --git a/docs/adr/0001-php-from-scratch-sans-composer.md b/docs/adr/0001-php-from-scratch-sans-composer.md new file mode 100644 index 0000000..d4edb2d --- /dev/null +++ b/docs/adr/0001-php-from-scratch-sans-composer.md @@ -0,0 +1,23 @@ +# ADR-0001 — PHP from scratch, sans framework ni Composer + +- Statut : Accepte +- Date : 2026-04-23 + +## Contexte +Certification RNCP (Titre Developpeur Web, option DevOps). L'objectif pedagogique est +de demontrer la maitrise des fondamentaux (routage, PDO, sessions, securite) plutot que +la configuration d'un framework. Options : Symfony/Laravel ; micro-framework (Slim) ; +from scratch. + +## Decision +Application PHP 8.3 ecrite **from scratch** : routeur, autoloader PSR-4 manuel +(`spl_autoload_register`), couche `Database` sur PDO, le tout **sans Composer**. Les +outils de dev (PHPUnit, PHPStan) sont utilises via leurs **`.phar` autonomes**. + +## Consequences +- (+) Chaque mecanisme (routage, auth, RBAC, requetes preparees) est explicite et + defendable a l'oral ; pas de magie de framework. +- (+) Surface de dependances minimale (moins de supply-chain a auditer). +- (-) Du code d'infrastructure a ecrire et tester soi-meme (Core, Auth). +- CI sans Composer : les `.phar` (phpunit, phpstan) sont epingles/telecharges. + Voir `docs/PROJECT_CONTEXT.md` section 6. diff --git a/docs/adr/0002-back-office-mvc-rendu-serveur.md b/docs/adr/0002-back-office-mvc-rendu-serveur.md new file mode 100644 index 0000000..83f1bc9 --- /dev/null +++ b/docs/adr/0002-back-office-mvc-rendu-serveur.md @@ -0,0 +1,22 @@ +# ADR-0002 — Back-office en MVC rendu serveur (pas de SPA) + +- Statut : Accepte +- Date : 2026-06-15 + +## Contexte +Le back-office (login, CRUD catalogue, stock, users, RBAC, stats) doit etre construit. +Options : SPA JS consommant une API JSON ; pages rendues serveur (MVC PHP) ; hybride. +La borne client, elle, est deja un front statique distinct (Bloc 1). + +## Decision +Le back-office est en **MVC rendu serveur** : formulaires POST + redirections, vues PHP +injectees dans un layout commun. L'API REST (`/api/*`) reste interne, consommee par la +borne. Login = vue PHP, pas un endpoint JSON. + +## Consequences +- (+) CSRF, sessions, garde de permission et echappement de sortie se branchent + naturellement sur chaque page ; demontre le MVC sans build front. +- (+) Pas de duplication d'etat client/serveur pour l'admin. +- (-) Interactions riches (matrice RBAC, editeur recette) gerees en JS vanilla cible, + CSP-safe (champs caches / cases scalaires), sans framework front. +- Controleurs non-`final` (seam de test) ; vues sous `src/app/Views/admin`. diff --git a/docs/adr/0003-stock-pourcentage-dispo-calculee.md b/docs/adr/0003-stock-pourcentage-dispo-calculee.md new file mode 100644 index 0000000..4605c22 --- /dev/null +++ b/docs/adr/0003-stock-pourcentage-dispo-calculee.md @@ -0,0 +1,25 @@ +# ADR-0003 — Stock en pourcentage + disponibilite produit calculee (RG-T21) + +- Statut : Accepte +- Date : 2026-06-12 + +## Contexte +Modeliser le stock des ingredients et la commandabilite des produits. Un stock en +quantites absolues seules rend les seuils d'alerte arbitraires d'un ingredient a +l'autre ; et marquer la disponibilite produit "en dur" exige une cascade a maintenir +a chaque mouvement de stock. + +## Decision +Stock ancre sur une **`stock_capacity`** (reference 100%, `CHECK > 0`) ; `stock_pct` et +les 3 bandes (normal / alerte / critique) sont **calcules**, pas stockes. La +**disponibilite produit (RG-T21)** est derivee : commandable si `is_available = 1` ET +chaque ingredient non retirable est au-dessus de la bande critique. Aucune colonne +stockee, aucune cascade. + +## Consequences +- (+) Seuils homogenes (en %) ; un reappro au-dessus du critique rend le produit + commandable de lui-meme, sans ecriture. +- (+) `stock_quantity` signe (survente assumee, remontee manager) : le systeme ne bloque + pas une commande sur une lecture de stock. +- (-) Le calcul de dispo se fait a la lecture (jointure composition) ; borne par requete. +- Source unique de la derivation : `IngredientRepository::stockBand`. Voir `docs/merise/`. diff --git a/docs/adr/0004-pin-action-sensible-audit.md b/docs/adr/0004-pin-action-sensible-audit.md new file mode 100644 index 0000000..80b3060 --- /dev/null +++ b/docs/adr/0004-pin-action-sensible-audit.md @@ -0,0 +1,25 @@ +# ADR-0004 — PIN d'action sensible (equipier) + audit dans la meme transaction + +- Statut : Accepte +- Date : 2026-06-15 + +## Contexte +Les postes back-office sont partages (session ouverte au comptoir). Pour les operations +sensibles (annulation, changement prix/TVA, suppressions, inventaire, gestion +utilisateur, RBAC, effacement PII), il faut imputer l'acte a une personne, pas a la +session partagee. + +## Decision +Modele **identifiant equipier + PIN** : l'operation sensible exige email + PIN, verifies +contre `user.pin_hash` (argon2id). Le `user_id` ainsi resolu est l'**acteur** ecrit dans +`audit_log` (RG-T14), dans la **meme transaction** que l'effet (RG-T08). Le set sensible +est defini par RG-T13. Les operations de stock tracent via `stock_movement.user_id` +(pas de double-journal). + +## Consequences +- (+) Imputabilite reelle sur poste partage ; trace immuable et atomique (pas d'effet + sans audit, ni l'inverse). +- (+) Le PIN n'identifie pas la session : un manager peut autoriser sur le poste d'un + autre sans relog. +- (-) Surface d'attaque PIN (4 chiffres) -> necessite un throttle dedie (voir ADR-0005). +- Brique : `App\Auth\PinVerifier`. Regle : `docs/merise/mlt.md` RG-T13/RG-T14. diff --git a/docs/adr/0005-throttle-pin-separe-du-login.md b/docs/adr/0005-throttle-pin-separe-du-login.md new file mode 100644 index 0000000..7de7f22 --- /dev/null +++ b/docs/adr/0005-throttle-pin-separe-du-login.md @@ -0,0 +1,23 @@ +# ADR-0005 — Throttle du PIN separe des compteurs de connexion (RG-T22) + +- Statut : Accepte +- Date : 2026-06-15 + +## Contexte +Le PIN d'action sensible (ADR-0004) est court (4 chiffres) : il faut limiter le +brute-force. Question : reutiliser les compteurs de login (`user.lockout_until` / +`login_throttle`) ou un compteur dedie ? Et sur quelle dimension compter ? + +## Decision +Table **`pin_throttle`** dediee, **separee** des compteurs de connexion. La dimension +est l'**utilisateur agissant** (la session authentifiee qui soumet le PIN), pas l'email +cible (contournable par rotation) ni l'IP (collateral sur poste partage). Backoff +degressif, bornes propres plus permissives que le login. Verrou evalue AVANT la +verification ; sous verrou actif, pas de nouvelle ligne `pin.failed` (anti-amplification). + +## Consequences +- (+) Spammer le PIN d'une victime ne verrouille pas sa CONNEXION (pas d'escalade DoS + sur une surface plus sensible). +- (+) Detection : un pic de `pin.failed` reste alertable. +- (-) Un compteur de plus a purger (cron, comme `login_throttle`). +- Brique : `App\Auth\PinThrottle`. Regle : RG-T22. Cf. ADR-0004. diff --git a/docs/adr/0006-http-409-conflit-422-validation.md b/docs/adr/0006-http-409-conflit-422-validation.md new file mode 100644 index 0000000..aac79e5 --- /dev/null +++ b/docs/adr/0006-http-409-conflit-422-validation.md @@ -0,0 +1,25 @@ +# ADR-0006 — HTTP 409 (conflit) vs 422 (validation) + +- Statut : Accepte +- Date : 2026-06-17 + +## Contexte +Les controleurs renvoyaient 422 a la fois pour une validation qui echoue ET pour un +conflit d'etat (unicite, suppression bloquee par FK RESTRICT). Le contrat documente +(`byan-api.md`) attendait 409 pour les conflits. Derive a corriger. + +## Decision +Convention harmonisee sur tous les controleurs : +- **422** : requete bien formee mais **semantiquement invalide** (validation serveur, + RG-T18) ; +- **409** : **conflit d'etat** (violation d'unicite SQLSTATE 23000, hard-delete bloque + par FK RESTRICT) ; +- **403** : CSRF invalide ou permission manquante ; **404** : ressource introuvable. + +## Consequences +- (+) Statuts semantiquement justes (RFC 9110), testables, coherents entre Category / + Product / Menu / Ingredient / User / Role. +- (+) Aligne le code sur le contrat d'API documente. +- (-) Pages rendues serveur : un 200-avec-erreurs "marcherait" visuellement, mais le + statut correct est verrouille par les tests (un oubli = test rouge). +- Remediation : PR #33 (Category/Product/Menu) ; les controleurs suivants naissent en 409. diff --git a/docs/adr/0007-rgpd-anonymisation-tombstone.md b/docs/adr/0007-rgpd-anonymisation-tombstone.md new file mode 100644 index 0000000..7716606 --- /dev/null +++ b/docs/adr/0007-rgpd-anonymisation-tombstone.md @@ -0,0 +1,24 @@ +# ADR-0007 — Effacement RGPD par anonymisation (tombstone), pas DELETE + +- Statut : Accepte +- Date : 2026-06-17 + +## Contexte +Le droit a l'effacement (RGPD, Cr 3.d) s'applique aux comptes back-office. Un `DELETE` +dur casserait l'integrite referentielle (FK entrantes depuis `stock_movement.user_id`, +`customer_order.acting_user_id`, `audit_log.actor_user_id`) et effacerait la trace +d'imputabilite des actes passes. + +## Decision +**Anonymisation** (mlt 10.5), pas suppression : en une transaction, vider la PII de la +ligne `user` (email -> `anon-@wakdo.invalid` RFC 2606, prenom/nom vides, hash vide, +PIN/reset NULL), poser `anonymized_at`, `is_active = 0`. La ligne **persiste** comme +tombstone. Idempotent (clause `anonymized_at IS NULL`). Trace : `audit_log` +`user.erase_pii`. + +## Consequences +- (+) FK preservees ; les actes passes restent imputables a un principal anonymise + (qui-en-tant-qu-id), sans PII. +- (+) Email unique conserve, non identifiant. +- (-) La ligne reste en base (tombstone) : a documenter dans le registre de traitement. +- Garde-fou : interdit d'anonymiser le dernier admin actif / soi-meme (anti-lockout). diff --git a/docs/adr/0008-makefile-vers-compose-migrate.md b/docs/adr/0008-makefile-vers-compose-migrate.md new file mode 100644 index 0000000..15eabe7 --- /dev/null +++ b/docs/adr/0008-makefile-vers-compose-migrate.md @@ -0,0 +1,26 @@ +# ADR-0008 — Du Makefile a `docker compose up` (service wakdo-migrate) + +- Statut : Accepte +- Date : 2026-06-17 + +## Contexte +Le critere Cr 7.c.4 demande de lancer la stack complete en une commande. C'etait +`make init`. Mais le Makefile portait surtout des cibles mortes/trompeuses +(`test`/`lint` annoncaient "pas implemente" alors que les tests tournent) ; sa seule +cible porteuse, `init`, existait parce que `docker compose up` seul n'applique pas les +migrations. Le critere parle d'un **resultat**, pas de `make`. + +## Decision +Migration + seed deplaces **dans la stack** : un service one-shot **`wakdo-migrate`** +(image mariadb, `db/migrate-container.sh` par le reseau) applique +`db/migrations/*.sql` (suivi `schema_migrations`) puis `db/seeds/*.sql` (suivi +`seeds_applied`), idempotents. `wakdo-app`/`wakdo-web` `depends_on: +service_completed_successfully`. **Makefile supprime.** `docker compose up` devient +l'unique commande. + +## Consequences +- (+) Commande universelle, sans dependance a l'outil `make` sur l'hote. +- (+) Comportement = doc (l'ancien `make init` ne seedait meme pas). +- (-) Migrations/seed evalues a chaque `up` (cout negligeable, suivi -> re-run sans effet). +- (-) Base **deja seedee** avant le suivi : back-filler `seeds_applied` avant le 1er up. +- `db/migrate.sh` (hote) conserve pour l'usage manuel. Detail : journal 2026-06-17. diff --git a/docs/adr/0009-compose-standalone-et-prod-gitignore.md b/docs/adr/0009-compose-standalone-et-prod-gitignore.md new file mode 100644 index 0000000..5c3afe5 --- /dev/null +++ b/docs/adr/0009-compose-standalone-et-prod-gitignore.md @@ -0,0 +1,29 @@ +# ADR-0009 — docker-compose.yml standalone + docker-compose.prod.yml gitignore + +- Statut : Accepte +- Date : 2026-06-17 + +## Contexte +Le `docker-compose.yml` versionne supposait un reverse proxy Traefik (reseau externe, +labels, aucun port hote) : `docker compose up` echouait pour quiconque sans Traefik +(jury, contributeur, tests E2E). Options envisagees : overlay `-f` (base + prod fusionnes +via `!reset`) ; un seul fichier parametre ; deux fichiers complets independants. + +## Decision +Deux fichiers **complets et independants** (pas d'overlay) : +- **`docker-compose.yml`** (versionne) : standalone, `wakdo-web` publie + `${HTTP_PORT:-8080}:80`, reseau interne seul, sans Traefik. `docker compose up` tourne + partout, facon app open-source self-hostable. +- **`docker-compose.prod.yml`** : **gitignore**, propre a chaque hote derriere un proxy + (meme stack + reseau externe + labels Traefik, sans port). `docker compose -f + docker-compose.prod.yml up -d`. + +Renommage `TRAEFIK_DOMAIN_*` -> `APP_HOST_*` (ce sont des `ServerName` de vhosts, pas du +Traefik). `.env.example` local-first. + +## Consequences +- (+) `docker compose up` marche en local sans configuration ; le repo ne porte aucune + hypothese d'infra. +- (+) Le critere Cr 7.c.4 tient avec un fichier que tout le monde peut lancer. +- (-) Duplication entre les deux fichiers (assumee : clarte > DRY pour l'infra). +- (-) Le serveur maintient son propre fichier prod (comme `.env`). diff --git a/docs/adr/0010-cookie-secure-conditionnel-https.md b/docs/adr/0010-cookie-secure-conditionnel-https.md new file mode 100644 index 0000000..8502797 --- /dev/null +++ b/docs/adr/0010-cookie-secure-conditionnel-https.md @@ -0,0 +1,28 @@ +# ADR-0010 — Cookie de session Secure conditionnel au HTTPS + +- Statut : Accepte +- Date : 2026-06-17 + +## Contexte +Le cookie de session du back-office etait pose avec `secure => true` en dur +(security-by-design). Or un cookie `Secure` n'est emis/renvoye par le navigateur que +sur HTTPS : en HTTP (dev, stack standalone locale, E2E sans TLS) la session ne tenait +pas d'une requete a l'autre, donc le login admin echouait ("Session expiree" au POST, +le jeton CSRF ne pouvant matcher une session perdue). Revele par le parcours E2E admin. +En prod le souci n'apparait pas : Traefik termine le TLS. + +## Decision +`secure` devient **conditionnel au schema** : vrai si la requete est HTTPS, faux sinon. +Detection (`SessionManager::cookieSecure()`) : `X-Forwarded-Proto: https` (pose par +Traefik en prod) en priorite, sinon la variable serveur `HTTPS`, sinon le port 443. +Applique aux deux points (pose du cookie + expiration au logout). + +## Consequences +- (+) Le back-office est utilisable en **HTTP local** (dev, standalone, E2E) ; prod + **inchange** (derriere Traefik -> `X-Forwarded-Proto=https` -> `Secure` reste pose). +- (+) Comportement standard (les frameworks derivent `Secure` du schema). +- Confiance en `X-Forwarded-Proto` : sure ici car l'app n'est joignable que par le + reverse proxy sur le reseau interne (aucun acces client direct). +- (-) Un deploiement en **HTTP nu** (sans proxy TLS) n'aurait pas `Secure` — mais servir + l'authentification en HTTP nu est de toute facon a proscrire (independant de ce flag). +- `httponly` et `SameSite=Strict` restent inconditionnels. Revele par [E2E admin](../domaines/auth.md). diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..384b7e8 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,38 @@ +# Registre des decisions d'architecture (ADR) + +Une fiche courte par decision structurante : **contexte**, **decision**, **consequences**. +Format inspire des Architecture Decision Records (M. Nygard). Les ADR sont immuables : +une decision revisee donne une nouvelle fiche qui *supersede* l'ancienne (statut mis a jour). + +**Auteur : BYAN** (formalisation ; arbitrage et validation par l'auteur). + +| # | Decision | Statut | +|---|---|---| +| [0001](0001-php-from-scratch-sans-composer.md) | PHP from scratch, sans framework ni Composer | Accepte | +| [0002](0002-back-office-mvc-rendu-serveur.md) | Back-office en MVC rendu serveur (pas de SPA) | Accepte | +| [0003](0003-stock-pourcentage-dispo-calculee.md) | Stock en pourcentage + disponibilite produit calculee (RG-T21) | Accepte | +| [0004](0004-pin-action-sensible-audit.md) | PIN d'action sensible (equipier) + audit dans la meme transaction | Accepte | +| [0005](0005-throttle-pin-separe-du-login.md) | Throttle du PIN separe des compteurs de connexion (RG-T22) | Accepte | +| [0006](0006-http-409-conflit-422-validation.md) | HTTP 409 (conflit) vs 422 (validation) | Accepte | +| [0007](0007-rgpd-anonymisation-tombstone.md) | Effacement RGPD par anonymisation (tombstone), pas DELETE | Accepte | +| [0008](0008-makefile-vers-compose-migrate.md) | Du Makefile a `docker compose up` (service wakdo-migrate) | Accepte | +| [0009](0009-compose-standalone-et-prod-gitignore.md) | docker-compose.yml standalone + docker-compose.prod.yml gitignore | Accepte | +| [0010](0010-cookie-secure-conditionnel-https.md) | Cookie de session Secure conditionnel au HTTPS | Accepte | + +## Modele de fiche + +``` +# ADR-NNNN — Titre + +- Statut : Propose | Accepte | Supersede par ADR-XXXX +- Date : AAAA-MM-JJ + +## Contexte +Le probleme, les contraintes, les options envisagees. + +## Decision +Le choix retenu, en une ou deux phrases nettes. + +## Consequences +Ce que ca implique (positif et negatif), et les regles/fichiers concernes. +``` diff --git a/docs/api/conventions.md b/docs/api/conventions.md new file mode 100644 index 0000000..3f0ad61 --- /dev/null +++ b/docs/api/conventions.md @@ -0,0 +1,343 @@ +# API Wakdo - conventions de nommage, structure et listing + +**Statut** : v0.2 - convention de casse arbitree (snake_case, voir section 4) +**Perimetre** : back-office admin (rendu serveur) + API REST sous `/api/*` +**Auteur methodologie** : BYAN +**A lire avec** : `docs/PROJECT_CONTEXT.md`, `docs/merise/dictionary.md` (source de verite des +noms de champs), `docs/merise/mct.md` + `mlt.md` (operations metier), `db/seeds/0001_rbac_and_reference.sql` +(catalogue des 23 permissions). NB : `docs/api/byan-api.md` documente l'API de la plateforme BYAN, +distincte de l'API Wakdo decrite ici. + +--- + +## 1. Objet + +Fixer les conventions de nommage, la structure des points d'entree HTTP de Wakdo, et tenir le +listing des endpoints (en service et prevus). Objectif : que chaque endpoint ajoute suive le meme +moule. Les choix sont des conventions de projet (coherence, lisibilite), pas des regles universelles ; +une convention peut evoluer, auquel cas ce document est mis a jour en premier. + +--- + +## 2. Par quoi passe une requete + +Deux hotes distincts, un seul conteneur web (Apache), routes par le Traefik de l'hote : + +``` +Client (borne / navigateur back-office) + -> Traefik (TLS, ajoute X-Forwarded-For, route par Host) + -> wakdo-web (Apache, vhost selon le Host) + - vhost kiosk : DocumentRoot src/public/borne (statique + futur appel /api) + - vhost admin : DocumentRoot src/public/admin + - fichier existant (assets/ : css, js, images) : servi tel quel + - sinon RewriteRule -> index.php (front controller) + -> wakdo-app (PHP-FPM, via proxy FastCGI sur *.php) + front controller -> Router -> Controller -> Response + -> wakdo-db (MariaDB, requetes preparees PDO uniquement) +``` + +Consequence de nommage : le DocumentRoot du vhost admin est `src/public/admin`, donc le +`REQUEST_URI` arrive **sans prefixe** `/admin`. Le Router voit `/login`, `/api/health`, etc. +On n'ajoute pas de segment `/admin` dans les chemins de routes. + +Code de reference : routes dans `src/public/admin/index.php`, controleurs dans +`src/app/Controllers/`, enveloppe de reponse dans `src/app/Core/Response.php`, resolution +(404 / 405) dans `src/app/Core/Router.php`. + +--- + +## 3. Deux familles d'endpoints + +| Famille | Prefixe | Rendu | Authentification | Exemple | +|---|---|---|---|---| +| Pages back-office | aucun | HTML (vue serveur + `layout.php`) | session admin | `/login`, `/forgot_password` | +| API REST | `/api/` | JSON (enveloppe section 7) | selon la ressource (section 10) | `/api/health`, `/api/categories` (prevu) | + +La borne (kiosk) consommera l'API REST `/api/*` (P4). En attendant, elle lit un repli JSON +statique sous `src/public/borne/data/` (voir section 8.3). + +--- + +## 4. Nommage des chemins (URL) + +Deux decisions, dont une sourcee et une de coherence : + +- **Minuscules** sur tout le chemin. Sourced : RFC 3986 §6.2.2.1 - seuls le scheme et l'hote sont + insensibles a la casse, le path est sensible a la casse ; le minuscule evite les bugs de casse. +- **Separateur de mots : `_` (snake_case)**. Aucun standard n'impose `-` ou `_` dans un segment + (les deux sont des caracteres `unreserved`, RFC 3986 §2.3). On retient `_` pour n'avoir **qu'une + seule convention de casse** sur tout le projet : colonnes DB, champs JSON (section 8) et chemins + d'URL partagent le snake_case. Cela calque les noms de tables (`order_item` -> `/api/order_items`) + et reduit la charge a memoriser (Rasoir d'Ockham, mantra #37). + +Autres regles : + +- **Noms de ressources au pluriel** pour les collections : `/api/categories`, `/api/products`, + `/api/orders`. +- **Identifiant en segment** pour une ressource unitaire : `/api/orders/{number}`, + `/api/products/{id}`. Parametre dynamique : `{nom}` (groupe nomme cote Router). +- **Sous-ressource** par imbrication : `/api/orders/{id}/items` (prevu). +- **Action non-CRUD** par sous-chemin verbe : `POST /api/orders/{id}/cancel` + (cf. `docs/uml/security-sequence.md`). +- Pas de barre oblique finale signifiante : `Request::normalizePath` aligne `/api/health/` et + `/api/health`. + +--- + +## 5. Listing des endpoints + +### 5.1 En service (P2) + +| Methode | Chemin | Auth | Rendu | Role | +|---|---|---|---|---| +| GET | `/` | (session en P3) | HTML | accueil back-office (squelette) | +| GET | `/api/health` | public | JSON (plat) | sonde de sante (DB reelle) | +| GET | `/login` | public | HTML | formulaire de connexion | +| POST | `/login` | public + CSRF | 302 / HTML | authentification (mlt 12.1) | +| POST | `/logout` | session + CSRF | 302 | deconnexion (mlt 12.2) | +| GET | `/forgot_password` | public | HTML | demande de reinitialisation | +| POST | `/forgot_password` | public + CSRF | HTML (neutre) | envoi du lien (mlt 12.3) | +| GET | `/reset_password` | public (token en query) | HTML | formulaire nouveau mot de passe | +| POST | `/reset_password` | public + CSRF | 302 / HTML | confirmation (mlt 12.3) | +| GET | `/api/me` | session | JSON | identite + permissions du compte courant (RG-6/RG-T02/RG-T03) | + +`/api/me` est le premier consommateur reel de `SessionGuard` (RG-6 idle/absolu + RG-T02 +is_active) et d'`Authorizer` (RG-T03, permissions rechargees depuis la base). Reponse : +`{ "data": { "user_id", "role_id", "role_code", "permissions": [...] } }` ; `401 AUTH_REQUIRED` +si la session est absente, expiree ou le compte desactive. Les autorisations par operation +(et le PIN des actions sensibles, RG-T13) se cablent quand les operations existent (P3). + +### 5.2 API kiosk - lecture catalogue + commande (prevu P4, public) + +La borne est publique (aucune session) ; cf. `mlt.md` CREATE_ORDER, declencheur kiosk. + +| Methode | Chemin | Permission | Op MCT | Statut | +|---|---|---|---|---| +| GET | `/api/categories` | (lecture publique) | READ_CATALOGUE | livre | +| GET | `/api/products` | (lecture publique) | READ_CATALOGUE | livre | +| GET | `/api/products/{id}` | (lecture publique) | READ_CATALOGUE | livre | +| GET | `/api/menus` | (lecture publique) | READ_CATALOGUE | livre | +| GET | `/api/menus/{id}` | (lecture publique) | READ_CATALOGUE | livre (slots de composition) | +| GET | `/api/allergens` | (lecture publique) | READ_CATALOGUE | livre (14 allergenes INCO) | +| POST | `/api/orders` | (kiosk public) | CREATE_ORDER (mlt 3.3) | livre (idempotency_key, RG-T19) | +| POST | `/api/orders/{number}/pay` | (kiosk public) | (encaissement) | livre (paid + decrement stock RG-T20) | +| GET | `/api/orders/{number}` | (lecture publique) | (suivi statut) | livre (champs non sensibles : numero, statut, total) | + +### 5.3 API / pages back-office (prevu P3-P4, session + permission) + +Provisoire : le choix entre endpoints JSON `/api/*` et pages rendues serveur pour les ecritures +admin est tranche phase par phase (P3 CRUD). Les colonnes Permission renvoient au catalogue fige +des 23 permissions (`db/seeds/0001_rbac_and_reference.sql`) ; l'imputabilite et le PIN suivent +`mlt.md` RG-T13/RG-T14. + +Commandes (cote equipier) : + +| Methode | Chemin | Permission | Op MCT | Note | +|---|---|---|---|---| +| GET | `/api/orders` | `order.read` | READ_ORDERS | filtre par `role_visible_source` (RG-T12) | +| GET | `/api/orders/{number}` | `order.read` | READ_ORDERS | vue back-office detaillee (differe) ; le suivi public minimal est livre en 5.2 | +| POST | `/api/orders` (comptoir/drive) | `order.create` | CREATE_COUNTER_ORDER (mlt 4.1) | source auto-taggee | +| POST | `/api/orders/{id}/deliver` | `order.deliver` | DELIVER_ORDER (mlt 6.1) | | +| POST | `/api/orders/{id}/cancel` | `order.cancel` | CANCEL_ORDER (mlt 7.1) | PIN + audit_log (RG-T13/14) | + +Catalogue (produits, menus, categories) : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| POST | `/api/products` | `product.create` | CREATE_PRODUCT (mlt 8.1) | +| PUT | `/api/products/{id}` | `product.update` | UPDATE_PRODUCT (mlt 8.2) - PIN sur prix/TVA | +| DELETE | `/api/products/{id}` | `product.delete` | DELETE_PRODUCT (mlt 8.3) - PIN | +| POST | `/api/menus` | `menu.create` | CREATE_MENU | +| PUT | `/api/menus/{id}` | `menu.update` | UPDATE_MENU | +| DELETE | `/api/menus/{id}` | `menu.delete` | DELETE_MENU - PIN | +| POST/PUT/DELETE | `/api/categories[/{id}]` | `category.manage` | MANAGE_CATEGORY | + +Stock et ingredients : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| GET | `/api/ingredients` | `ingredient.manage` | READ_INGREDIENTS | +| GET | `/api/stock` | `stock.read` | READ_STOCK | +| POST | `/api/stock/restock` | `stock.manage` | RESTOCK (mlt 9.1) | +| POST | `/api/stock/count` | `stock.count` | INVENTORY_COUNT (mlt 9.2) - PIN | + +Utilisateurs et RBAC : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| GET | `/api/users` | `user.read` | READ_USERS | +| POST | `/api/users` | `user.create` | CREATE_USER (mlt 10.1) - PIN | +| PUT | `/api/users/{id}` | `user.update` | UPDATE_USER (mlt 10.2) - PIN | +| POST | `/api/users/{id}/deactivate` | `user.deactivate` | DEACTIVATE_USER (mlt 10.3) - PIN | +| GET/PUT | `/api/roles[/{id}/permissions]` | `role.manage` | MANAGE_RBAC (mlt 10.4) - PIN | + +Statistiques : + +| Methode | Chemin | Permission | Op MCT | +|---|---|---|---| +| GET | `/api/stats` | `stats.read` | READ_STATS (mlt 11.x) | + +> Les chemins exacts en 5.2/5.3 sont une projection a partir des operations MCT et des permissions +> seedees ; ils sont confirmes au moment d'ecrire chaque endpoint. Seule la section 5.1 est en service. + +--- + +## 6. Methodes HTTP + +| Methode | Usage | +|---|---| +| GET | lecture, sans effet de bord | +| POST | creation, ou action de formulaire back-office (login, logout, reset) | +| PUT | mise a jour d'une ressource (prevu, CRUD admin P3) | +| DELETE | suppression d'une ressource (prevu) | + +Le Router fait une correspondance exacte de la methode : methode connue sur chemin connu mais non +enregistree -> `405` ; chemin inconnu -> `404` (`Router::dispatch`). Une requete `HEAD` sur une +route `GET` renvoie aujourd'hui `405` (correspondance exacte) ; un assouplissement reste possible +si un besoin apparait. + +--- + +## 7. Enveloppe de reponse JSON + +L'API enveloppe ses reponses pour qu'un client distingue donnees et erreur de maniere uniforme. + +Succes - ressource unitaire : + +```json +{ "data": { "id": 3, "name": "Big Mac", "price_cents": 590 } } +``` + +Succes - collection (`total` optionnel pour la pagination future) : + +```json +{ "data": [ { "id": 1 }, { "id": 2 } ], "total": 2 } +``` + +Erreur : + +```json +{ "data": null, "error": { "code": "NOT_FOUND", "message": "Resource not found" } } +``` + +Exception documentee : `GET /api/health` renvoie un objet de diagnostic plat (`status`, `app_env`, +`php_version`, `db`, `categories`), hors enveloppe, car il sert le monitoring et non un client +applicatif. + +Type de contenu : `application/json; charset=utf-8` (`Response::json`). Les pages back-office +renvoient `text/html; charset=utf-8`. + +--- + +## 8. Normalisation des noms de champs + +### 8.1 Regle generale : snake_case aligne sur le dictionnaire + +Les champs JSON reprennent les noms du dictionnaire (`docs/merise/dictionary.md`), source de verite, +ce qui evite une couche de traduction entre base, code et contrat HTTP. + +| Categorie | Convention | Exemple | +|---|---|---| +| Champ simple | snake_case, anglais | `display_order`, `image_path` | +| Montant monetaire | entier en centimes, suffixe `_cents` | `price_cents`, `total_ttc_cents` | +| Taux de TVA | entier pour mille | `vat_rate` (55 = 5,5 % ; 100 = 10 %) | +| Booleen | prefixe `is_` | `is_available`, `is_active` | +| Horodatage | suffixe `_at`, ISO 8601 en sortie API | `created_at`, `paid_at` | +| Cle etrangere | suffixe `_id` | `category_id`, `role_id` | +| Valeur d'enumeration | minuscules snake_case | `pending_payment`, `dine_in`, `kiosk` | +| Identifiant | `id` (entier) ou `order_number` (chaine metier) | `id`, `order_number` | + +Les horodatages sont stockes en `DATETIME` ; leur exposition API se fait en ISO 8601 (a cadrer +au moment d'ecrire les endpoints de lecture P4). + +### 8.2 Codes d'erreur + +SCREAMING_SNAKE_CASE, stables (un client peut s'y fier) ; le `message` reste lisible (non garanti +stable). + +| Code | HTTP | Sens | +|---|---|---| +| `NOT_FOUND` | 404 | ressource introuvable | +| `METHOD_NOT_ALLOWED` | 405 | methode non autorisee sur ce chemin | +| `VALIDATION_ERROR` | 422 | entree invalide (champ, longueur, enum) | +| `CONFLICT` | 409 | conflit d'etat (ex. transition de commande concurrente) ; suppression dure bloquee par une reference (FK RESTRICT) ; unicite slug/name deja prise (remontee par la base). La validation simple en amont (champ/format/bornes) reste `VALIDATION_ERROR` 422 | +| `AUTH_REQUIRED` | 401 | authentification requise (prevu, API admin) | +| `FORBIDDEN` | 403 | permission insuffisante, ou jeton CSRF invalide cote formulaire | +| `RATE_LIMITED` | 429 | throttling (prevu) | +| `INTERNAL_ERROR` | 500 | erreur interne, message generique (pas de divulgation) | + +Codes specifiques nommes par le MLT, en surcharge du socle : `CANNOT_CANCEL_IN_STATE` (422) et +`INVALID_TRANSITION` (409) pour l'annulation (`mlt.md` 7.1, `security-sequence.md`). Meme format +d'enveloppe. + +### 8.3 Divergence connue : repli JSON de la borne + +Le repli statique de la borne (`src/public/borne/data/categories.json`, `produits.json`) provient +des sources de l'ecole et porte un nommage different et heterogene (`title`/`nom`, `prix`, `image`, +`type`). Ce contrat est fige par le brief ecole et consomme tel quel par le JS de la borne via +`data.js`. + +La convention canonique reste celle de 8.1. Le rapprochement se fait en un point unique : la couche +`data.js` (bascule prevue en P4). Quand l'API exposera `/api/categories` et `/api/products`, elle +servira la forme canonique ; `data.js` mappera vers ce que la borne attend. + +| Repli borne | Canonique API / dictionnaire | +|---|---| +| `title` (categorie) | `name` | +| `nom` (produit) | `name` | +| `prix` | `price_cents` | +| `image` | `image_path` | +| `type` | `item_type` (`product` / `menu`) | + +--- + +## 9. Authentification et sessions + +- **Cookie de session** : `WAKDO_SID` (`SESSION_NAME`), attributs `secure`, `HttpOnly`, + `SameSite=Strict`. Bornes de validite appliquees cote application (idle 4h, absolue 10h), + pas par la duree du cookie. +- **Formulaires back-office** : jeton CSRF synchroniseur en champ cache `_csrf`, verifie sur chaque + POST (`/login`, `/logout`, `/forgot_password`, `/reset_password`). Jeton invalide -> `403`. +- **API REST** : endpoints kiosk de lecture catalogue et creation de commande publics (pas de + session ; `mlt.md` CREATE_ORDER). Endpoints d'administration sous `/api` (P3/P4) : session admin + + verification de permission via `role_permission` ; actions sensibles avec re-autorisation PIN + (`mlt.md` RG-T13). + +Le schema `ApiKey` / `Bearer` de l'API plateforme BYAN (`docs/api/byan-api.md`) ne s'applique pas +ici. + +--- + +## 10. CORS + +La borne consomme `/api/*` en **meme origine** : le vhost kiosk (`docker/apache/vhost.conf`) +relaie `/api/*` au front controller admin via PHP-FPM (`ProxyPassMatch` + `ProxyFCGISetEnvIf` +qui force `SCRIPT_FILENAME` sur `public/admin/index.php`). `data.js` garde donc des URLs +relatives et le navigateur n'emet pas de requete cross-origin pour ce parcours. + +Le middleware `App\Core\Cors` reste en place comme defense en profondeur : il lit +`CORS_ALLOWED_ORIGIN` (valeur exacte, sans joker, = `APP_URL_KIOSK`) et autorise un eventuel +consommateur cross-origin de l'API. Il n'est pas sur le chemin de la borne. + +--- + +## 11. Versionnement + +Demarrage sans segment de version (`/api/...`), ce qui correspond a une v1 implicite. En cas de +changement de contrat non retrocompatible, l'option retenue est un prefixe explicite `/api/v2/...` +introduit a ce moment-la, en gardant `/api/...` pour la v1 tant que des clients en dependent. + +--- + +## 12. Ou est defini quoi (recap code) + +| Element | Fichier | +|---|---| +| Declaration des routes | `src/public/admin/index.php` | +| Resolution / 404 / 405 | `src/app/Core/Router.php` | +| Enveloppe `data` / `error` / contenu JSON | `src/app/Core/Response.php` | +| Lecture de la requete (chemin, query, corps, IP) | `src/app/Core/Request.php` | +| Controleurs | `src/app/Controllers/` | +| Acces base (requetes preparees, transaction) | `src/app/Core/Database.php` | +| Noms de champs (source de verite) | `docs/merise/dictionary.md` | +| Operations metier et permissions | `docs/merise/mct.md`, `mlt.md`, `db/seeds/0001_rbac_and_reference.sql` | diff --git a/docs/architecture/forgejo-actions-runner.md b/docs/architecture/forgejo-actions-runner.md new file mode 100644 index 0000000..96a5e6c --- /dev/null +++ b/docs/architecture/forgejo-actions-runner.md @@ -0,0 +1,102 @@ +# Forgejo Actions - runner (act_runner) + +Prerequis d'infrastructure pour la CI/CD Wakdo. Les workflows vivent dans +`.forgejo/workflows/` (lot D) ; ils ne s'executent que si un `act_runner` est +enregistre et en ligne sur le serveur. + +## Pourquoi un runner separe de la stack app + +La stack `docker-compose.yml` de Wakdo = runtime applicatif (web, app, db, cron). +Le runner CI est du **tooling** : il se rattache au depot Forgejo, pas a l'app. +On le fait tourner comme service dedie sur l'hote stark (meme lecon que +"gh dans Docker = mauvaise idee", cf. journal session 6). Cela evite que la CI +puisse impacter le runtime, et garde un cycle de vie independant. + +## 1. Obtenir le token de registration (action manuelle, niveau admin) + +Le token vient de l'instance Forgejo, pas du repo. Dans l'UI Forgejo : + +- niveau **repo** : `Settings > Actions > Runners > Create new runner` +- ou niveau **org/instance** : `Site Administration > Actions > Runners` + +Recuperer le `REGISTRATION_TOKEN` affiche. Il est a usage unique pour +l'enregistrement (pas a versionner). + +## 2. Enregistrer le runner (sur stark) + +Setup reel en place (image `simplyforma/forgejo-runner` deja presente sur +l'hote, data dir sous `$HOME` car `/srv` non inscriptible par `corentin`). +Le conteneur tourne sous l'uid de l'hote (`--user`) pour pouvoir ecrire +`.runner` dans le volume monte. + +```bash +DATA=/home/corentin/forgejo-runner-wakdo +mkdir -p "$DATA" + +docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$DATA":/data --workdir /data \ + --entrypoint forgejo-runner \ + simplyforma/forgejo-runner:12.10.2 \ + register --no-interactive \ + --instance https://git.acadenice.com \ + --token "" \ + --name stark-wakdo \ + --labels 'docker:docker://node:20-bookworm,php-ci:docker://php:8.3-cli' +``` + +L'enregistrement ecrit `$DATA/.runner` (contient le secret du runner - ne pas +versionner, ne pas sortir de l'hote). Runner enregistre le 2026-06-15 +(uuid `e4a3dbef-...`, labels `docker` + `php-ci`). + +## 3. Lancer le runner en service + +```bash +DATA=/home/corentin/forgejo-runner-wakdo +DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) + +docker run -d --restart=always \ + --name forgejo-runner-wakdo \ + --user "$(id -u):$(id -g)" \ + --group-add "$DOCKER_GID" \ + -e HOME=/data \ + -v "$DATA":/data --workdir /data \ + -v /var/run/docker.sock:/var/run/docker.sock \ + --entrypoint forgejo-runner \ + simplyforma/forgejo-runner:12.10.2 \ + daemon +``` + +Notes : +- `--group-add $DOCKER_GID` : acces au socket Docker pour executer les jobs + dans des conteneurs (sans tourner en root). +- `-e HOME=/data` : evite l'erreur `mkdir /.cache: permission denied` (le cache + server interne ecrit sous `$HOME`). +- Verifier `docker logs forgejo-runner-wakdo` : `declared successfully` + + `[poller] launched`, et `Settings > Actions > Runners` doit montrer `stark-wakdo` **Idle**. +- Prerequis cote depot : **Actions activees** (`Settings > Actions` du depot). + +## 4. Labels et usage en workflow + +Les jobs ciblent un label via `runs-on`. Pour la CI PHP de Wakdo : + +```yaml +jobs: + ci: + runs-on: docker # image par defaut node:20-bookworm + # les etapes installent/php via le conteneur ou une action setup-php +``` + +## Securite du runner + +- Le `.runner` (secret) reste sur l'hote, hors du repo. +- Le socket Docker monte donne un acces privilegie : le runner ne doit executer + que des workflows du depot Wakdo (runner dedie au repo, pas partage). +- Roter le secret = re-enregistrer avec un nouveau token et supprimer l'ancien + runner dans l'UI. + +## Lien avec les autres lots + +- **Lot C** : ce document + prerequis infra. +- **Lot D** : `.forgejo/workflows/ci.yml` (PHPUnit + PHPStan + secret-scan gitleaks) + et auto-merge des PR sur CI verte (strategie solo dev validee). diff --git a/docs/architecture/functional-schema.md b/docs/architecture/functional-schema.md new file mode 100644 index 0000000..05e0717 --- /dev/null +++ b/docs/architecture/functional-schema.md @@ -0,0 +1,124 @@ +# Schema fonctionnel — Wakdo + +> Conceptualisation de l'application (Cr 4.a.1 a 4.a.4) : enchainement des vues en +> fonction des actions et interactions utilisateur, pour les deux interfaces +> (borne kiosk Bloc 1, back-office Bloc 2). Complete les diagrammes UML +> (`docs/uml/use-cases.md`, `sequence-passer-commande.md`, `state-commande.md`) et +> le modele Merise (`docs/merise/`). + +--- + +## 1. Vue d'ensemble + +Deux interfaces, deux parcours, un meme catalogue en base : + +- **Borne (kiosk)** — publique, anonyme, tactile. Le client compose une commande et + la valide ; la borne consomme l'API de lecture (catalogue) et l'API de commande + (creation + encaissement) en `fetch` Ajax. +- **Back-office** — interne, authentifie (sessions + RBAC par permission), pages + rendues serveur (MVC). Chaque action sensible repasse par un PIN equipier. + +Les transitions ci-dessous decrivent quelle vue mene a quelle vue, sous quelle +action, et quel appel API ou garde de securite intervient. + +--- + +## 2. Parcours borne (Bloc 1) + +```mermaid +flowchart TD + A["Accueil (index.html)\nchoix sur place / a emporter"] -->|clic mode| B["Categories (categories.html)"] + B -->|clic categorie| C["Produits (products.html?category)\npanneau commande persistant"] + C -->|produit simple| D["Modale options (product-options.js)\ntaille / quantite"] + C -->|menu| E["Composeur menu (page-product-menu.js)\nslots GET /api/menus/{id}"] + D -->|ajouter| C + E -->|ajouter| C + C -->|voir panier| F["Panier (cart.html)\nmodifier quantite / retirer"] + F -->|valider| G["Paiement (payment.html)\nsaisie numero chevalet si sur place"] + G -->|enregistrer| H["Confirmation (confirmation.html)\nnumero + montant"] + H -->|nouvelle commande| A + + C -. "GET /api/categories,/products,/menus (data.js)" .-> API[(API kiosk)] + G -. "POST /api/orders puis /pay (checkout.js)" .-> API + H -. "suivi optionnel GET /api/orders/{number}" .-> API +``` + +**Transitions detaillees :** + +| Vue | Action | Vue suivante | API / etat | +|---|---|---|---| +| Accueil | Choisir « sur place » / « a emporter » | Categories | mode memorise (state.js / nav.js) | +| Categories | Choisir une categorie | Produits | `GET /api/categories` (chargement) | +| Produits | Cliquer un produit simple | Modale options | `GET /api/products` | +| Produits | Cliquer un menu | Composeur de menu | `GET /api/menus/{id}` (slots) | +| Modale / Composeur | Ajouter au panier | Produits (panneau mis a jour) | panier en `localStorage` | +| Produits | Voir le panier | Panier | — | +| Panier | Valider | Paiement | — | +| Paiement | Saisir le numero (chevalet, si sur place) puis enregistrer | Confirmation | `POST /api/orders` puis `POST /api/orders/{number}/pay` (idempotent) | +| Confirmation | Nouvelle commande | Accueil | panier vide | + +**Transverse borne :** bascule de police adaptee aux dyslexiques (bouton `a11y.js`, +present sur chaque vue, RGAA Cr 1.c.2) ; navigation clavier + focus-trap dans les +modales ; panneau commande persistant (aside) sur Produits. + +--- + +## 3. Parcours back-office (Bloc 2) + +```mermaid +flowchart TD + L["Connexion (/login)"] -->|identifiants valides| R{"role.default_route\n(seed)"} + L -->|oubli mdp| RP["/forgot_password -> /reset_password"] + R -->|admin| DB["Tableau de bord (/admin/dashboard)"] + R -->|manager| ST["Statistiques (/admin/stats)"] + + DB --> NAV["Navigation laterale\n(conditionnee aux permissions)"] + ST --> NAV + NAV --> CAT["Categories / Produits / Menus\n(+ editeur de recette)"] + NAV --> STK["Stock / Ingredients\n(reappro, inventaire, mouvements)"] + NAV --> USR["Utilisateurs / Roles (RBAC)"] + NAV --> ORD["Commandes (liste, lecture seule)"] + NAV --> PRO["Profil : PIN + mention RGPD (/admin/privacy)"] + + CAT -.->|action sensible : prix/TVA, suppression| PIN["PIN equipier + audit_log\n(meme transaction)"] + STK -.->|inventaire| PIN + USR -.->|mutation compte / matrice RBAC / effacement| PIN +``` + +**Gardes et regles :** + +| Etape | Garde | Regle Merise | +|---|---|---| +| Acces a toute page `/admin/*` | `SessionGuard::check()` : session valide (idle 4h, absolu 10h, compte actif) | RG-6 / RG-T02 | +| Acces a une fonction | `Authorizer::can(role_id, permission)` : teste une permission, pas un nom de role | RG-T03 | +| Action sensible (annulation, prix/TVA, suppression, gestion compte/RBAC, inventaire, effacement PII) | PIN equipier verifie + ecriture `audit_log` dans la meme transaction | RG-T13 / RG-T14 | +| Echec de PIN | trace `pin.failed` + throttle degressif | RG-T22 | + +**Landing par role** (seed `role.default_route`) : admin -> `/admin/dashboard`, +manager -> `/admin/stats`. Les autres roles (kitchen, counter, drive) sont definis +en base ; leurs ecrans operationnels (file cuisine, saisie comptoir/drive) sont +suivis comme evolution (voir le backlog de finition). + +--- + +## 4. Points de contact API + +| Interface | Appelle | Sens | +|---|---|---| +| Borne | `GET /api/categories`, `/products`, `/products/{id}`, `/menus`, `/menus/{id}`, `/allergens` | lecture catalogue (anonyme) | +| Borne | `POST /api/orders`, `POST /api/orders/{number}/pay`, `GET /api/orders/{number}` | commande + suivi (anonyme, idempotent) | +| Back-office | pages rendues serveur sous `/admin/*` + `GET /api/me` | session + RBAC | + +CORS : la borne et le back-office partagent l'origine via une passerelle `/api/*` +(meme origine) ; le middleware CORS reste en defense (origine exacte, sans joker). + +--- + +## 5. References croisees + +- Cas d'usage et acteurs : `docs/uml/use-cases.md` +- Sequence de commande : `docs/uml/sequence-passer-commande.md` +- Machine a etats de la commande : `docs/uml/state-commande.md` +- Sequence securite (annulation PIN-gated) : `docs/uml/security-sequence.md` +- Modele de donnees : `docs/merise/{dictionary,mcd,mld,mlt}.md` +- Contrat API : `docs/api/conventions.md` diff --git a/docs/design/README.md b/docs/design/README.md index 281a8c9..0f9b183 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -3,6 +3,8 @@ ## Fichiers - `maquette-borne.pdf` : maquette ecrans complete fournie avec le brief ecole +- `screens/` : les 10 ecrans de la maquette exportes en PNG (un par ecran) +- `maquette-vs-build.md` : decomposition ecran par ecran + tracabilite maquette vs kiosk construit (ecarts structurants) ## Source en ligne diff --git a/docs/design/maquette-vs-build.md b/docs/design/maquette-vs-build.md new file mode 100644 index 0000000..26d642e --- /dev/null +++ b/docs/design/maquette-vs-build.md @@ -0,0 +1,121 @@ +# Maquette borne vs kiosk construit — decomposition et tracabilite + +> Auteur : BYAN. Note de tracabilite maquette -> code (appui oral RNCP Bloc 1 : +> "comment etes-vous passe de la maquette au code ?"). +> Source : `docs/design/maquette-borne.pdf` (export Figma de l'ecole, 10 ecrans, +> format 1440x1024). Ecrans exportes un a un dans `docs/design/screens/`. + +## 1. Lecture d'ensemble + +La maquette decrit un **parcours de type McDonald's** (Big Mac, Best Of, McCafe, +arches M, Coca) : c'est la base de reference a rebrander en Wakdo. + +Point central : les **10 "ecrans" ne sont pas 10 pages**. Ce sont en realite +~4 ecrans de base plus un systeme de modales qui s'ouvrent par-dessus l'ecran de +commande : + +``` +Accueil + -> Ecran de commande UNIQUE + (bandeau categories en haut + grille produits + panneau commande persistant a droite) + sur lequel s'ouvrent les modales de composition (taille menu, accompagnement, boisson, format/quantite) + -> Chevalet (sur place : saisie du numero) + -> Remerciement +``` + +Le kiosk construit, lui, eclate cet ecran unique en **pages distinctes** et n'a +pas de panneau de commande persistant. C'est l'origine du sentiment "ca ne +correspond pas". + +## 2. Decomposition ecran par ecran + +### Ecran 1 — Accueil +![Accueil](screens/01-accueil.png) +- "Bonjour," + "Souhaitez-vous consommer votre menu sur place ou preferez-vous l'emporter ?" +- Deux grandes cartes : **Sur Place** (icone table) / **A Emporter** (icone sac). +- Fond : arches + Big Mac + Coca. + +### Ecran 2 — Ecran de commande (pivot) +![Ecran de commande menus](screens/02-commande-menus.png) +- **Bandeau categories horizontal** (Menus actif, Sandwiches, Wraps, Frites, Boissons Froides, Encas, Desserts...) avec fleches rouges ◀ ▶ pour faire defiler. +- Titre de section "Nos menus" + sous-titre + **grille de produits** (image, nom, prix). +- **Panneau de commande persistant a droite** : numero de commande (72), "Sur place : 326", lignes de commande avec options en puces et icone corbeille, "TOTAL (ttc) 36,50 EUR", boutons "Abandon" / "Payer", logo W en haut. +- Cet ecran est le coeur de la maquette : tout le reste (sauf accueil/chevalet/remerciement) se joue ici ou en modale par-dessus. + +### Ecran 3 — Modale "Une grosse faim ?" (composition menu, etape 1) +![Modale taille menu](screens/03-modale-taille-menu.png) +- Choix de la taille : **Menu Maxi Best Of** / **Menu Best Of**. +- Bouton "Etape Suivante". Premiere etape d'un assistant en modale. + +### Ecran 4 — Modale "Choisissez votre accompagnement" (etape 2) +![Modale accompagnement](screens/04-modale-accompagnement.png) +- **Frites** / **Potatoes**. +- Boutons "Retour" + "Etape Suivante". + +### Ecran 5 — Modale "Choisissez votre boisson" (etape 3) +![Modale boisson](screens/05-modale-boisson.png) +- Carrousel de boissons (Eau, Coca, Coca Zero, Jus de pomme BIO, The...) avec ◀ ▶. +- Bouton "Ajouter le menu a ma commande" (fin de l'assistant). + +### Ecran 6 — Ecran de commande, categorie Boissons Froides (a la carte) +![Commande boissons](screens/06-commande-boissons.png) +- Meme ecran pivot, categorie "Boissons Froides" active. +- Grille de 8 boissons avec prix unitaires (Eau 1 EUR, Coca 1.90 EUR, Fanta 1.90 EUR, Jus de pomme BIO 2.30 EUR...). + +### Ecran 7 — Selection d'un produit (etat) +![Boisson selectionnee](screens/07-boissons-selection.png) +- Meme grille, "Coca Cola" entoure en jaune : etat visuel de selection. + +### Ecran 8 — Modale "Une petite soif ?" (option produit a la carte) +![Modale format quantite](screens/08-modale-format-quantite.png) +- Taille **30Cl / 50Cl** (+0.50 EUR pour le 50Cl). +- **Stepper de quantite** (- 1 +). +- Boutons "Annuler" / "Ajouter a ma commande". + +### Ecran 9 — Chevalet (sur place) +![Chevalet](screens/09-chevalet.png) +- "Pour etre servis a table," + "Recuperez un chevalet et indiquez ici le numero inscrit dessus". +- Grands chiffres `2 6 1` + bouton "Enregistrer le numero". + +### Ecran 10 — Remerciement +![Remerciement](screens/10-remerciement.png) +- "Toute l'equipe vous remercie, Et vous souhaite un bon appetit dans nos restaurants, A bientot !" +- Bouton "Nouvelle commande". + +## 3. Maquette -> kiosk construit (mapping) + +| Maquette | Kiosk construit | Verdict | +|----------|-----------------|---------| +| 1. Accueil sur place / a emporter | `index.html` | conforme | +| 2 + 6. Ecran de commande unique (bandeau + grille + **panneau persistant**) | eclate en `categories.html` -> `products.html` -> `cart.html` | divergence structurante : multi-pages, et **pas de panneau de commande persistant** | +| (pas de page categories separee) | `categories.html` plein ecran "Que souhaitez-vous commander ?" | ecran **ajoute** (la maquette met les categories en bandeau) | +| 3-5. Composeur menu = **assistant modal en etapes** | `page-product-menu.js` = composition **libre** | divergence (le refactor "consommer les slots /api/menus" est deja en file P4) | +| 8. Modale d'option produit (taille + quantite) | `product.html` (page) | divergence : page au lieu de modale | +| 9. Ecran **chevalet** dedie (saisie numero) | numero gere par l'API (chunk 1a), affiche en confirmation | manquant cote ecran | +| (aucun ecran de paiement) | `payment.html` "Carte bancaire / Especes" | ecran **ajoute** par le build | +| 10. Remerciement | `confirmation.html` | conforme | + +## 4. Ecarts structurants (le fond du sujet) + +1. **Paradigme inverse.** Maquette = **mono-ecran** (un plan de commande avec + categories en bandeau et un panneau recapitulatif persistant a droite, modales + par-dessus). Build = **multi-pages** classiques (categories -> produits -> + produit -> panier). C'est l'ecart structurant principal. +2. **Panneau de commande lateral absent.** La piece centrale de la maquette + (numero de commande, lignes editables avec corbeille, TOTAL ttc, Abandon / + Payer, visible en permanence) n'est pas presente dans le build. +3. **Composition de menu.** Maquette = assistant modal en etapes ; build = + composition libre cote client (`page-product-menu.js`). + +## 5. Rebrand McDonald's -> Wakdo + +Le visuel de la maquette est du McDonald's litteral (Big Mac, Best Of, McCafe, +arches, "Tous a l'eau by M"). Le rebrand vers Wakdo (logo W, catalogue propre) +est attendu et legitime : le branding McDo n'est pas livrable. Le sujet de cette +note n'est donc pas le rebrand mais la **structure** des ecrans. + +## 6. Suite + +Re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories + +composeur en modale) = chantier UI conduit via un cycle FD dedie. Backlog des +divergences = section 3 ci-dessus. diff --git a/docs/design/screens/01-accueil.png b/docs/design/screens/01-accueil.png new file mode 100644 index 0000000..b91c532 Binary files /dev/null and b/docs/design/screens/01-accueil.png differ diff --git a/docs/design/screens/02-commande-menus.png b/docs/design/screens/02-commande-menus.png new file mode 100644 index 0000000..e309520 Binary files /dev/null and b/docs/design/screens/02-commande-menus.png differ diff --git a/docs/design/screens/03-modale-taille-menu.png b/docs/design/screens/03-modale-taille-menu.png new file mode 100644 index 0000000..2504d1c Binary files /dev/null and b/docs/design/screens/03-modale-taille-menu.png differ diff --git a/docs/design/screens/04-modale-accompagnement.png b/docs/design/screens/04-modale-accompagnement.png new file mode 100644 index 0000000..0e45681 Binary files /dev/null and b/docs/design/screens/04-modale-accompagnement.png differ diff --git a/docs/design/screens/05-modale-boisson.png b/docs/design/screens/05-modale-boisson.png new file mode 100644 index 0000000..e835aba Binary files /dev/null and b/docs/design/screens/05-modale-boisson.png differ diff --git a/docs/design/screens/06-commande-boissons.png b/docs/design/screens/06-commande-boissons.png new file mode 100644 index 0000000..d358170 Binary files /dev/null and b/docs/design/screens/06-commande-boissons.png differ diff --git a/docs/design/screens/07-boissons-selection.png b/docs/design/screens/07-boissons-selection.png new file mode 100644 index 0000000..66b49f5 Binary files /dev/null and b/docs/design/screens/07-boissons-selection.png differ diff --git a/docs/design/screens/08-modale-format-quantite.png b/docs/design/screens/08-modale-format-quantite.png new file mode 100644 index 0000000..5e6f8f2 Binary files /dev/null and b/docs/design/screens/08-modale-format-quantite.png differ diff --git a/docs/design/screens/09-chevalet.png b/docs/design/screens/09-chevalet.png new file mode 100644 index 0000000..5560a90 Binary files /dev/null and b/docs/design/screens/09-chevalet.png differ diff --git a/docs/design/screens/10-remerciement.png b/docs/design/screens/10-remerciement.png new file mode 100644 index 0000000..e1054dd Binary files /dev/null and b/docs/design/screens/10-remerciement.png differ diff --git a/docs/domaines/README.md b/docs/domaines/README.md new file mode 100644 index 0000000..e972b3a --- /dev/null +++ b/docs/domaines/README.md @@ -0,0 +1,18 @@ +# Documentation par domaine + +Une fiche par domaine fonctionnel livre : **perimetre**, **ce qui est livre** (code + +routes), **regles metier** (RG-T* de `docs/merise/mlt.md`), **decisions** (renvoi +`docs/adr/`), **tables**. Vue d'ensemble : `docs/ARCHITECTURE.md`. + +**Auteur : BYAN** (formalisation ; arbitrage et validation par l'auteur). + +| Domaine | Fiche | Statut | +|---|---|---| +| Authentification & sessions | [auth.md](auth.md) | Livre (P2) | +| Catalogue (categories, produits, menus) | [catalogue.md](catalogue.md) | Livre (P3) | +| Stock & recettes (ingredients) | [stock-recettes.md](stock-recettes.md) | Livre (P3) | +| Comptes utilisateurs | [users.md](users.md) | Livre (P3) | +| RBAC (roles & permissions) | [rbac.md](rbac.md) | Livre (P3) | +| Statistiques | [stats.md](stats.md) | Livre (P3, KPIs vente differes P4) | +| Borne (kiosk) | [borne.md](borne.md) | Front P5 (API au swap P4) | +| Commande | — | P4 (schema pret, workflow a venir) | diff --git a/docs/domaines/auth.md b/docs/domaines/auth.md new file mode 100644 index 0000000..7ec332d --- /dev/null +++ b/docs/domaines/auth.md @@ -0,0 +1,29 @@ +# Domaine — Authentification & sessions + +## Perimetre +Connexion back-office, deconnexion, reinitialisation de mot de passe, garde de session, +PIN d'action sensible. Pas d'auth cote borne (front public). + +## Ce qui est livre +- `App\Auth\AuthService` (login 12.1 / logout 12.2), `PasswordResetService` (12.3). +- `SessionManager` (seul a toucher `$_SESSION`/cookie, mode test memoire), `SessionGuard` + (RG-6/RG-T02 : idle 4h, absolu 10h, `is_active`), `Csrf` (jeton synchroniseur). +- `PasswordHasher` (argon2id + leurre de timing), `PinVerifier`, `PinThrottle`, + `ThrottlePolicy` (backoff degressif). +- Controleurs `AuthController`, `PasswordResetController`, `ProfileController` (set-PIN + self-service), `MeController` (`/api/me`). + +## Regles metier +- RG-6 / RG-T02 : session valide (idle + absolu + compte actif) sinon 302 `/login`. +- RG-8 / RG-9 : throttle login par compte + par IP (`login_throttle`), backoff degressif. +- RG-T13 : PIN d'action sensible (voir [users](users.md), [rbac](rbac.md), stock). +- Anti-enumeration : reponses neutres (reset, login) ; leurre de timing argon2id. + +## Decisions +[ADR-0001](../adr/0001-php-from-scratch-sans-composer.md) (from scratch), +[ADR-0004](../adr/0004-pin-action-sensible-audit.md) (PIN), +[ADR-0005](../adr/0005-throttle-pin-separe-du-login.md) (throttle PIN). + +## Tables +`user`, `login_throttle`, `pin_throttle`, `audit_log` (login + pin.failed). Detail : +`docs/merise/mlt.md` section 12 + 22. diff --git a/docs/domaines/borne.md b/docs/domaines/borne.md new file mode 100644 index 0000000..24aae20 --- /dev/null +++ b/docs/domaines/borne.md @@ -0,0 +1,31 @@ +# Domaine — Borne (kiosk) + +## Perimetre +Front client tactile (Bloc 1) : parcours welcome -> categories -> produit -> panier -> +confirmation. HTML/CSS/JS vanilla, servi en statique par Apache. + +## Ce qui est livre +- Pages : `index`, `categories`, `products`, `product`, `cart`, `payment`, + `confirmation` (`src/public/borne/`). +- JS modules ES6 (`assets/js/`) : `data.js` (chargement, point de swap P4), `state.js` + (panier), `page-*.js`, `nav.js`, et `allergens.js` (modale generale 14 INCO sur carte + et fiche). +- Donnees : JSON statiques (`data/`) en P5 ; basculent sur `/api/*` DB-backed au swap P4. + +## Regles metier / conventions +- Allergenes : info **generale** (les 14 INCO, reglement UE 1169/2011), pas un calcul + par produit (mapping `ingredient_allergen` differe). +- CSP-safe pour le code projet : pas de script inline ajoute (donnees via `data-*`, + `addEventListener`). Source allergenes = liste fixe `data/allergens.json`, se branchera + sur `/api/allergens` au swap P4. + +## Tests +Harnais front `node:test` + jsdom (`tests/js/allergens.test.js`) : 14 INCO, bouton "i", +ouverture/fermeture (bouton/overlay/Echap), idempotence. Job CI `js-tests` (Node 20). + +## Decisions +Swap point P5 -> API au P4 (cf. `data.js` + journaux). Modele = app self-hostable +([ADR-0009](../adr/0009-compose-standalone-et-prod-gitignore.md)). + +## Tables (au swap P4) +`category`, `product`, `menu` + `allergen` (lecture). Aujourd'hui : JSON statiques. diff --git a/docs/domaines/catalogue.md b/docs/domaines/catalogue.md new file mode 100644 index 0000000..55d8a90 --- /dev/null +++ b/docs/domaines/catalogue.md @@ -0,0 +1,29 @@ +# Domaine — Catalogue (categories, produits, menus) + +## Perimetre +CRUD des categories, produits et menus composes (borne de base + slots). Base du +catalogue consomme par la borne. + +## Ce qui est livre +- Repositories : `CategoryRepository`, `ProductRepository`, `MenuRepository`. +- Controleurs : `CategoryController` (`category.manage`), `ProductController` + (`product.read/create/update/delete`), `MenuController` (`menu.read/create/update/delete`). +- Menus composes : burger de base + `menu_slot` / `menu_slot_option`, editeur slots en + JS vanilla CSP-safe (champ cache `slots_json`), reecriture delete-and-reinsert en tx. + +## Regles metier +- RG-T16 (allowlist colonnes), RG-T18 (validation serveur bornee : prix > 0, TVA dans + {55,100}, etc.), RG-T15 (sorties echappees). +- Produit : PIN equipier + audit UNIQUEMENT si prix ou TVA change (mlt 8.2 RG-4) ; + suppression = PIN + audit (8.3). Menu : suppression = PIN + audit (8.6). +- Pas de suppression dure si reference (FK RESTRICT depuis order_item / menu / selection) + -> 409, alternative = desactivation (`is_available`). + +## Decisions +[ADR-0002](../adr/0002-back-office-mvc-rendu-serveur.md) (MVC serveur), +[ADR-0006](../adr/0006-http-409-conflit-422-validation.md) (409/422), +[ADR-0004](../adr/0004-pin-action-sensible-audit.md) (PIN + audit). + +## Tables +`category`, `product`, `menu`, `menu_slot`, `menu_slot_option`. Detail : +`docs/merise/mlt.md` section 8. diff --git a/docs/domaines/rbac.md b/docs/domaines/rbac.md new file mode 100644 index 0000000..d2c9b30 --- /dev/null +++ b/docs/domaines/rbac.md @@ -0,0 +1,30 @@ +# Domaine — RBAC (roles & permissions) + +## Perimetre +Gestion des roles et de la matrice role/permission (mlt 10.4 MANAGE_RBAC), permission +`role.manage`. Catalogue de permissions fige au seed (lecture seule). + +## Ce qui est livre +- `RoleRepository` (App\Auth) : roles (CRUD, code immuable), permissions (lecture), + matrice (`permissionIdsFor`/`permissionCodesFor`, `setPermissions` tx + + `replacePermissions` raw), `role_visible_source` (`setVisibleSources` / raw). +- `RoleController` (`role.manage`) : index, create/store (role custom RG-4), edit/update + (champs role + matrice + sources visibles en UNE transaction). Vues `admin/roles/{index,form}`. +- Matrice soumise en champs **scalaires** (`perm_`, `source_`) : `Request::formBody` + ne garde que les scalaires (pas de `name[]`, pas de JS). + +## Regles metier +- RG-6 (mlt 10.4) : PIN equipier + `audit_log` (`role.manage`) dans une transaction ; + `details` JSON = **diff** des codes de permission (ajoutes/retires), calcule avant la + reecriture delete-and-reinsert. +- `Authorizer::can` recharge les permissions a chaque verification (effet immediat). +- Garde-fous anti-lockout : le role `admin` conserve `role.manage` ET reste actif ; + `code` immuable apres creation ; `order_source` borne a l'ENUM ; code dupli -> 409. + +## Decisions +[ADR-0004](../adr/0004-pin-action-sensible-audit.md) (PIN + audit), +[ADR-0006](../adr/0006-http-409-conflit-422-validation.md) (409). + +## Tables +`role`, `permission`, `role_permission`, `role_visible_source`, `audit_log`. Detail : +`docs/merise/mlt.md` section 10.4. diff --git a/docs/domaines/stats.md b/docs/domaines/stats.md new file mode 100644 index 0000000..b99fccf --- /dev/null +++ b/docs/domaines/stats.md @@ -0,0 +1,26 @@ +# Domaine — Statistiques + +## Perimetre +Tableau de bord de pilotage (mlt domaine 11), permission `stats.read`. Landing par +defaut du role manager. + +## Ce qui est livre +- `StatsRepository` : `counts()` (compteurs catalogue : produits/menus/categories/ + ingredients, total + actifs/disponibles), `stockHealth()` (repartition des ingredients + actifs par bande RG-T21 + liste d'alerte triee du plus critique). +- `StatsController` (`stats.read`) -> `/admin/stats` + vue `admin/stats/index` (cartes + KPI + table d'alerte stock) + lien nav "Pilotage". + +## Regles metier / perimetre +- KPIs sur les **donnees disponibles** en P3 : sante catalogue + stock. **Ferme le 404** + du landing manager (`role.default_route = /admin/stats`). +- KPIs de **vente** (CA, volumes, `service_day`) = **P4** : ils dependent du domaine + commande (encore en schema seul). +- Sante stock = reutilise `IngredientRepository::stockBand` (source unique RG-T21). + +## Decisions +[ADR-0003](../adr/0003-stock-pourcentage-dispo-calculee.md) (bandes RG-T21). + +## Tables +Lecture seule : `product`, `menu`, `category`, `ingredient` (compteurs + bandes). +KPIs vente (P4) : `customer_order`, `order_item`. Detail : `docs/merise/mlt.md` section 11. diff --git a/docs/domaines/stock-recettes.md b/docs/domaines/stock-recettes.md new file mode 100644 index 0000000..2eafb98 --- /dev/null +++ b/docs/domaines/stock-recettes.md @@ -0,0 +1,31 @@ +# Domaine — Stock & recettes (ingredients) + +## Perimetre +Gestion des ingredients, du stock (reappro + inventaire), des mouvements de stock, et de +la composition des produits (recettes). Sous-tend la disponibilite produit calculee. + +## Ce qui est livre +- `IngredientRepository` : CRUD, stock %/bande calcules, `restock` (tx), `inventoryCount` + (tx, ecrit une ligne meme a delta=0, RG-3), `movements` (borne), `isReferenced`. +- `IngredientController` : CRUD (`ingredient.manage`, sans PIN), RESTOCK (`stock.manage`, + sans PIN), INVENTORY_COUNT (`stock.count` + PIN), mouvements (`stock.read`). +- `ProductRepository` : composition (`product_ingredient`), `setComposition` + (delete-and-reinsert tx), `isOrderable` (RG-T21), `autoUnavailableIds`. +- Editeur de recette (`ProductController::recipeForm/saveRecipe`, `ingredient.manage`). + +## Regles metier +- RG-T13 : INVENTORY_COUNT seule action sensible du stock (PIN equipier) ; succes -> + `stock_movement.user_id`, **sans** `audit_log` (RG-T14 : le mouvement EST la trace). + RESTOCK et CRUD ingredient ne sont PAS sensibles. +- RG-T22 : echec PIN inventaire -> `pin.failed` + throttle dans une transaction. +- RG-T21 : disponibilite produit calculee (cf. [ADR-0003](../adr/0003-stock-pourcentage-dispo-calculee.md)). +- FK : `product_ingredient`/`stock_movement` RESTRICT sur l'ingredient (hard-delete -> 409) ; + `product_ingredient.product_id` CASCADE (trace du nombre de lignes a la suppression, dette #27). + +## Decisions +[ADR-0003](../adr/0003-stock-pourcentage-dispo-calculee.md) (stock % + RG-T21), +[ADR-0004](../adr/0004-pin-action-sensible-audit.md) / RG-T14 (attribution sans double-journal). + +## Tables +`ingredient`, `product_ingredient`, `stock_movement`, `allergen`, `ingredient_allergen` +(mapping differe). Detail : `docs/merise/mlt.md` sections 8.8 + 9. diff --git a/docs/domaines/users.md b/docs/domaines/users.md new file mode 100644 index 0000000..a424ce1 --- /dev/null +++ b/docs/domaines/users.md @@ -0,0 +1,31 @@ +# Domaine — Comptes utilisateurs + +## Perimetre +Gestion des comptes back-office (mlt domaine 10.1-10.3 + 10.5) : creation, edition, +desactivation, reinitialisation de PIN, effacement RGPD. + +## Ce qui est livre +- `UserRepository` (App\Auth) : all (JOIN role) / find / emailExists / activeRoleExists / + create / update (allowlist) / setPasswordHash / clearPin / deactivate / anonymise / + activeAdminCount / isAdmin. +- `UserController` : index (`user.read`), create/store (`user.create`), edit/update + (`user.update`), deactivate (`user.deactivate`), reset-pin, erase-PII. Vues + `admin/users/{index,form,confirm}`. + +## Regles metier +- RG-T13/14 : **toutes** les mutations sont sensibles -> PIN equipier + `audit_log` + (`user.create/update/deactivate/erase_pii`) dans la meme transaction ; `details` JSON = + noms de champs / role (pas de PII). Throttle RG-T22. +- RG-T16 : allowlist (email/prenom/nom/role_id/is_active) ; `is_active` pose serveur a + la creation. Unicite email -> 409. +- Self-protection : pas d'auto-desactivation (403 SELF_DEACTIVATION) ; on ne retire pas + le statut du **dernier admin actif** (update/deactivate/erase) ; effacement deja fait -> 409. + +## Decisions +[ADR-0004](../adr/0004-pin-action-sensible-audit.md) (PIN + audit), +[ADR-0007](../adr/0007-rgpd-anonymisation-tombstone.md) (anonymisation RGPD), +[ADR-0006](../adr/0006-http-409-conflit-422-validation.md) (409/422). + +## Tables +`user` (+ `anonymized_at` pour RGPD), `audit_log`, `role` (FK). Detail : +`docs/merise/mlt.md` section 10.1-10.5. diff --git a/docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md b/docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md new file mode 100644 index 0000000..48bfc15 --- /dev/null +++ b/docs/journal/2026-06-15--p3-throttle-pin-rg-t22.md @@ -0,0 +1,211 @@ +# P3 securite — throttle du PIN d'action sensible (RG-T22), design multi-agents + verification adversariale + +**Date** : 2026-06-15 (suite de la session CRUD Produits #17) +**Branche** : working tree sur `dev` (chunk non commite ; base `dev` = `2756fb4`) +**PR** : ouverte vers `dev` apres revue de l'implementation (auto-merge sur CI verte) +**Duree estimee** : session longue (finalisation + merge Produits, puis design + build + docs Merise du throttle) + +--- + +## Ce qui a ete fait + +Deux temps dans la session. + +### 1. Finalisation et merge du CRUD Produits (PR #17) + +Le CRUD produits (cas riche : `price_cents`, `vat_rate {55,100}`, `category_id`, suppression FK-safe) +a ete termine, revu (6 findings : 1 HIGH, 1 LOW, 4 MEDIUM de couverture), corrige, puis merge sur `dev` +en auto-merge sur CI verte (squash, `dev` = `2756fb4`). La revue avait remonte un finding **HIGH** : le PIN +d'action sensible (`PinVerifier`) verifie le PIN avec parite de timing mais **sans limitation de +tentatives**. Mitigation shippee dans #17 : chaque echec ecrit une ligne `audit_log` `pin.failed` +(detectable). Le throttle complet a ete arbitre comme chunk dedie — ce qui suit. + +### 2. Throttle du PIN (RG-T22) — conception puis construction + +**Conception via un panel multi-agents** (3 lentilles independantes : Ockham / efficacite-menace / +anti-DoS) -> synthese -> passe adversariale. Le panel a tranche la **dimension** du compteur et a integre +deux correctifs d'emblee. Verdict de l'adversaire : la conception tient (`holds = true`). + +Artefacts produits (tous dans le working tree, **non commites**) : + +- `db/migrations/0002_pin_throttle.sql` — nouvelle table (entite 22), cle sur `actor_user_id` + (UNIQUE, FK -> `user` ON DELETE CASCADE), separee des compteurs de connexion. **Appliquee a la base + dev** via `bash db/migrate.sh`. +- `src/app/Auth/ThrottlePolicy.php` — dimension `'pin'` ajoutee a `fromConfig` (bornes propres + `PIN_THROTTLE_*` : base 30s, plafond 300s). +- `src/app/Auth/PinThrottle.php` (nouveau) — `isLocked` / `recordFailure` (upsert atomique + backoff, + une transaction) / `reset`. +- `src/app/Auth/PinVerifier.php` — methode additive `payTimingDecoy` (parite de timing du chemin + verrouille). +- `src/app/Controllers/ProductController.php` — cablage dans `update` (branche prix/TVA) et `destroy` : + gate avant verification, `recordFailure` sur PIN faux, `reset` apres l'effet reussi. +- Config : `.env.example` + `docker-compose.yml` (`PIN_THROTTLE_THRESHOLD/BASE/MAX/WINDOW`). +- Docs Merise portees de 21 a 22 entites : RG-T22 dans `mlt.md`, entite 22 `pin_throttle` dans + `mcd.md` / `mld.md` / `dictionary.md`, couverture MCT 22/22 dans `mct.md`. +- Tests : +16 (dimension `pin` de `ThrottlePolicy` ; `PinThrottleTest` ; cas de controleur ; leurre de + timing ; integration `PinThrottleDbTest`). **188 tests / 525 assertions verts, PHPStan L6 propre.** + +--- + +## Pourquoi — decisions et alternatives + +### Decision 1 — Compter les echecs par utilisateur AGISSANT (et non par email cible ni par IP) + +- **Decision** : la dimension du throttle est l'identite de session authentifiee qui realise l'action + (`$guard->userId`), stockee dans une table dediee `pin_throttle` cle sur `actor_user_id`. +- **Alternatives considerees** : + - *par email cible* : contournable par rotation des emails (le modele "identifiant equipier + PIN" + verifie un email arbitraire) ; + - *par IP* : sur un poste a session partagee, tous les equipiers sortent par la meme IP ; un verrou IP + priverait de re-autorisation l'ensemble des equipiers honnetes du comptoir ; + - *hybride cible + IP avec delai `usleep`* : ajoute une colonne de portee, ~6 cles de config, un `usleep` + qui retient un worker PHP-FPM, et une surface de blocage d'un collegue ; + - *globale* : un seul attaquant degraderait l'autorisation sensible de tout le magasin. +- **Raison du choix** : la cle "acteur" est la seule non-contournable (changer d'acteur impose une + reconnexion, elle-meme throttlee et auditee cote login) ET sans collateral sur un poste partage + (verrouiller l'attaquant n'affecte aucun autre `user_id`). Elle dissout la tension rotation/collateral + qui force les autres pistes a un delai par IP. Rasoir d'Ockham (#37) : une table, un collaborateur, deux + points d'appel, `PinVerifier` inchange. + +### Decision 2 — Table dediee, separee des compteurs de connexion + +- **Decision** : compteurs `pin_throttle` physiquement distincts de `user.failed_login_attempts` / + `user.lockout_until` / `login_throttle`. +- **Alternative** : reutiliser les colonnes de login existantes. +- **Raison** : un echec de PIN n'incremente aucun compteur de login ; sinon, marteler le PIN d'une victime + verrouillerait sa connexion (escalade de deni de service vers une surface plus sensible). Un test de + regression verifie l'absence d'ecriture vers `user`/`login_throttle` sur le chemin d'echec. + +### Decision 3 — Backoff plus permissif que le login + +- **Decision** : base 30s, plafond 300s (le login est a 60s / 900s). +- **Raison** : RG-T13 cadre le PIN comme un controle de dissuasion (risque residuel Faible) ; un faux + positif bloque un manager en plein rush. Le backoff reste degressif, pas un verrou definitif. + +### Decision 4 — Correctifs adversariaux integres a la conception (pas en second passage) + +- **Anti-flood de l'audit** : sous verrou actif, aucune nouvelle ligne `pin.failed` (les echecs ayant + arme le verrou sont deja audites) — sinon le chemin verrouille, moins couteux, gonflerait le journal + append-only et noierait l'alerte de volume. +- **Parite de timing** : `payTimingDecoy` paie le cout argon2id sur le chemin verrouille, pour que la + latence ne distingue pas "verrouille" de "mauvais PIN". + +### Methodo — pourquoi un panel + une passe adversariale + +Challenge Before Confirm (mantra IA-16) sur un finding de severite HIGH avec migration de schema (peu +reversible) : faire produire trois conceptions independantes, les arbitrer, puis tenter de casser la +retenue. La passe adversariale a confirme que les quatre attaques visees (rotation d'email, falsification +de `X-Forwarded-For`, contamination du compteur de login, collateral de borne partagee) echouent par +construction, et a remonte les deux correctifs ci-dessus. + +--- + +## Comment — points techniques cles + +- **Upsert atomique, miroir de la dimension IP d'`AuthService`** : `INSERT ... ON DUPLICATE KEY UPDATE + failed_attempts = IF(window_started_at < :cutoff, 1, failed_attempts + 1) ...`. L'increment est calcule + cote SQL sous le verrou de ligne pris sur la cle UNIQUE, ce qui serialise les POST concurrents (anti + lost-update). Placeholders nommes distincts car `PDO::ATTR_EMULATE_PREPARES = false` interdit de lier un + meme nom deux fois (`src/app/Auth/PinThrottle.php`). +- **Gate-before-verify** : `isLocked($actorId)` est evalue AVANT `resolveActingUser`. Un acteur verrouille + recoit le meme 422 generique "Email ou PIN invalide" (anti-enumeration) ; meme un PIN correct est bloque + tant que le verrou court. +- **Le piege du `reset`** : a un succes, deux identites sont en portee — l'acteur de session + (`$guard->userId`, celui qui a ete incremente) et l'equipier resolu par le PIN (`$actor['id']`, ecrit + dans `audit_log`). Le `reset` cible l'acteur de **session** ; le confondre laisserait le compteur de + l'agissant sans purge. Un test l'asserte explicitement (`ProductControllerTest`). +- **FK ON DELETE CASCADE** (contrairement a `login_throttle`, sans FK) : la cle est un utilisateur + back-office authentifie, donc supprimer/anonymiser le compte retire proprement sa ligne de throttle + (etat ephemere, par opposition a `audit_log` qui est permanent et en SET NULL). + +--- + +## Criteres RNCP couverts + +- **Bloc 2 - Cr 3.a / 3.b** : extension du modele Merise (dictionnaire/MCD/MLD) — entite 22 `pin_throttle`, + FK et cardinalite (assoc R9), coherence 22/22 verifiee dans les quatre docs. +- **Bloc 2 - Cr 4.e (securite)** : requetes preparees (anti-injection), reponse generique + (anti-enumeration), separation dure des compteurs (anti escalade de DoS), gate avant verification. +- **Bloc 2 - Cr 4.c (POO / namespaces)** : `PinThrottle` (classe dediee), reutilisation de `ThrottlePolicy` + (math pure), cablage via les controleurs heritant d'`AdminController`. +- **Bloc 2 - Cr 4.g (preparation livraison)** : 188 tests PHPUnit verts, PHPStan niveau 6 propre, test + d'integration contre une vraie MariaDB. +- **Bloc 2 - Cr 3.d (RGPD)** : FK ON DELETE CASCADE (l'etat de throttle suit l'anonymisation du compte) et + purge cron documentee (minimisation / limitation de conservation). +- **Bloc 5 - Cr 7.b.3 (cron) / Cr 7.d.2 (tests avant deploiement)** : predicat de purge `pin_throttle` + aligne sur `login_throttle` ; le chunk passera la CI (PHPUnit + PHPStan + secret-scan) avant merge. + +--- + +## Questions anticipees du jury + +- **Q** : "Pourquoi compter les echecs de PIN sur l'utilisateur agissant plutot que sur l'IP, comme pour le login ?" + **R** : Sur une borne a session partagee, tous les equipiers sortent par la meme IP ; un verrou par IP + les priverait tous de re-autorisation. La cle "acteur" verrouille seulement l'individu qui multiplie les + echecs, sans toucher ses collegues, et reste non-contournable (changer d'acteur impose une reconnexion, + deja throttlee cote login). + +- **Q** : "Un attaquant qui martele le PIN d'un collegue peut-il bloquer sa connexion ?" + **R** : Non. Les compteurs du PIN vivent dans une table separee (`pin_throttle`), distincte de + `user.failed_login_attempts` et de `login_throttle`. Un echec de PIN n'ecrit aucun compteur de login ; + un test de regression le verifie. + +- **Q** : "Pourquoi un backoff degressif et pas un verrou definitif ?" + **R** : Le PIN est un controle de dissuasion a risque residuel Faible ; un verrou dur bloquerait un + manager sur quelques fautes de frappe en plein service. Le backoff ralentit la force brute (de quelques + essais a une poignee par fenetre) tout en s'auto-resorbant. + +- **Q** : "Comment avez-vous valide cette conception de securite ?" + **R** : Trois conceptions independantes ont ete produites puis arbitrees, et une passe adversariale a + tente de casser la retenue (rotation d'email, falsification d'en-tete proxy, contamination du login, + collateral de borne). Les quatre echouent par construction ; la passe a aussi remonte deux correctifs + (anti-flood de l'audit, parite de timing) integres avant la fin. + +- **Q** : "Pourquoi ajouter une 22e table plutot que des colonnes sur `user` ?" + **R** : Des colonnes sur `user` devraient porter sur l'utilisateur cible (contournable par rotation) ou + ajouter une 4e dimension de verrou sur la table de comptes. Une table dediee, cle sur l'acteur, garde + `user` epuree et garantit la separation des compteurs par construction. + +--- + +## Points d'amelioration conscients + +- **Couverture CI de l'increment SQL** : les tests unitaires stubbent le compteur relu apres l'upsert + (`FakeDatabase.pinThrottleAttempts` fixe), donc la semantique reelle de l'increment + fenetre glissante + n'est prouvee que par `PinThrottleDbTest` (integration), auto-skippee sans MariaDB. C'est la posture + STANDARD du projet (CI sans Composer ni base : `AuthServiceDbTest`, `PinVerifierDbTest`... skippent de + meme) ; verifiee en local avec `WAKDO_DB_TESTS=1`. A garder en tete si la CI gagne un service DB. +- **Cron de purge non encore etendu** : le predicat de purge `pin_throttle` est documente (`mlt.md` 13.5) + mais le job cron lui-meme (`docker/cron`) n'a pas ete edite. Sans impact fonctionnel (la table tient une + ligne par utilisateur back-office) ; a brancher avec le job `login_throttle` existant. +- **Dimension par IP volontairement absente** : choix documente (collateral de borne partagee). A + reconsiderer seulement si un abus par IP est observe en pratique. +- **Detection** : l'alerte sur le volume de `pin.failed` est le vrai controle detectif ; elle reste a + outiller cote supervision (hors code applicatif). Un PIN de plus de 4 chiffres pour les roles sensibles + est recommande. + +--- + +## Etat a la reprise + +- Chunk throttle PIN complet (source + tests + migration + docs Merise + `.env.example` + compose + ce + journal), vert (188 tests, PHPStan L6), revue adversariale de l'implementation passee (`holds = true`), + commite et pousse cette session avec PR vers `dev` (auto-merge sur CI verte). Migration `0002` deja + appliquee a la base dev. +- **Prochaine action** : suite P3 : Menus (+ slots), Ingredients/stock, Users + matrice RBAC, Stats. + Differe : etendre le cron de purge a `pin_throttle` ; alerte de volume `pin.failed` (supervision). + +--- + +## Liens vers artefacts + +- CRUD Produits merge : commit `49ab77b` -> `dev` `2756fb4` (PR #17, squash). +- Throttle PIN (non commite) : `src/app/Auth/PinThrottle.php`, `src/app/Auth/ThrottlePolicy.php`, + `src/app/Auth/PinVerifier.php`, `src/app/Controllers/ProductController.php`, + `db/migrations/0002_pin_throttle.sql`. +- Tests : `tests/Unit/Auth/PinThrottleTest.php`, `tests/Unit/Auth/ThrottlePolicyTest.php`, + `tests/Unit/Admin/ProductControllerTest.php`, `tests/Integration/PinThrottleDbTest.php`, + `tests/Support/FakeDatabase.php`. +- Docs Merise (RG-T22, entite 22) : `docs/merise/{mlt,mcd,mld,dictionary,mct}.md`. +- Config : `.env.example`, `docker-compose.yml` (`PIN_THROTTLE_*`). +- Resume roulant : `docs/SESSION_RESUME.md` (entree Produits #17 = suite 4). diff --git a/docs/journal/2026-06-16--audit-reel-livrables-p2-p3.md b/docs/journal/2026-06-16--audit-reel-livrables-p2-p3.md new file mode 100644 index 0000000..67dfac2 --- /dev/null +++ b/docs/journal/2026-06-16--audit-reel-livrables-p2-p3.md @@ -0,0 +1,187 @@ +# Audit reel des livrables P2/P3 — verification sur pieces + +**Date** : 2026-06-16 +**Branche** : `docs/journal-audit-2026-06-16` -> `dev` +**PR** : cette note (PR dediee) ; remediations associees : #19, #20, #21 +**Auteur** : BYAN +**Duree estimee** : 1 session + +--- + +## Ce qui a ete fait + +Verification du travail livre le 2026-06-15 (8 PR : P2 auth/RBAC/PIN, P3 shell + +CRUD categories/produits + set-PIN + throttle PIN), a la demande explicite de +controler "le reel, pas le journal" — suspicion d'un ecart non documente. + +Methode : controle sur pieces uniquement. + +- git : timeline du 2026-06-15, parents de commit, branches reellement presentes + cote Forgejo (2 : `dev`, `main`) ; +- code lu a la ligne (`file:line`) ; +- base MariaDB live interrogee (schema, seed, migrations trackees) ; +- suite de tests rejouee en conteneur ; API CI Forgejo (264 runs analyses) ; +- sweep multi-agents : 10 dimensions (PR #11-#18 + regles SbD RG-T01..T22 + + infra/config), chaque finding re-verifie en adversarial (confirmer le miss ou + le refuter), plus un critique de completude. + +--- + +## Resultat — le socle metier tient + +Confirme enforced dans le code (pas seulement documente), `file:line` a l'appui : +RG-T01 (CSRF sur les mutations), T02 (garde de session + re-verif `is_active`), +T03 (autorisation par permission, pas par nom de role), T06 (requetes preparees), +T08 (mutation + `audit_log` dans une seule transaction), T13 (PIN d'action +sensible), T14 (audit append-only), T16 (allowlist de colonnes), T18 (validation +serveur bornee), T22 (throttle PIN isole du login). + +Base live conforme au seed documente (5 roles / 23 permissions / 57 lignes de +matrice / 14 allergenes / 9 categories / 53 produits / 13 menus) ; migrations +`0001` + `0002` trackees appliquees. 188 tests / PHPStan L6 : reproduits verts en +conteneur. + +--- + +## Miss confirmes (par gravite) + +Severite issue de la passe adversariale, qui a parfois revu a la baisse +l'evaluation initiale. + +### CRITIQUE — durcissement php.ini absent du conteneur en service [OUVERT] + +`docker/php-fpm/php.ini` (durci le 2026-06-15 : `allow_url_fopen=Off`, +`disable_functions`, `cgi.fix_pathinfo=0`, `enable_dl=Off`) n'est pas actif sur +`wakdo-app`. `docker exec wakdo-app php -i` renvoie `allow_url_fopen=On`, +`disable_functions` vide, `enable_dl=On`. Cause : l'image date du 2026-04-30 (le +`php.ini` est `COPY`-e a la build, pas monte) et n'a pas ete reconstruite depuis +le durcissement. Correctif : rebuild de `wakdo-app` puis re-verif via `php -i`. + +### HIGH — la CI n'executait aucun test d'integration DB [CORRIGE, PR #21] + +`static-tests` lancait `phpunit` sans base ni `WAKDO_DB_TESTS=1` : les 7 +`tests/Integration/*DbTest` s'auto-skippaient (13 skips), donc le SQL porteur de +securite (upsert atomique du throttle, predicat `AND r.is_active = 1`, audit +in-transaction, FK RESTRICT/CASCADE) n'etait valide par aucun test en pipeline. +Le double `FakeDatabase` n'execute pas le SQL : une regression de ces requetes +passait la CI au vert. Corrige par un service MariaDB ephemere + application +schema/seed + `WAKDO_DB_TESTS=1` + `--fail-on-skipped`. CI verte verifiee sur le +runner (run #78 push + run #79 PR #21 : `secret-scan` / `php-lint` / +`static-tests` au vert). + +### MEDIUM + +- XSS stockee latente dans la borne (RG-T15) : 3 scripts injectaient + `product.nom` / `item.libelle` / `product.image` dans `innerHTML` sans + echappement (seul `page-product-menu.js` etait conforme). Donnees statiques + aujourd'hui, mais `data.js` documente la bascule P4 vers `/api/products` + (valeurs CRUD admin). [CORRIGE, PR #20] +- Liens de nav admin morts : `/admin/menus|orders|users|roles` exposes dans le + layout (conditionnes par permission) sans route -> 404 JSON. [OUVERT] +- Utilisateur DB applicatif en `GRANT ALL PRIVILEGES` alors que la doc + (compose, `backup-db.sh`) decrit un moindre privilege (SELECT / LOCK TABLES / + SHOW VIEW). [OUVERT] +- Retention RGPD (audit / order) + purge throttle : documentees comme purges + cron mais non implementees (pas de job actif, pas de script, vars non + injectees au conteneur cron). [OUVERT] + +### LOW + +- Enumeration d'email sur le reset de mot de passe : reponse instantanee sur + email inconnu vs travail + ecriture sur email connu. La parite timing/ecritures + tient sur le login, pas sur le reset. +- Suppression produit non entierement FK-safe : `product_ingredient.product_id` + est `ON DELETE CASCADE` (omis du docblock) -> suppression silencieuse de + recette possible, sans trace dans l'audit. Latent : table vide au seed actuel. +- Page `/admin/profile/pin` non liee dans la nav (joignable par URL directe). +- `PASSWORD_ALGO` expose en env mais code en dur (`PASSWORD_ARGON2ID`) : un + changement de valeur serait sans effet. +- Chemin d'echec PIN non atomique (`logFailedPin` hors transaction puis + `recordFailure` dans sa propre transaction), en tension avec RG-T08 (qui tient + sur le chemin de succes). +- `borne/data/produits.json` (66 produits, maquette statique) diverge de la + table `product` (53). + +### Faux positifs ecartes par la passe adversariale + +- "Throttle login partiel / non teste" : la double porte compte + IP est + complete, l'increment atomique et le predicat de fenetre sont couverts (unit + + integration), l'IP du dernier hop `X-Forwarded-For` n'est pas falsifiable. +- "Code mort `userId === null` post-guard" : c'est le narrowing `?int -> int` + requis par PHPStan L6, pas un defaut. + +--- + +## Remediations livrees cette session + +- **PR #19** : suppression des 6 maquettes `.html` du back-office servies sans + authentification (exposition / information disclosure). +- **PR #20** : echappement (`escHtml` centralise dans `state.js`) des chaines + data-derived injectees en `innerHTML` dans les 3 scripts kiosk (RG-T15). +- **PR #21** : execution des tests d'integration DB en CI (service MariaDB + + `WAKDO_DB_TESTS=1` + `--fail-on-skipped`). Recette validee en local (188 tests + / 525 assertions / 0 skip) puis sur le runner. + +--- + +## Reste a traiter (ordre suggere) + +1. **CRITIQUE** : reconstruire l'image `wakdo-app` pour activer le `php.ini` + durci. +2. **MEDIUM** : retirer ou router les liens de nav morts ; appliquer un GRANT de + moindre privilege au user DB ; implementer la purge RGPD / throttle (job cron + + script). +3. **LOW** : decoy de timing sur le reset ; pre-check FK + trace audit a la + suppression produit ; lien de nav vers la page PIN ; honorer ou retirer + `PASSWORD_ALGO` ; atomiser le chemin d'echec PIN. + +--- + +## Criteres RNCP couverts + +- **Bloc 5 - Cr 7.d.2 / 7.d.3** (CI/CD : application testee avant deploiement, + integration continue testee) : PR #21 fait reellement tourner les tests + d'integration en pipeline (avant, ils etaient skippes). +- **Bloc 2 - Cr 4.f.2** (maitrise de l'outil collaboratif : Git, PR, branches, + hooks) : remediation via PR dediees et branches courtes, CI gardee. +- **Securite (transverse)** : verification que les regles SbD documentees sont + effectivement appliquees ; fermeture d'une exposition (maquettes non gardees) + et d'une XSS latente. + +--- + +## Questions anticipees du jury + +- **Q** : "Vos tests etaient verts ; comment un trou a-t-il pu subsister ?" + **R** : la suite unitaire (188 verts) ne touchait pas le SQL reel (double en + memoire), et les tests d'integration s'auto-skippaient en CI. Le badge vert ne + couvrait pas la couche SQL. Corrige (PR #21) et garde par `--fail-on-skipped`. +- **Q** : "Le graphe des branches semble casse." + **R** : workflow squash-merge -> historique `dev` lineaire (1 PR = 1 commit) ; + les branches de feature apparaissent en moignons car le squash ne cree pas le + 2e parent d'un merge classique. Choix assume. +- **Q** : "Pourquoi le durcissement php.ini n'etait-il pas actif ?" + **R** : le `php.ini` est `COPY`-e dans l'image, pas monte ; l'image n'avait pas + ete reconstruite depuis le durcissement. Detecte par `php -i` sur le conteneur, + corrige par un rebuild. + +--- + +## Points d'amelioration conscients + +- Les findings MEDIUM / LOW restants sont traces ici et priorises ; ils ne + bloquent pas la suite P3, mais sont a fermer avant une mise en avant securite + au jury. +- `--fail-on-skipped` est volontairement strict : tout futur test legitimement + skippe devra etre justifie explicitement. + +--- + +## Liens vers artefacts + +- PR : #19 (maquettes), #20 (escHtml RG-T15), #21 (tests DB en CI). +- Fichiers cles : `.forgejo/workflows/ci.yml`, `docker/php-fpm/php.ini`, + `src/public/borne/assets/js/{state,page-products,page-product,page-cart}.js`, + `src/app/Auth/*`, `src/app/Controllers/*`, `db/migrations/`, `db/seeds/`. +- Methode : sweep multi-agents (10 dimensions) + verifications adversariales, + pilote depuis Claude Code. diff --git a/docs/journal/2026-06-17--makefile-to-compose-migrate.md b/docs/journal/2026-06-17--makefile-to-compose-migrate.md new file mode 100644 index 0000000..41717a9 --- /dev/null +++ b/docs/journal/2026-06-17--makefile-to-compose-migrate.md @@ -0,0 +1,88 @@ +# 2026-06-17 — Du Makefile a `docker compose up` (service wakdo-migrate) + +**Auteur : BYAN.** Remplacement de l'orchestration locale par Makefile par un service +compose one-shot. Objectif : que `docker compose up` amene a lui seul une stack +complete et utilisable, et retirer un Makefile devenu en partie trompeur. + +## Pourquoi ce changement + +### Le declencheur : le Makefile mentait en partie +Un audit du Makefile (24 cibles) a montre trois categories : +- **Cibles mortes / trompeuses** : `test`, `test-unit`, `test-integration`, `lint` + affichaient *« Pas encore implemente … en P2 »* alors que les tests EXISTENT et + tournent (PHPUnit via `.phar`, 263 tests unit + 301/916 en integration ; PHPStan + L6 ; tests JS node:test+jsdom). `install-hooks` referencait un `.githooks/` et un + `scripts/install-hooks.sh` absents. Une cible qui annonce un faux est pire qu'une + cible absente. +- **Wrappers fins** : `up/down/logs/shell/...` = une ligne au-dessus de + `docker compose`, valeur surtout de decouvrabilite (`make help`). +- **Une seule cible reellement porteuse** : `init` (build -> up -> wait-db -> + migrate), citee comme la preuve du critere RNCP **Cr 7.c.4** (*« lancer la stack + complete avec une seule ligne de commande »*). + +### Le point cle : Cr 7.c.4 parle d'un RESULTAT, pas de `make` +Le critere exige *une commande -> stack complete*. Il ne mentionne pas `make` ; +`make init` n'etait qu'un choix d'implementation. Or `docker compose up` seul ne +suffisait pas : il demarre les conteneurs mais **n'applique pas les migrations** +(base vide -> stack non « complete »). C'etait l'unique raison d'etre de `make init`. + +En deplacant migration + seed DANS la stack (un service one-shot qui tourne au +boot), c'est `docker compose up` LUI-MEME qui amene la stack complete. Avantages : +- **Commande universelle** : `docker compose up`, sans dependance a l'outil `make` + sur l'hote (un correcteur n'a pas a installer/connaitre `make`). +- **Comportement = documentation** : l'ancien `make init` ne faisait meme PAS le + seed (il s'arretait a `migrate`), alors que le README annoncait « migrate + seed ». + Le nouveau chemin seed pour de vrai, donc la stack est *loginnable* (admin present) + en une commande. +- **Plus idiomatique** : faire porter l'init par la couche d'orchestration (compose) + plutot que par un outil hote externe. + +## Ce qui a ete fait + +- **`db/migrate-container.sh`** : runner in-container. Applique `db/migrations/*.sql` + (suivi `schema_migrations`) PUIS `db/seeds/*.sql` (suivi `seeds_applied`), de + maniere idempotente, en se connectant a la base par le reseau compose (DB_HOST). + Distinct de `db/migrate.sh` (hote, via `docker exec`), conserve pour l'usage manuel + (`--status`) et la CI. +- **Service `wakdo-migrate`** (image mariadb, `restart: "no"`) : `depends_on` + `wakdo-db: service_healthy`, lance le runner puis sort. `wakdo-app` et `wakdo-web` + gagnent `depends_on wakdo-migrate: service_completed_successfully` -> ils ne servent + qu'une fois le schema + le seed en place. +- **Makefile supprime.** Les commandes equivalentes en clair : + `docker compose up -d` (= ex-`make init`/`up`), `docker compose down` (`make down`), + `docker compose down -v` (`make clean`), `docker compose build --no-cache && up -d` + (`make rebuild`), `docker compose logs -f` (`make logs`), + `docker compose exec wakdo-db mariadb -uroot -p"$DB_ROOT_PASSWORD"` (`make shell-db`). + Tests : `docker run --rm -v "$PWD":/app -w /app wakdo-wakdo-app php phpunit.phar -c phpunit.xml` + (cf. README / SESSION_RESUME). + +## Verification + +Base MariaDB ephemere et vierge (pour ne pas toucher la dev) : +- Run #1 : 2 migrations + 2 seeds appliques. +- Run #2 : 0 nouveau (idempotent, tout ignore). +- Donnees : 5 roles, 23 permissions, admin `admin@wakdo.local` present ; + `schema_migrations`=2, `seeds_applied`=2. + +`docker compose -p wakdo config` valide. La CI n'utilise pas `make` (0 appel) : +elle garde sa propre boucle migrate -> non impactee. + +## Mapping Cr 7.c.4 (apres ce changement) +*« Le fichier de configuration permet de lancer la stack applicative complete avec +une seule ligne de commande »* -> **`docker compose up`** : `docker-compose.yml` +decrit la stack, le service `wakdo-migrate` applique schema + donnees, app/web +attendent sa completion. Une commande, aucune dependance hote. + +## Note de deploiement (environnements deja seedes) +Sur une base existante deja migree ET seedee AVANT l'introduction du suivi +(`seeds_applied` absente), le premier `docker compose up` avec le nouveau service +tenterait de rejouer les seeds (INSERT non idempotents) -> conflits d'unicite. Pour +ces environnements : back-fill une fois la table de suivi +(`CREATE TABLE seeds_applied(...)` + INSERT des noms de fichiers seed deja appliques, +idem `schema_migrations` si besoin) AVANT le premier up. Les deploiements sur volume +VIERGE ne sont pas concernes (le service applique tout proprement, comme verifie). + +## Compromis assumes +- migrations + seeds evalues a CHAQUE `up` : cout negligeable (le suivi rend les + re-runs sans effet). +- `wakdo-migrate` se connecte en root (DDL + INSERT de reference), comme `migrate.sh`. diff --git a/docs/journal/2026-06-17--session-infra-doc-e2e.md b/docs/journal/2026-06-17--session-infra-doc-e2e.md new file mode 100644 index 0000000..e9a07cb --- /dev/null +++ b/docs/journal/2026-06-17--session-infra-doc-e2e.md @@ -0,0 +1,55 @@ +# 2026-06-17 — Session : infra compose, documentation, E2E Playwright + +**Auteur : BYAN.** Retrospective de session. Suite a l'achevement du back-office P3 +(Stats/Users/RBAC), cette session a porte sur l'infra de demarrage, un jeu de +documentation pour la Forge, et l'amorce des tests E2E. + +## Contexte de depart +P3 back-office complet et merge (#37 Stats, #38 Users, #39 RBAC). `dev` propre. + +## Ce qui a ete livre (PR mergees sur dev) + +| PR | Objet | +|----|-------| +| #40 | Makefile -> `docker compose up` : service one-shot `wakdo-migrate` (migrate + seed idempotents, suivi `schema_migrations`/`seeds_applied`). Makefile supprime. | +| #41 | `docker-compose.yml` **standalone portable** (port hote, sans Traefik) ; prod = `docker-compose.prod.yml` **gitignore** par hote. Renommage `TRAEFIK_DOMAIN_*` -> `APP_HOST_*`. `.env.example` local-first. | +| #42 | Doc socle : `ARCHITECTURE.md` (10 sections) + `DEVELOPER.md`. | +| #43 | Registre `docs/adr/` : 9 ADR (puis 10, cf. #46). | +| #44 | Doc par domaine `docs/domaines/` : 7 fiches (auth, catalogue, stock-recettes, users, rbac, stats, borne). | +| #45 | E2E Playwright **etape 1** : parcours borne (welcome -> confirmation). | +| #46 | E2E Playwright **etape 2** : parcours admin (garde -> login -> dashboard -> logout) + fix securite (cf. ADR-0010). | + +## Decisions notables +- **`docker compose up` comme commande unique** (Cr 7.c.4) sans `make` ni dependance + hote, via le service `wakdo-migrate`. Cf. ADR-0008, journal makefile-to-compose. +- **Deux fichiers compose pleins et independants** (pas d'overlay `-f`) : un standalone + versionne pour tous, un prod gitignore par hote. Choix de simplicite assume sur + demande du user (clarte > DRY pour l'infra). Cf. ADR-0009. +- **Playwright en conteneur officiel, contre une stack jetable isolee** (`run.sh`, + projet `-p wakdoe2e`, override container_name, joint par `--add-host`) : aucune + dependance browser sur l'hote, ne touche aucune stack existante. Hostnames de test + en `.test` (Chromium force `*.localhost` vers 127.0.0.1, RFC 6761). + +## Ce que l'E2E a fait remonter (sa valeur) +1. **a11y** : le bouton "Valider ma commande" (``) gardait `aria-disabled="true"` + (`.disabled` est un no-op sur un ``) -> annonce desactive panier rempli. Corrige. +2. **securite/usage** : cookie de session `secure=true` en dur -> session intenable en + HTTP, donc admin inconnectable en local. Rendu **conditionnel au HTTPS** (prod + inchange). Cf. ADR-0010. + +## Verifications +PHPStan L6 OK ; 263 tests unit ; 7 tests JS ; 2 parcours E2E verts (borne + admin) ; +smoke-test standalone (stack jetable, migrate + seed + vhosts) ; CI Forgejo verte sur +chaque PR (auto-merge sur label). `.env` LOCAL migre vers `APP_HOST_*`. + +## Reste a faire (file d'attente) +- **Deploiement serveur (Thanos)** : migrer le `.env` serveur (`APP_HOST_*`), placer son + `docker-compose.prod.yml`, back-fill `seeds_applied` avant le 1er up. +- **E2E etape 3** : job CI Forgejo (stack jetable + Playwright conteneur) ; verifier que + le runner peut lancer Docker. +- **Front** : page de **login** a retravailler (signalee comme "moche" ; pas le dashboard). +- **Doc** : enrichissements (diagrammes), doc commande quand P4 sort. +- **P4** : domaine commande (KPIs vente, nav orders, swap borne -> API DB-backed). + +## Reprise +`docs/SESSION_RESUME.md` tient l'etat detaille et les commandes de reprise. diff --git a/docs/journal/2026-06-18--front-login-ui-admin-p4-commande.md b/docs/journal/2026-06-18--front-login-ui-admin-p4-commande.md new file mode 100644 index 0000000..9583a05 --- /dev/null +++ b/docs/journal/2026-06-18--front-login-ui-admin-p4-commande.md @@ -0,0 +1,80 @@ +# 2026-06-18 — Session : page login, refonte UI admin, humanisation, P4 commande + +**Auteur : BYAN.** Retrospective de session. Apres la session infra/doc/E2E du 2026-06-17, +cette session a porte sur le front (pages auth), une refonte du back-office pour des +equipiers non-techniques (UX + UI), l'humanisation des libelles, et l'amorce du domaine +P4 commande (creation + encaissement cote API). + +## Contexte de depart +Back-office P3 complet et merge. `dev` propre. Pistes ouvertes : E2E-CI, page login +signalee comme "moche", domaine P4 commande. + +## Ce qui a ete livre (PR mergees sur dev) + +| PR | Objet | +|----|-------| +| #48 | Relooking des pages auth (login / forgot / reset) : `.login-card`, logo reel, `.form-input`, `.btn`. La page login servie via `layout.php` (passe sur `admin.css`). | +| #49 | Design system back-office (direction A+C, lot 1) : shell en grille `sidebar/topbar/content`, tokens (jaune Wakdo doux, ombres, rayons), `.sidebar-item`, `.tile`, `.alert`. | +| #50 | Dashboard donnees reelles (lot 2) : tuiles KPI alimentees par `StatsRepository` (produits dispo, categories, menus, stock critique). | +| #51 | Fix borne : logo d'en-tete centre (etait a droite). | +| #52 | Modal de re-autorisation PIN : le PIN d'action sensible devient un modal au clic (email pre-rempli), au lieu d'un fieldset inline. CSP-safe (`pin-modal.js`). | +| #54 | Humanise les libelles restants : Slug -> Reference, Delta -> Variation, Acteur -> Auteur (vues + messages de validation + tests). | +| #55 | P4 chunk 1a : creation de commande (`OrderRepository::createPending`, RG-5 etapes 1-4, calcul RG-4, numero K+id, idempotence) + migration `service_tag` (chevalet, B4). | +| #56 | Fix : logo reel dans la sidebar (un "W" dessine a la main avait ete introduit ; remplace par `logo.png` + mot-symbole, comme la page login). | +| #57 | P4 chunk 1b : encaissement (`OrderController` `POST /api/orders` + `/{number}/pay`, `OrderRepository::pay`, transition gardee -> paid + decrement de stock atomique RG-T20, idempotence). | +| #58 | CI : retire le job `auto-merge` redondant (bruit HTTP 405). En cours de merge a la redaction. | + +## Decisions notables +- **E2E-CI abandonne.** Le log reel du runner a montre `Cannot connect to the Docker + daemon` : le runner de prod (`forgejo_forgejo_internal`) n'expose pas le socket Docker + aux jobs, et les jobs se repartissent sur plusieurs runners. L'E2E reste manuel via + `tests/e2e/run.sh`. Le`s emulations locales contre le mauvais runner tournaient a blanc. +- **UI pour equipiers non-techniques.** Zero jargon dev dans les ecrans ; le PIN (action + sensible) est un modal au moment de l'action, pas un champ inline ; direction visuelle + "Mix A+C" (neutre + accent jaune parcimonieux). +- **P4 commande, regles tranchees** : + - `order_number` = `K` + id auto-increment (plus simple que `K-AAAA-MM-JJ-NNN`, pas de + compteur jour) ; + - TVA portee par le produit (`product.vat_rate`), independante du mode de service + (la distinction sur place / a emporter est surtout fiscale mais la TVA reste celle + du produit) ; TVA d'une ligne menu = `vat_rate` du burger du menu ; + - flux en **deux etapes** (creation `pending_payment` puis paiement -> `paid` + + decrement), divergence assumee du flux mono-transaction de la spec, alignee sur un + parcours borne reel (ecran paiement). +- **Modele de menu borne** : B1 burger impose, B2 Normal/Maxi, B3 salades-en-menus, + B4 numero de chevalet quand "sur place". +- **Auto-merge** : bascule definitive sur l'auto-merge NATIF Forgejo + (`merge_when_checks_succeed`) ; le job CI `auto-merge` (par label) est retire (#58). + +## Ce que la session a fait remonter (dette / pistes) +1. **PR #53 (ecran Roles humanise) restee ouverte et en conflit avec `dev`.** CI verte sur + sa tete, mais `mergeable: false` (5 commits de retard, 2 d'avance) : les vues Roles et + `admin.css` ont bouge avec le design system (#49) et les relabels (#54). A rebaser sur + `dev` et resoudre avant merge. Dette principale a resorber. +2. **CORS PHP manquant.** Le vhost admin delegue les headers CORS a "un middleware PHP" qui + n'existe pas. Sans bloquer le chunk 1b (endpoints + tests cote serveur), cela bloquera + la borne des qu'elle appellera `/api` cross-origin. Prerequis de la fondation borne. +3. **`pay()` / decrement de stock inerte** tant que les recettes (`product_ingredient`) ne + sont pas seedees : la transition `paid` s'applique, mais aucun `stock_movement` n'est + produit faute de composition. La logique s'active des le seed des recettes. +4. **Logo admin** : un "W" dessine a la main avait remplace le vrai logo ; corrige (#56). + +## Verifications +PHPStan L6 0 erreur ; PHPUnit 284 tests unit (chunk 1b : +7 tests `pay`, +6 tests +controleur) ; php -l propre ; CI Forgejo verte par PR (merge natif squash sur les checks +requis : secret-scan, php-lint, static-tests). + +## Reste a faire (file d'attente) +- **Rebaser + merger PR #53** (ecran Roles humanise) — conflit avec `dev`. +- **Middleware CORS PHP** sur `/api` (prerequis borne cross-origin). +- **Seed des recettes** (`product_ingredient`) -> active le decrement de `pay()`. +- **Fondation borne** : read API (`GET /api/categories|products|menus`) pour consommer le + vrai modele -> B1 (burger impose) + B2 (Normal/Maxi). +- **B3** salades-en-menus (Cesar Classic / Italienne Mozza en menus) ; **B4** etape + chevalet a la borne. +- **Optionnel** : prix en euros vs centimes ; flux d'activite du dashboard (audit). + +## Reprise +`dev` porte tout le livre de la session sauf #53 (ouverte) et #58 (en cours de merge). +Domaine commande : `src/app/Order/` (OrderRepository create + pay), routes anonymes +`/api/orders` dans `src/public/admin/index.php`. diff --git a/docs/journal/README.md b/docs/journal/README.md index 0b8b5f8..73947df 100644 --- a/docs/journal/README.md +++ b/docs/journal/README.md @@ -31,6 +31,8 @@ Les fichiers sont ordonnes chronologiquement par leur nom. | 2026-04-24 | [infra-docker](2026-04-24--infra-docker.md) | Stack Docker complete (compose + 4 services), referentiel RNCP integre, cross-check mappings Cr 4.f | `feat/infra-docker` | | 2026-04-30 | [smoke-test-infra](2026-04-30--smoke-test-infra.md) | Smoke test bout-en-bout sur serveur reel : fusion .env, switch FQDN sur stark.a3n.fr, subnet explicite RFC 1918, fix init cron + healthz | `feat/infra-docker` | | 2026-06-04 | [conception-prodlike-revision](2026-06-04--conception-prodlike-revision.md) | Revue d'alignement P1 + decisions prod-like du modele de donnees (drop commande_event, nommage EN, TVA par produit apres fact-check BOFiP, perso menus/ingredients, allergenes, ~16 entites) | `feat/p1-conception` | +| 2026-06-15 | [p3-throttle-pin-rg-t22](2026-06-15--p3-throttle-pin-rg-t22.md) | P3 securite : throttle du PIN d'action sensible (RG-T22) — design multi-agents + verification adversariale, dimension "utilisateur agissant", entite 22 `pin_throttle` | `feat/p3-pin-throttle` -> `dev` | +| 2026-06-16 | [audit-reel-livrables-p2-p3](2026-06-16--audit-reel-livrables-p2-p3.md) | Verification sur pieces des livrables du 2026-06-15 (sweep 10 dimensions + adversarial) : socle SbD confirme, miss confirmes par gravite (php.ini non deploye, CI sans tests DB, XSS kiosk, liens morts...) et remediations | `docs/journal-audit-2026-06-16` -> `dev` (PR #19/#20/#21) | *Mis a jour a chaque nouvelle entree.* diff --git a/docs/merise/_diagrams/mcd-catalogue.drawio b/docs/merise/_diagrams/mcd-catalogue.drawio deleted file mode 100644 index c9cf3e8..0000000 --- a/docs/merise/_diagrams/mcd-catalogue.drawio +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-catalogue.mmd b/docs/merise/_diagrams/mcd-catalogue.mmd new file mode 100644 index 0000000..698a179 --- /dev/null +++ b/docs/merise/_diagrams/mcd-catalogue.mmd @@ -0,0 +1,51 @@ +erDiagram + category { + int id PK + varchar name + varchar slug + varchar image_path + smallint display_order + tinyint is_active + } + product { + int id PK + int category_id FK + varchar name + text description + int price_cents + smallint vat_rate + varchar image_path + tinyint is_available + smallint display_order + } + menu { + int id PK + int category_id FK + int burger_product_id FK + varchar name + text description + int price_normal_cents + int price_maxi_cents + varchar image_path + tinyint is_available + smallint display_order + } + menu_slot { + int id PK + int menu_id FK + varchar name + enum slot_type + tinyint is_required + smallint display_order + } + menu_slot_option { + int menu_slot_id FK + int product_id FK + } + + category ||--o{ product : "groups" + category ||--o{ menu : "groups" + menu ||--|| product : "anchors (burger_product_id)" + menu ||--o{ menu_slot : "defines_slot" + menu_slot ||--o{ menu_slot_option : "lists" + product ||--o{ menu_slot_option : "is_eligible_for" diff --git a/docs/merise/_diagrams/mcd-catalogue.svg b/docs/merise/_diagrams/mcd-catalogue.svg index c3a2016..38c2764 100644 --- a/docs/merise/_diagrams/mcd-catalogue.svg +++ b/docs/merise/_diagrams/mcd-catalogue.svg @@ -1,4 +1 @@ - - - -
CATEGORIE
id : INT (PK)
libelle : VARCHAR (UNIQUE)
slug : VARCHAR (UNIQUE)
image_path : VARCHAR
ordre : SMALLINT
est_actif : BOOLEAN
CATEGORIEid : INT (PK)...
PRODUIT
id : INT (PK)
categorie_id : INT (FK)
libelle : VARCHAR
description : TEXT
prix_ttc_cents : INT
image_path : VARCHAR
est_disponible : BOOLEAN
ordre : SMALLINT
PRODUITid : INT (PK)...
MENU
id : INT (PK)
categorie_id : INT (FK)
libelle : VARCHAR
description : TEXT
prix_ttc_cents : INT
image_path : VARCHAR
est_disponible : BOOLEAN
ordre : SMALLINT
MENUid : INT (PK)...
MENU_PRODUIT (associative)
menu_id : INT (PK, FK)
produit_id : INT (PK, FK)
role : ENUM
position : SMALLINT
MENU_PRODUIT (associative)menu_id : INT (PK,...
regroupe
regroupe
(0,N)
(0,N)
(1,1)
(1,1)
regroupe
regroupe
(0,N)
(0,N)
(1,1)
(1,1)
fait_partie_de
fait_partie_de
(0,N)
(0,N)
(1,1)
(1,1)
compose
compose
(1,N)
(1,N)
(1,1)
(1,1)
Text is not SVG - cannot display
\ No newline at end of file +

groups

groups

anchors (burger_product_id)

defines_slot

lists

is_eligible_for

category

int

id

PK

varchar

name

varchar

slug

varchar

image_path

smallint

display_order

tinyint

is_active

product

int

id

PK

int

category_id

FK

varchar

name

text

description

int

price_cents

smallint

vat_rate

varchar

image_path

tinyint

is_available

smallint

display_order

menu

int

id

PK

int

category_id

FK

int

burger_product_id

FK

varchar

name

text

description

int

price_normal_cents

int

price_maxi_cents

varchar

image_path

tinyint

is_available

smallint

display_order

menu_slot

int

id

PK

int

menu_id

FK

varchar

name

enum

slot_type

tinyint

is_required

smallint

display_order

menu_slot_option

int

menu_slot_id

FK

int

product_id

FK

\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-commande.drawio b/docs/merise/_diagrams/mcd-commande.drawio deleted file mode 100644 index a6ef277..0000000 --- a/docs/merise/_diagrams/mcd-commande.drawio +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-commande.svg b/docs/merise/_diagrams/mcd-commande.svg deleted file mode 100644 index 2d4be3b..0000000 --- a/docs/merise/_diagrams/mcd-commande.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
COMMANDE
id : INT (PK)
numero : VARCHAR (UNIQUE)
source : ENUM (kiosk|comptoir|drive)
mode_consommation : ENUM (sur_place|a_emporter|drive)
statut : ENUM
total_ht_cents : INT
total_tva_cents : INT
total_ttc_cents : INT
tva_taux_pourmille : SMALLINT
paye_a : DATETIME
USER
id : INT (PK)
(detail dans RBAC)
COMMANDE_EVENT
id : INT (PK)
commande_id : INT (FK)
event_type : ENUM
from_statut : ENUM (NULL)
to_statut : ENUM
user_id : INT (FK, NULL)
payload : JSON (NULL)
created_at : DATETIME
LIGNE_COMMANDE
id : INT (PK)
commande_id : INT (FK)
type_item : ENUM (produit|menu)
produit_id : INT (FK, NULL)
menu_id : INT (FK, NULL)
libelle_snapshot : VARCHAR
prix_unitaire_ttc_cents_snapshot : INT
quantite : SMALLINT
PRODUIT
id : INT (PK)
(detail dans Catalogue)
MENU
id : INT (PK)
(detail dans Catalogue)
contient
(1,N)
(1,1)
refere_si_type_produit
(0,1)
(0,N)
refere_si_type_menu
(0,N)
Polymorphisme
Exactement UNE des deux references est non-nulle.
Discriminateur : type_item ∈ {produit, menu}.
Contrainte CHECK SQL au MLD.
journalise
(1,N)
(1,1)
declenche
(0,N)
(0,1)
(0,1)
Journal d'audit (event sourcing)
Append-only : aucun UPDATE / DELETE applicatif.
user_id NULL si auto-validation kiosk.
ON DELETE CASCADE cote commande_id.
ON DELETE SET NULL cote user_id.
\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-global.drawio b/docs/merise/_diagrams/mcd-global.drawio deleted file mode 100644 index 962f01b..0000000 --- a/docs/merise/_diagrams/mcd-global.drawio +++ /dev/null @@ -1,182 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-global.svg b/docs/merise/_diagrams/mcd-global.svg deleted file mode 100644 index 1d26537..0000000 --- a/docs/merise/_diagrams/mcd-global.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
CATEGORIE
PRODUIT
MENU_PRODUIT
MENU
LIGNE_COMMANDE
COMMANDE
COMMANDE_EVENT
USER
ROLE
ROLE_PERMISSION
PERMISSION
regroupe
(0,N)
(1,1)
regroupe
(0,N)
(1,1)
fait_partie_de
(0,N)
(1,1)
compose
(1,N)
(1,1)
contient
(1,N)
(1,1)
refere_si_type_produit
(0,1)
(0,N)
refere_si_type_menu
(0,1)
(0,N)
a_pour_role
(1,1)
(0,N)
possede
(0,N)
(1,1)
assignee_a
(0,N)
(1,1)
journalise
(1,N)
(1,1)
declenche
(0,N)
(0,1)
\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-ingredients-stock.mmd b/docs/merise/_diagrams/mcd-ingredients-stock.mmd new file mode 100644 index 0000000..dff0eee --- /dev/null +++ b/docs/merise/_diagrams/mcd-ingredients-stock.mmd @@ -0,0 +1,61 @@ +erDiagram + product { + int id PK + varchar name + } + ingredient { + int id PK + varchar name + varchar unit + int stock_quantity + int stock_capacity + smallint pack_size + varchar pack_label + smallint low_stock_pct + smallint critical_stock_pct + tinyint is_active + } + product_ingredient { + int product_id FK + int ingredient_id FK + smallint quantity_normal + smallint quantity_maxi + tinyint is_removable + tinyint is_addable + int extra_price_cents + } + allergen { + int id PK + varchar code + varchar name + text description + } + ingredient_allergen { + int ingredient_id FK + int allergen_id FK + } + customer_order { + int id PK + varchar order_number + } + user { + int id PK + varchar email + } + stock_movement { + int id PK + int ingredient_id FK + enum movement_type + int delta + int order_id FK + int user_id FK + varchar note + } + + product ||--o{ product_ingredient : "is_composed_of" + ingredient ||--o{ product_ingredient : "appears_in" + ingredient ||--o{ ingredient_allergen : "contains" + allergen ||--o{ ingredient_allergen : "is_present_in" + ingredient ||--o{ stock_movement : "decrements" + customer_order |o--o{ stock_movement : "triggers" + user |o--o{ stock_movement : "logs" diff --git a/docs/merise/_diagrams/mcd-ingredients-stock.svg b/docs/merise/_diagrams/mcd-ingredients-stock.svg new file mode 100644 index 0000000..615a975 --- /dev/null +++ b/docs/merise/_diagrams/mcd-ingredients-stock.svg @@ -0,0 +1 @@ +

is_composed_of

appears_in

contains

is_present_in

decrements

triggers

logs

product

int

id

PK

varchar

name

ingredient

int

id

PK

varchar

name

varchar

unit

int

stock_quantity

int

stock_capacity

smallint

pack_size

varchar

pack_label

smallint

low_stock_pct

smallint

critical_stock_pct

tinyint

is_active

product_ingredient

int

product_id

FK

int

ingredient_id

FK

smallint

quantity_normal

smallint

quantity_maxi

tinyint

is_removable

tinyint

is_addable

int

extra_price_cents

allergen

int

id

PK

varchar

code

varchar

name

text

description

ingredient_allergen

int

ingredient_id

FK

int

allergen_id

FK

customer_order

int

id

PK

varchar

order_number

user

int

id

PK

varchar

email

stock_movement

int

id

PK

int

ingredient_id

FK

enum

movement_type

int

delta

int

order_id

FK

int

user_id

FK

varchar

note

\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-order.mmd b/docs/merise/_diagrams/mcd-order.mmd new file mode 100644 index 0000000..be06a07 --- /dev/null +++ b/docs/merise/_diagrams/mcd-order.mmd @@ -0,0 +1,67 @@ +erDiagram + customer_order { + int id PK + varchar order_number + varchar idempotency_key + enum source + int acting_user_id FK + enum service_mode + enum status + int total_ht_cents + int total_vat_cents + int total_ttc_cents + datetime paid_at + datetime delivered_at + datetime cancelled_at + } + order_item { + int id PK + int order_id FK + enum item_type + int product_id FK + int menu_id FK + enum format + varchar label_snapshot + int unit_price_cents_snapshot + smallint vat_rate_snapshot + smallint quantity + } + order_item_selection { + int id PK + int order_item_id FK + int menu_slot_id FK + int product_id FK + varchar label_snapshot + } + order_item_modifier { + int id PK + int order_item_id FK + int ingredient_id FK + enum action + int extra_price_cents + } + product { + int id PK + varchar name + } + menu { + int id PK + varchar name + } + menu_slot { + int id PK + varchar name + } + ingredient { + int id PK + varchar name + } + + customer_order ||--o{ order_item : "contains" + order_item }o--o| product : "references_product" + order_item }o--o| menu : "references_menu" + order_item ||--o{ order_item_selection : "fills_slot" + order_item ||--o{ order_item_modifier : "modifies_ingredient" + menu_slot ||--o{ order_item_selection : "slot_filled_by" + product ||--o{ order_item_selection : "chosen_for_slot" + ingredient ||--o{ order_item_modifier : "modified_by" diff --git a/docs/merise/_diagrams/mcd-order.svg b/docs/merise/_diagrams/mcd-order.svg new file mode 100644 index 0000000..aeb5ac7 --- /dev/null +++ b/docs/merise/_diagrams/mcd-order.svg @@ -0,0 +1 @@ +

contains

references_product

references_menu

fills_slot

modifies_ingredient

slot_filled_by

chosen_for_slot

modified_by

customer_order

int

id

PK

varchar

order_number

varchar

idempotency_key

enum

source

int

acting_user_id

FK

enum

service_mode

enum

status

int

total_ht_cents

int

total_vat_cents

int

total_ttc_cents

datetime

paid_at

datetime

delivered_at

datetime

cancelled_at

order_item

int

id

PK

int

order_id

FK

enum

item_type

int

product_id

FK

int

menu_id

FK

enum

format

varchar

label_snapshot

int

unit_price_cents_snapshot

smallint

vat_rate_snapshot

smallint

quantity

order_item_selection

int

id

PK

int

order_item_id

FK

int

menu_slot_id

FK

int

product_id

FK

varchar

label_snapshot

order_item_modifier

int

id

PK

int

order_item_id

FK

int

ingredient_id

FK

enum

action

int

extra_price_cents

product

int

id

PK

varchar

name

menu

int

id

PK

varchar

name

menu_slot

int

id

PK

varchar

name

ingredient

int

id

PK

varchar

name

\ No newline at end of file diff --git a/docs/merise/_diagrams/mcd-rbac.drawio b/docs/merise/_diagrams/mcd-rbac.drawio deleted file mode 100644 index 31e109d..0000000 --- a/docs/merise/_diagrams/mcd-rbac.drawio +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mcd-rbac.mmd b/docs/merise/_diagrams/mcd-rbac.mmd new file mode 100644 index 0000000..f3bb49a --- /dev/null +++ b/docs/merise/_diagrams/mcd-rbac.mmd @@ -0,0 +1,64 @@ +erDiagram + user { + int id PK + varchar email + varchar password_hash + varchar pin_hash + varchar first_name + varchar last_name + int role_id FK + tinyint is_active + datetime last_login_at + smallint failed_login_attempts + datetime lockout_until + datetime anonymized_at + } + role { + int id PK + varchar code + varchar label + text description + varchar default_route + enum order_source + tinyint is_active + } + role_visible_source { + int role_id FK + enum source + } + permission { + int id PK + varchar code + varchar label + text description + } + role_permission { + int role_id FK + int permission_id FK + } + audit_log { + int id PK + int actor_user_id FK + int actor_role_id FK + varchar action_code + varchar entity_type + int entity_id + varchar summary + json details + datetime created_at + } + login_throttle { + int id PK + varchar ip_address UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } + + user }o--|| role : "holds" + role ||--o{ role_visible_source : "sees_source" + role ||--o{ role_permission : "grants" + permission ||--o{ role_permission : "granted_to" + user |o--o{ audit_log : "performs" + role |o--o{ audit_log : "context_of" diff --git a/docs/merise/_diagrams/mcd-rbac.svg b/docs/merise/_diagrams/mcd-rbac.svg index e92d624..688b27f 100644 --- a/docs/merise/_diagrams/mcd-rbac.svg +++ b/docs/merise/_diagrams/mcd-rbac.svg @@ -1,4 +1 @@ - - - -
USER
id : INT (PK)
email : VARCHAR (UNIQUE, RFC 5321)
password_hash : VARCHAR (argon2id)
nom : VARCHAR
prenom : VARCHAR
role_id : INT (FK)
est_actif : BOOLEAN
last_login_at : DATETIME
ROLE
id : INT (PK)
code : VARCHAR (UNIQUE)
libelle : VARCHAR
description : TEXT
est_actif : BOOLEAN
PERMISSION
id : INT (PK)
code : VARCHAR (UNIQUE, resource.action)
libelle : VARCHAR
description : TEXT
ROLE_PERMISSION (associative)
role_id : INT (PK, FK)
permission_id : INT (PK, FK)
a_pour_role
(1,1)
(0,N)
possede
(0,N)
(1,1)
assignee_a
(0,N)
(1,1)
\ No newline at end of file +

holds

sees_source

grants

granted_to

performs

context_of

user

int

id

PK

varchar

email

varchar

password_hash

varchar

pin_hash

varchar

first_name

varchar

last_name

int

role_id

FK

tinyint

is_active

datetime

last_login_at

smallint

failed_login_attempts

datetime

lockout_until

datetime

anonymized_at

role

int

id

PK

varchar

code

varchar

label

text

description

varchar

default_route

enum

order_source

tinyint

is_active

role_visible_source

int

role_id

FK

enum

source

permission

int

id

PK

varchar

code

varchar

label

text

description

role_permission

int

role_id

FK

int

permission_id

FK

audit_log

int

id

PK

int

actor_user_id

FK

int

actor_role_id

FK

varchar

action_code

varchar

entity_type

int

entity_id

varchar

summary

json

details

datetime

created_at

login_throttle

int

id

PK

varchar

ip_address

UK

smallint

failed_attempts

datetime

window_started_at

datetime

lockout_until

datetime

last_attempt_at

\ No newline at end of file diff --git a/docs/merise/_diagrams/mld-catalogue.drawio b/docs/merise/_diagrams/mld-catalogue.drawio deleted file mode 100644 index e292dbc..0000000 --- a/docs/merise/_diagrams/mld-catalogue.drawio +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mld-catalogue.mmd b/docs/merise/_diagrams/mld-catalogue.mmd new file mode 100644 index 0000000..7519fdb --- /dev/null +++ b/docs/merise/_diagrams/mld-catalogue.mmd @@ -0,0 +1,46 @@ +erDiagram + category { + int id PK + varchar name UK + varchar slug UK + smallint display_order + tinyint is_active + } + product { + int id PK + int category_id FK + varchar name + int price_cents + smallint vat_rate + tinyint is_available + smallint display_order + } + menu { + int id PK + int category_id FK + int burger_product_id FK + varchar name + int price_normal_cents + int price_maxi_cents + tinyint is_available + smallint display_order + } + menu_slot { + int id PK + int menu_id FK + varchar name + enum slot_type + tinyint is_required + smallint display_order + } + menu_slot_option { + int menu_slot_id PK,FK + int product_id PK,FK + } + + category ||--o{ product : "category_id (RESTRICT)" + category ||--o{ menu : "category_id (RESTRICT)" + product ||--o{ menu : "burger_product_id (RESTRICT)" + menu ||--o{ menu_slot : "menu_id (CASCADE)" + menu_slot ||--o{ menu_slot_option : "menu_slot_id (CASCADE)" + product ||--o{ menu_slot_option : "product_id (RESTRICT)" diff --git a/docs/merise/_diagrams/mld-catalogue.svg b/docs/merise/_diagrams/mld-catalogue.svg new file mode 100644 index 0000000..7d501b3 --- /dev/null +++ b/docs/merise/_diagrams/mld-catalogue.svg @@ -0,0 +1 @@ +

category_id (RESTRICT)

category_id (RESTRICT)

burger_product_id (RESTRICT)

menu_id (CASCADE)

menu_slot_id (CASCADE)

product_id (RESTRICT)

category

int

id

PK

varchar

name

UK

varchar

slug

UK

smallint

display_order

tinyint

is_active

product

int

id

PK

int

category_id

FK

varchar

name

int

price_cents

smallint

vat_rate

tinyint

is_available

smallint

display_order

menu

int

id

PK

int

category_id

FK

int

burger_product_id

FK

varchar

name

int

price_normal_cents

int

price_maxi_cents

tinyint

is_available

smallint

display_order

menu_slot

int

id

PK

int

menu_id

FK

varchar

name

enum

slot_type

tinyint

is_required

smallint

display_order

menu_slot_option

int

menu_slot_id

PK,FK

int

product_id

PK,FK

\ No newline at end of file diff --git a/docs/merise/_diagrams/mld-commande.drawio b/docs/merise/_diagrams/mld-commande.drawio deleted file mode 100644 index 1a47a5d..0000000 --- a/docs/merise/_diagrams/mld-commande.drawio +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mld-ingredients-stock.mmd b/docs/merise/_diagrams/mld-ingredients-stock.mmd new file mode 100644 index 0000000..556f22f --- /dev/null +++ b/docs/merise/_diagrams/mld-ingredients-stock.mmd @@ -0,0 +1,59 @@ +erDiagram + ingredient { + int id PK + varchar name UK + varchar unit + int stock_quantity + int stock_capacity + smallint pack_size + smallint low_stock_pct + smallint critical_stock_pct + tinyint is_active + } + product_ingredient { + int product_id PK,FK + int ingredient_id PK,FK + smallint quantity_normal + smallint quantity_maxi + tinyint is_removable + tinyint is_addable + int extra_price_cents + } + allergen { + int id PK + varchar code UK + varchar name + } + ingredient_allergen { + int ingredient_id PK,FK + int allergen_id PK,FK + } + stock_movement { + int id PK + int ingredient_id FK + enum movement_type + int delta + int order_id FK + int user_id FK + varchar note + } + product { + int id PK + varchar name + } + customer_order { + int id PK + varchar order_number + } + user { + int id PK + varchar email + } + + product ||--o{ product_ingredient : "product_id (CASCADE)" + ingredient ||--o{ product_ingredient : "ingredient_id (RESTRICT)" + ingredient ||--o{ ingredient_allergen : "ingredient_id (CASCADE)" + allergen ||--o{ ingredient_allergen : "allergen_id (RESTRICT)" + ingredient ||--o{ stock_movement : "ingredient_id (RESTRICT)" + customer_order ||--o{ stock_movement : "order_id (SET NULL, nullable)" + user ||--o{ stock_movement : "user_id (SET NULL, nullable)" diff --git a/docs/merise/_diagrams/mld-ingredients-stock.svg b/docs/merise/_diagrams/mld-ingredients-stock.svg new file mode 100644 index 0000000..22a377f --- /dev/null +++ b/docs/merise/_diagrams/mld-ingredients-stock.svg @@ -0,0 +1 @@ +

product_id (CASCADE)

ingredient_id (RESTRICT)

ingredient_id (CASCADE)

allergen_id (RESTRICT)

ingredient_id (RESTRICT)

order_id (SET NULL, nullable)

user_id (SET NULL, nullable)

ingredient

int

id

PK

varchar

name

UK

varchar

unit

int

stock_quantity

int

stock_capacity

smallint

pack_size

smallint

low_stock_pct

smallint

critical_stock_pct

tinyint

is_active

product_ingredient

int

product_id

PK,FK

int

ingredient_id

PK,FK

smallint

quantity_normal

smallint

quantity_maxi

tinyint

is_removable

tinyint

is_addable

int

extra_price_cents

allergen

int

id

PK

varchar

code

UK

varchar

name

ingredient_allergen

int

ingredient_id

PK,FK

int

allergen_id

PK,FK

stock_movement

int

id

PK

int

ingredient_id

FK

enum

movement_type

int

delta

int

order_id

FK

int

user_id

FK

varchar

note

product

int

id

PK

varchar

name

customer_order

int

id

PK

varchar

order_number

user

int

id

PK

varchar

email

\ No newline at end of file diff --git a/docs/merise/_diagrams/mld-order.mmd b/docs/merise/_diagrams/mld-order.mmd new file mode 100644 index 0000000..f6ec1e3 --- /dev/null +++ b/docs/merise/_diagrams/mld-order.mmd @@ -0,0 +1,72 @@ +erDiagram + customer_order { + int id PK + varchar order_number UK + varchar idempotency_key UK + enum source + int acting_user_id FK + enum service_mode + enum status + int total_ht_cents + int total_vat_cents + int total_ttc_cents + datetime paid_at + datetime delivered_at + datetime cancelled_at + } + order_item { + int id PK + int order_id FK + enum item_type + int product_id FK + int menu_id FK + enum format + varchar label_snapshot + int unit_price_cents_snapshot + smallint vat_rate_snapshot + smallint quantity + } + order_item_selection { + int id PK + int order_item_id FK + int menu_slot_id FK + int product_id FK + varchar label_snapshot + } + order_item_modifier { + int id PK + int order_item_id FK + int ingredient_id FK + enum action + int extra_price_cents + } + user { + int id PK + varchar email + } + product { + int id PK + varchar name + } + menu { + int id PK + varchar name + } + menu_slot { + int id PK + varchar name + } + ingredient { + int id PK + varchar name + } + + user ||--o{ customer_order : "acting_user_id (SET NULL, nullable)" + customer_order ||--o{ order_item : "order_id (CASCADE)" + product ||--o{ order_item : "product_id (RESTRICT, polymorphic)" + menu ||--o{ order_item : "menu_id (RESTRICT, polymorphic)" + order_item ||--o{ order_item_selection : "order_item_id (CASCADE)" + menu_slot ||--o{ order_item_selection : "menu_slot_id (RESTRICT)" + product ||--o{ order_item_selection : "product_id (RESTRICT)" + order_item ||--o{ order_item_modifier : "order_item_id (CASCADE)" + ingredient ||--o{ order_item_modifier : "ingredient_id (RESTRICT)" diff --git a/docs/merise/_diagrams/mld-order.svg b/docs/merise/_diagrams/mld-order.svg new file mode 100644 index 0000000..3cbc2f8 --- /dev/null +++ b/docs/merise/_diagrams/mld-order.svg @@ -0,0 +1 @@ +

acting_user_id (SET NULL, nullable)

order_id (CASCADE)

product_id (RESTRICT, polymorphic)

menu_id (RESTRICT, polymorphic)

order_item_id (CASCADE)

menu_slot_id (RESTRICT)

product_id (RESTRICT)

order_item_id (CASCADE)

ingredient_id (RESTRICT)

customer_order

int

id

PK

varchar

order_number

UK

varchar

idempotency_key

UK

enum

source

int

acting_user_id

FK

enum

service_mode

enum

status

int

total_ht_cents

int

total_vat_cents

int

total_ttc_cents

datetime

paid_at

datetime

delivered_at

datetime

cancelled_at

order_item

int

id

PK

int

order_id

FK

enum

item_type

int

product_id

FK

int

menu_id

FK

enum

format

varchar

label_snapshot

int

unit_price_cents_snapshot

smallint

vat_rate_snapshot

smallint

quantity

order_item_selection

int

id

PK

int

order_item_id

FK

int

menu_slot_id

FK

int

product_id

FK

varchar

label_snapshot

order_item_modifier

int

id

PK

int

order_item_id

FK

int

ingredient_id

FK

enum

action

int

extra_price_cents

user

int

id

PK

varchar

email

product

int

id

PK

varchar

name

menu

int

id

PK

varchar

name

menu_slot

int

id

PK

varchar

name

ingredient

int

id

PK

varchar

name

\ No newline at end of file diff --git a/docs/merise/_diagrams/mld-rbac.drawio b/docs/merise/_diagrams/mld-rbac.drawio deleted file mode 100644 index 0922801..0000000 --- a/docs/merise/_diagrams/mld-rbac.drawio +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/merise/_diagrams/mld-rbac.mmd b/docs/merise/_diagrams/mld-rbac.mmd new file mode 100644 index 0000000..2bc76b8 --- /dev/null +++ b/docs/merise/_diagrams/mld-rbac.mmd @@ -0,0 +1,61 @@ +erDiagram + role { + int id PK + varchar code UK + varchar label + varchar default_route + enum order_source + tinyint is_active + } + user { + int id PK + varchar email UK + varchar password_hash + varchar pin_hash + varchar first_name + varchar last_name + int role_id FK + tinyint is_active + smallint failed_login_attempts + datetime lockout_until + datetime anonymized_at + } + role_visible_source { + int role_id PK,FK + enum source PK + } + permission { + int id PK + varchar code UK + varchar label + } + role_permission { + int role_id PK,FK + int permission_id PK,FK + } + audit_log { + int id PK + int actor_user_id FK + int actor_role_id FK + varchar action_code + varchar entity_type + int entity_id + varchar summary + json details + datetime created_at + } + login_throttle { + int id PK + varchar ip_address UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } + + role ||--o{ user : "role_id (RESTRICT)" + role ||--o{ role_visible_source : "role_id (CASCADE)" + role ||--o{ role_permission : "role_id (CASCADE)" + permission ||--o{ role_permission : "permission_id (CASCADE)" + user ||--o{ audit_log : "actor_user_id (SET NULL, nullable)" + role ||--o{ audit_log : "actor_role_id (SET NULL, nullable)" diff --git a/docs/merise/_diagrams/mld-rbac.svg b/docs/merise/_diagrams/mld-rbac.svg new file mode 100644 index 0000000..ad68a9f --- /dev/null +++ b/docs/merise/_diagrams/mld-rbac.svg @@ -0,0 +1 @@ +

role_id (RESTRICT)

role_id (CASCADE)

role_id (CASCADE)

permission_id (CASCADE)

actor_user_id (SET NULL, nullable)

actor_role_id (SET NULL, nullable)

role

int

id

PK

varchar

code

UK

varchar

label

varchar

default_route

enum

order_source

tinyint

is_active

user

int

id

PK

varchar

email

UK

varchar

password_hash

varchar

pin_hash

varchar

first_name

varchar

last_name

int

role_id

FK

tinyint

is_active

smallint

failed_login_attempts

datetime

lockout_until

datetime

anonymized_at

role_visible_source

int

role_id

PK,FK

enum

source

PK

permission

int

id

PK

varchar

code

UK

varchar

label

role_permission

int

role_id

PK,FK

int

permission_id

PK,FK

audit_log

int

id

PK

int

actor_user_id

FK

int

actor_role_id

FK

varchar

action_code

varchar

entity_type

int

entity_id

varchar

summary

json

details

datetime

created_at

login_throttle

int

id

PK

varchar

ip_address

UK

smallint

failed_attempts

datetime

window_started_at

datetime

lockout_until

datetime

last_attempt_at

\ No newline at end of file diff --git a/docs/merise/dictionary.md b/docs/merise/dictionary.md index 0dcbf80..e45529d 100644 --- a/docs/merise/dictionary.md +++ b/docs/merise/dictionary.md @@ -1,417 +1,441 @@ -# Data Dictionary — Wakdo +# Dictionnaire de Donnees — Wakdo -**Merise phase** : P1 - Conception, step 1 (data dictionary first, mantra #33) -**Version** : v0.2 — prod-like, 19 entities -**Date** : 2026-06-04 -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 1 (dictionnaire de donnees d'abord, mantra #33) +**Version** : v0.3 — prod-like, 22 entites (19 prod-like + couche security-by-design, incl. les entites `login_throttle` et `pin_throttle`) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design en cours (voir note 13) +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose +## 1. Objectif -This dictionary lists **all data entities** identified for Wakdo, with their attributes, -types, constraints, and sources. It serves as the basis for the MCD (entities + relations), -then the MLD (relational mapping), then the DDL (SQL CREATE TABLE). +Ce dictionnaire liste **toutes les entites de donnees** identifiees pour Wakdo, avec leurs attributs, +types, contraintes et sources. Il sert de base au MCD (entites + relations), +puis au MLD (mapping relationnel), puis au DDL (SQL CREATE TABLE). -**Methodology**: bottom-up derivation from available sources: -- **School source**: `docs/merise/_sources/categories.json` + `produits.json` - (66 products, 9 categories) -- **Business brief**: `docs/PROJECT_CONTEXT.md` (menu composition, order flow, RBAC, - service modes) -- **Mockup**: `docs/design/maquette-borne.pdf` (kiosk UX, visible screens) +**Methodologie** : derivation bottom-up depuis les sources disponibles : +- **Source ecole** : `docs/merise/_sources/categories.json` + `produits.json` + (66 produits, 9 categories) +- **Brief metier** : `docs/PROJECT_CONTEXT.md` (composition du menu, flux de commande, RBAC, + modes de service) +- **Maquette** : `docs/design/maquette-borne.pdf` (UX borne, ecrans visibles) -All deviations between school source and final model are documented in the -"Modeling notes" section at the bottom of this document. +Tous les ecarts entre la source ecole et le modele final sont documentes dans la +section "Notes de modelisation" en bas de ce document. -For the entity-relationship diagram and cardinality justifications, see [`mcd.md`](mcd.md). -This dictionary does not duplicate that view to avoid diverging sources of truth. +Pour le diagramme entite-relation et les justifications de cardinalite, voir [`mcd.md`](mcd.md). +Ce dictionnaire ne duplique pas cette vue afin d'eviter des sources de verite divergentes. --- -## 2. General conventions +## 2. Conventions generales -### Naming +### Nommage -- **Tables**: `snake_case`, singular (e.g., `category`, `product`, `customer_order`). - Singular reflects the perspective "1 row = 1 instance of the entity" (standard relational - convention). Application code (PHP, JS) uses these names as-is via ORM mapping. -- **Columns**: `snake_case`. Typical suffixes: `_id` (FK), `_at` (timestamp), - `_cents` (monetary amount in integer cents), `_path` (file path), `_rate` (rate or - fraction stored as per-mille integer). -- **Primary keys**: column `id` (INT UNSIGNED AUTO_INCREMENT). No composite PK except - on pure join tables. -- **Foreign keys**: `_id` (e.g., `category_id` in `product`). -- **ENUM values**: English, snake_case (e.g., `pending_payment`, `dine_in`, `kiosk`). -- **Code-facing strings** (ENUM, permission codes, role codes): English only, consistent - across DB, PHP, and JSON API. +- **Tables** : `snake_case`, singulier (ex. `category`, `product`, `customer_order`). + Le singulier reflete la perspective "1 ligne = 1 instance de l'entite" (convention relationnelle + standard). Le code applicatif (PHP, JS) utilise ces noms tels quels via le mapping ORM. +- **Colonnes** : `snake_case`. Suffixes typiques : `_id` (FK), `_at` (timestamp), + `_cents` (montant monetaire en centimes entiers), `_path` (chemin de fichier), `_rate` (taux ou + fraction stocke en entier pour-mille). +- **Cles primaires** : colonne `id` (INT UNSIGNED AUTO_INCREMENT). Pas de PK composite sauf + sur les tables de jointure pures. +- **Cles etrangeres** : `_id` (ex. `category_id` dans `product`). +- **Valeurs ENUM** : anglais, snake_case (ex. `pending_payment`, `dine_in`, `kiosk`). +- **Chaines cote code** (ENUM, codes de permission, codes de role) : anglais uniquement, coherentes + entre la BDD, PHP et l'API JSON. -### Default types +### Types par defaut -| Category | MariaDB type | Justification | +| Categorie | Type MariaDB | Justification | |---|---|---| -| Identifiers | `INT UNSIGNED AUTO_INCREMENT` | 4 billion ids — sufficient for this project | -| Short labels | `VARCHAR(120)` | Covers most product names (max observed: 41 chars in school source) | -| Descriptions | `TEXT` | Variable length, no strict limit | -| Monetary amounts | `INT UNSIGNED` (cents) | Avoids FLOAT rounding bugs (see note 1) | -| Booleans | `TINYINT(1)` | MariaDB convention for `BOOLEAN` (alias) | -| Timestamps | `DATETIME` | Human-readable, timezone handled at app layer | -| Enumerations | `ENUM('a','b','c')` | DBMS-level constraint, readable (see note 2) | -| File paths | `VARCHAR(255)` | Standard POSIX path length limit | +| Identifiants | `INT UNSIGNED AUTO_INCREMENT` | 4 milliards d'ids — suffisant pour ce projet | +| Libelles courts | `VARCHAR(120)` | Couvre la plupart des noms de produits (max observe : 41 caracteres dans la source ecole) | +| Descriptions | `TEXT` | Longueur variable, sans limite stricte | +| Montants monetaires | `INT UNSIGNED` (cents) | Evite les bugs d'arrondi FLOAT (voir note 1) | +| Booleens | `TINYINT(1)` | Convention MariaDB pour `BOOLEAN` (alias) | +| Horodatages | `DATETIME` | Lisible par l'humain, fuseau horaire gere au niveau applicatif | +| Enumerations | `ENUM('a','b','c')` | Contrainte au niveau SGBD, lisible (voir note 2) | +| Chemins de fichiers | `VARCHAR(255)` | Limite standard de longueur de chemin POSIX | -### Charset and collation +### Charset et collation -- **Charset**: `utf8mb4` (RFC 3629 — real 4-byte UTF-8, supports emoji and Asian characters). - MariaDB handles `utf8mb4` natively. -- **Collation**: `utf8mb4_unicode_ci` (case-insensitive, Unicode-compliant comparison). +- **Charset** : `utf8mb4` (RFC 3629 — vrai UTF-8 sur 4 octets, supporte emoji et caracteres asiatiques). + MariaDB gere `utf8mb4` nativement. +- **Collation** : `utf8mb4_unicode_ci` (insensible a la casse, comparaison conforme Unicode). -### Audit fields (present on all business tables except pure join tables) +### Champs d'audit (presents sur toutes les tables metier sauf les tables de jointure pures) -| Column | Type | Default | Role | +| Colonne | Type | Default | Role | |---|---|---|---| -| `created_at` | `DATETIME` | `CURRENT_TIMESTAMP` | Creation timestamp, written once at insert | -| `updated_at` | `DATETIME` | `CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | Last modification timestamp, auto-updated | +| `created_at` | `DATETIME` | `CURRENT_TIMESTAMP` | Timestamp de creation, ecrit une fois a l'insertion | +| `updated_at` | `DATETIME` | `CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | Timestamp de derniere modification, mis a jour automatiquement | ### Soft delete -No generalized soft delete. Entities that can be temporarily deactivated carry an -`is_active` or `is_available` boolean column. Hard `DELETE` remains possible but is -reserved for admin operations with prior backup. +Pas de soft delete generalise. Les entites qui peuvent etre temporairement desactivees portent une +colonne booleenne `is_active` ou `is_available`. Le `DELETE` dur reste possible mais est +reserve aux operations admin avec sauvegarde prealable. --- -## 3. Entities +## 3. Entites ### 3.1 `category` -Business grouping of products and menus for display on the kiosk. +Regroupement metier de produits et de menus pour l'affichage sur la borne. -| Attribute | Type | NULL | Default | Constraint | School source | Notes | +| Attribut | Type | NULL | Default | Contrainte | Source ecole | Notes | |---|---|---|---|---|---|---| -| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-9) | same as source | -| `name` | VARCHAR(60) | NO | — | UNIQUE | `title` | renamed from `title` | -| `slug` | VARCHAR(60) | NO | — | UNIQUE | derived from `title` (kebab-case lowercase) | used for URL `/api/categories/burgers` | -| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | relative path, see note 8 | -| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (added) | display order on kiosk, adjustable from admin | -| `is_active` | TINYINT(1) | NO | 1 | — | (added) | deactivate without deleting | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-9) | identique a la source | +| `name` | VARCHAR(60) | NO | — | UNIQUE | `title` | renomme depuis `title` | +| `slug` | VARCHAR(60) | NO | — | UNIQUE | derive de `title` (kebab-case minuscule) | utilise pour l'URL `/api/categories/burgers` | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | chemin relatif, voir note 8 | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | ordre d'affichage sur la borne, ajustable depuis l'admin | +| `is_active` | TINYINT(1) | NO | 1 | — | (ajoute) | desactiver sans supprimer | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit | -**Examples**: `menus`, `drinks`, `burgers`, `fries`, `snacks`, `wraps`, `salads`, -`desserts`, `sauces`. Volume: 9 rows at init (seed from `categories.json`). +**Exemples** : `menus`, `drinks`, `burgers`, `fries`, `snacks`, `wraps`, `salads`, +`desserts`, `sauces`. Volume : 9 lignes a l'init (seed depuis `categories.json`). --- ### 3.2 `product` -A single sellable item, available a la carte or as a component in a menu slot. +Un article vendable unique, disponible a la carte ou comme composant dans un slot de menu. -| Attribute | Type | NULL | Default | Constraint | School source | Notes | +| Attribut | Type | NULL | Default | Contrainte | Source ecole | Notes | |---|---|---|---|---|---|---| -| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` | same as source | -| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | (derived from JSON object key) | | -| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | renamed from `nom` | -| `description` | TEXT | YES | NULL | — | (added) | populated later via admin | -| `price_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` (FLOAT) | FLOAT -> INT cents conversion at seed (see note 1) | -| `vat_rate` | SMALLINT UNSIGNED | NO | 100 | CHECK IN (55, 100) | (added) | VAT rate in per-mille: 100 = 10%, 55 = 5.5%. Default 10%. See note 9 | -| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | relative path, see note 8 | -| `is_available` | TINYINT(1) | NO | 1 | — | (added) | manual availability toggle from admin | -| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (added) | display order within category | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` | identique a la source | +| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | (derive de la cle d'objet JSON) | | +| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | renomme depuis `nom` | +| `description` | TEXT | YES | NULL | — | (ajoute) | renseigne plus tard via l'admin | +| `price_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` (FLOAT) | conversion FLOAT -> INT centimes au seed (voir note 1) | +| `vat_rate` | SMALLINT UNSIGNED | NO | 100 | CHECK IN (55, 100) | (ajoute) | taux de TVA en pour-mille : 100 = 10%, 55 = 5,5%. Defaut 10%. Voir note 9 | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | chemin relatif, voir note 8 | +| `is_available` | TINYINT(1) | NO | 1 | — | (ajoute) | bascule de disponibilite manuelle depuis l'admin | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | ordre d'affichage au sein de la categorie | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit | -**Volume**: ~53 rows at init (66 rows in `produits.json` minus 13 menus moved to `menu`). +**Volume** : ~53 lignes a l'init (66 lignes dans `produits.json` moins 13 menus deplaces vers `menu`). --- ### 3.3 `menu` -Fixed-price combo built around a specific burger, with customer-selectable slots -(drink, side, sauce). Two price tiers: Normal and Maxi. +Combo a prix fixe construit autour d'un burger specifique, avec des slots selectionnables par le client +(boisson, accompagnement, sauce). Deux paliers de prix : Normal et Maxi. -| Attribute | Type | NULL | Default | Constraint | School source | Notes | +| Attribut | Type | NULL | Default | Contrainte | Source ecole | Notes | |---|---|---|---|---|---|---| -| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-13 in `menus` category) | | -| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | implicit (category `menus`) | | -| `burger_product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | (added) | the fixed burger that anchors this menu; drives ingredient customization | -| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | e.g., "Menu Le 280" | -| `description` | TEXT | YES | NULL | — | (added) | | -| `price_normal_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` | Normal format price. Replaces single `prix_ttc_cents`. | -| `price_maxi_cents` | INT UNSIGNED | NO | — | CHECK > 0 | (added) | Maxi format price (~+150 cents vs normal; see note 7) | -| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | typically reuses the burger image | -| `is_available` | TINYINT(1) | NO | 1 | — | (added) | | -| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (added) | | +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | `id` (1-13 dans la categorie `menus`) | | +| `category_id` | INT UNSIGNED | NO | — | FK -> `category(id)`, ON DELETE RESTRICT | implicite (categorie `menus`) | | +| `burger_product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | (ajoute) | le burger fixe qui ancre ce menu ; pilote la personnalisation des ingredients | +| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | ex. "Menu Le 280" | +| `description` | TEXT | YES | NULL | — | (ajoute) | | +| `price_normal_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` | prix format Normal. Remplace le `prix_ttc_cents` unique. | +| `price_maxi_cents` | INT UNSIGNED | NO | — | CHECK > 0 | (ajoute) | prix format Maxi (~+150 centimes vs normal ; voir note 7) | +| `image_path` | VARCHAR(255) | YES | NULL | — | `image` | reutilise generalement l'image du burger | +| `is_available` | TINYINT(1) | NO | 1 | — | (ajoute) | | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | (ajoute) | | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | — | audit | -**Volume**: 13 rows at init. Replaces the old fixed-composition `menu_produit` model. +**Volume** : 13 lignes a l'init. Remplace l'ancien modele `menu_produit` a composition fixe. --- ### 3.4 `menu_slot` -A selectable slot within a menu (e.g., "drink slot", "side slot", "sauce slot"). -Each slot constrains which products the customer can choose from, expressed via -the join table `menu_slot_option`. +Un slot selectionnable au sein d'un menu (ex. "slot boisson", "slot accompagnement", "slot sauce"). +Chaque slot contraint les produits parmi lesquels le client peut choisir, exprimes via +la table de jointure `menu_slot_option`. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `menu_id` | INT UNSIGNED | NO | — | FK -> `menu(id)`, ON DELETE CASCADE | a slot belongs to exactly one menu | -| `name` | VARCHAR(80) | NO | — | — | e.g., "Drink", "Side", "Sauce" | -| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | — | — | semantic role of this slot | -| `is_required` | TINYINT(1) | NO | 1 | — | whether the customer must fill this slot | -| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | order of display in the menu builder | +| `menu_id` | INT UNSIGNED | NO | — | FK -> `menu(id)`, ON DELETE CASCADE | un slot appartient a exactement un menu | +| `name` | VARCHAR(80) | NO | — | — | ex. "Drink", "Side", "Sauce" | +| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | — | — | role semantique de ce slot | +| `is_required` | TINYINT(1) | NO | 1 | — | indique si le client doit remplir ce slot | +| `display_order` | SMALLINT UNSIGNED | NO | 0 | — | ordre d'affichage dans le constructeur de menu | -**No audit fields**: a slot is part of menu definition; created and updated with the menu. -**Composite index**: `(menu_id, display_order)`. +**Pas de champs d'audit** : un slot fait partie de la definition du menu ; cree et mis a jour avec le menu. +**Index composite** : `(menu_id, display_order)`. --- ### 3.5 `menu_slot_option` -Eligible products for a given menu slot. Pure join table. +Produits eligibles pour un slot de menu donne. Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `menu_slot_id` | INT UNSIGNED | NO | — | FK -> `menu_slot(id)`, ON DELETE CASCADE | | -| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | RESTRICT: removing a product must not silently break menus | +| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | RESTRICT : retirer un produit ne doit pas casser silencieusement les menus | -**Primary key**: composite `(menu_slot_id, product_id)`. +**Cle primaire** : composite `(menu_slot_id, product_id)`. -**Volume**: ~3-5 options per slot, ~3 slots per menu, 13 menus = ~120-200 rows at init. +**Volume** : ~3-5 options par slot, ~3 slots par menu, 13 menus = ~120-200 lignes a l'init. --- ### 3.6 `ingredient` -Elementary ingredient used in product composition. Carries stock data. +Ingredient elementaire utilise dans la composition des produits. Porte les donnees de stock. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `name` | VARCHAR(120) | NO | — | UNIQUE | e.g., "Sesame Bun", "Cheddar Slice", "Ketchup Portion" | -| `unit` | VARCHAR(40) | NO | — | — | packaging unit label: piece / portion / sachet 1kg / pot / bottle (free-form label, not an ENUM — units vary per ingredient) | -| `stock_quantity` | INT | NO | 0 | CHECK >= 0 | current stock in units. Signed INT to allow negative detection (alert), but business rule enforces >= 0 | -| `pack_size` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units per restocking pack (e.g., 100 for a bag of 100 portions) | -| `pack_label` | VARCHAR(80) | YES | NULL | — | human label of the pack (e.g., "Sac 100 portions") | -| `low_stock_threshold` | SMALLINT UNSIGNED | NO | 0 | CHECK >= 0 | alert threshold: stock_quantity <= this value triggers low-stock indicator | -| `is_active` | TINYINT(1) | NO | 1 | — | deactivate obsolete ingredients without deleting | +| `name` | VARCHAR(120) | NO | — | UNIQUE | ex. "Sesame Bun", "Cheddar Slice", "Ketchup Portion" | +| `unit` | VARCHAR(40) | NO | — | — | libelle de l'unite de conditionnement : piece / portion / sachet 1kg / pot / bouteille (libelle libre, pas un ENUM — les unites varient par ingredient) | +| `stock_quantity` | INT (signed) | NO | 0 | — | stock courant en unites. INT signe sans `CHECK >= 0` : il PEUT devenir negatif quand les ventes depassent le stock compte (ampleur de la survente, remontee aux managers). Le systeme ne bloque pas une commande sur le stock. | +| `stock_capacity` | INT | NO | — | CHECK > 0 | niveau "plein" de reference en unites = les 100% servant a calculer le pourcentage de stock. Le `CHECK > 0` protege aussi la division du pourcentage contre la division par zero | +| `pack_size` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites par pack de reapprovisionnement (ex. 100 pour un sac de 100 portions) | +| `pack_label` | VARCHAR(80) | YES | NULL | — | libelle humain du pack (ex. "Sac 100 portions") | +| `low_stock_pct` | SMALLINT UNSIGNED | NO | 10 | CHECK BETWEEN 0 AND 100 | bande d’alerte, pourcentage de capacite : `stock_quantity <= stock_capacity * low_stock_pct/100` declenche l'indicateur de stock bas | +| `critical_stock_pct` | SMALLINT UNSIGNED | NO | 5 | CHECK BETWEEN 0 AND 100 | seuil de rupture automatique, pourcentage de capacite : `stock_quantity <= stock_capacity * critical_stock_pct/100` rend le produit calcule en rupture | +| `is_active` | TINYINT(1) | NO | 1 | — | desactiver les ingredients obsoletes sans supprimer | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -**Stock decrement rule**: at the `paid` transition, each ingredient is decremented by -`product_ingredient.quantity_normal` or `quantity_maxi` (selected by `order_item.format`) -multiplied by `order_item.quantity`, then adjusted by `order_item_modifier` rows. See note 7. -**Restocking rule**: `stock_quantity += N * pack_size` (restocked in full packs). -**Cancellation rule**: stock is re-credited when a `paid` order is cancelled. -**Low-stock alert**: computed at display time (`stock_quantity <= low_stock_threshold`); -no additional stored column. +**CHECK au niveau table** : `critical_stock_pct < low_stock_pct` (le seuil critique se situe sous la bande d’alerte). + +**Regle de decrement de stock** : a la transition `paid`, chaque ingredient est decremente de +`product_ingredient.quantity_normal` ou `quantity_maxi` (selectionne par `order_item.format`) +multiplie par `order_item.quantity`, puis ajuste par les lignes `order_item_modifier`. Voir note 7. +**Regle de reapprovisionnement** : `stock_quantity += N * pack_size` (reapprovisionne en packs complets). +**Regle d'annulation** : le stock est recredite quand une commande `paid` est annulee. +**Modele de stock (base sur le pourcentage, trois bandes)** : le seuil d'alerte absolu est remplace par un +modele en pourcentage ancre sur `stock_capacity` (la reference 100%). Le pourcentage de stock est +calcule, non stocke : `stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Le +`CHECK > 0` sur `stock_capacity` protege cette division contre la division par zero. Trois bandes : +- **Normal** — au-dessus de la bande d’alerte : rien n'est signale. +- **Low** — `stock_quantity <= stock_capacity * low_stock_pct/100` : commandable + alerte manager. + Le manager retire le produit via `product.is_available=0`, ou reapprovisionne pour lever l'alerte. +- **Critical** — `stock_quantity <= stock_capacity * critical_stock_pct/100` : le produit + passe automatiquement en rupture (disponibilite calculee, voir regle RG-T21 dans `mlt.md`) ; aucune colonne stockee supplementaire. --- ### 3.7 `product_ingredient` -Default composition of a product (burger, wrap, etc.) in terms of ingredients. -Carries customization metadata for the ingredient configurator. +Composition par defaut d'un produit (burger, wrap, etc.) en termes d'ingredients. +Porte les metadonnees de personnalisation pour le configurateur d'ingredients. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE CASCADE | | -| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | RESTRICT: cannot remove an ingredient still referenced in a product recipe | -| `quantity_normal` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units consumed in Normal format (e.g., 2 for double cheese) | -| `quantity_maxi` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | units consumed in Maxi format. Equals `quantity_normal` for format-invariant ingredients (burger, sauce); higher for side and drink ingredients (Maxi enlarges side + drink only). See note 7. | -| `is_removable` | TINYINT(1) | NO | 1 | — | customer can remove this ingredient at no cost | -| `is_addable` | TINYINT(1) | NO | 0 | — | customer can add an extra unit of this ingredient | -| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | surcharge in cents when `is_addable=1` and customer adds it (0 = free extra) | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | RESTRICT : impossible de retirer un ingredient encore reference dans une recette de produit | +| `quantity_normal` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites consommees en format Normal (ex. 2 pour double cheese) | +| `quantity_maxi` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | unites consommees en format Maxi. Egale `quantity_normal` pour les ingredients invariants au format (burger, sauce) ; superieure pour les ingredients d'accompagnement et de boisson (le Maxi agrandit uniquement l'accompagnement + la boisson). Voir note 7. | +| `is_removable` | TINYINT(1) | NO | 1 | — | le client peut retirer cet ingredient sans frais | +| `is_addable` | TINYINT(1) | NO | 0 | — | le client peut ajouter une unite supplementaire de cet ingredient | +| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | supplement en centimes quand `is_addable=1` et que le client l'ajoute (0 = extra gratuit) | -**Primary key**: composite `(product_id, ingredient_id)`. +**Cle primaire** : composite `(product_id, ingredient_id)`. -**Volume**: ~5-10 ingredients per product, ~53 products = ~300-500 rows at seed. +**Volume** : ~5-10 ingredients par produit, ~53 produits = ~300-500 lignes au seed. --- ### 3.8 `allergen` -Catalogue of the 14 regulated allergens (INCO Regulation (EU) 1169/2011). +Catalogue des 14 allergenes reglementes (Reglement INCO (UE) 1169/2011). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `code` | VARCHAR(30) | NO | — | UNIQUE | machine-readable code, e.g., `gluten`, `milk`, `nuts` | -| `name` | VARCHAR(80) | NO | — | — | display name, e.g., "Gluten", "Lait", "Fruits a coque" | -| `description` | TEXT | YES | NULL | — | optional guidance for staff | +| `code` | VARCHAR(30) | NO | — | UNIQUE | code lisible par machine, ex. `gluten`, `milk`, `nuts` | +| `name` | VARCHAR(80) | NO | — | — | nom d'affichage, ex. "Gluten", "Lait", "Fruits a coque" | +| `description` | TEXT | YES | NULL | — | guidance optionnelle pour le personnel | -**Volume**: 14 rows at seed (fixed by EU regulation 1169/2011, list confirmed at seed time). -Allergens for a product are **computed** by joining `product_ingredient` -> -`ingredient_allergen` -> `allergen`; no manual re-entry per product. +**Volume** : 14 lignes au seed (fixe par le reglement UE 1169/2011, liste confirmee au moment du seed). +Les allergenes d'un produit sont **calcules** en joignant `product_ingredient` -> +`ingredient_allergen` -> `allergen` ; pas de ressaisie manuelle par produit. --- ### 3.9 `ingredient_allergen` -Maps which allergens each ingredient contains. Pure join table. +Indique quels allergenes contient chaque ingredient. Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE CASCADE | | | `allergen_id` | INT UNSIGNED | NO | — | FK -> `allergen(id)`, ON DELETE RESTRICT | | -**Primary key**: composite `(ingredient_id, allergen_id)`. +**Cle primaire** : composite `(ingredient_id, allergen_id)`. --- ### 3.10 `customer_order` -Customer transaction: 1 order = 1 validated cart at a point in time. -(Table name rationale: see modeling note 3.) +Transaction client : 1 commande = 1 panier valide a un instant donne. +(Rationale du nom de table : voir note de modelisation 3.) -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `order_number` | VARCHAR(20) | NO | — | UNIQUE | human-readable format: `K`/`C`/`D`-YYYY-MM-DD-NNN. Prefix by channel: K=kiosk, C=counter, D=drive. See note 4. | -| `source` | ENUM('kiosk','counter','drive') | NO | — | INDEX | input channel (who entered the order). Values in English, see note 5. | -| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | consumption mode, retained for stats/KPI only. No fiscal role (see note 9). `drive` source implies `drive` service_mode (cross-constraint enforced at app layer). | -| `status` | ENUM('pending_payment','paid','delivered','cancelled') | NO | 'pending_payment' | INDEX | 4-state machine: `pending_payment -> paid -> delivered` (+ `cancelled`). See note 6. | -| `total_ht_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | ex-VAT total, snapshot at order validation | -| `total_vat_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | VAT amount, snapshot | -| `total_ttc_cents` | INT UNSIGNED | NO | — | CHECK > 0 | incl.-VAT total; must equal total_ht_cents + total_vat_cents (verified at MLT layer) | -| `paid_at` | DATETIME | YES | NULL | — | timestamp of transition to `paid` (NULL before payment) | -| `delivered_at` | DATETIME | YES | NULL | — | timestamp of transition to `delivered` (NULL before delivery) | -| `cancelled_at` | DATETIME | YES | NULL | — | timestamp of cancellation (NULL if not cancelled) | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | used for live stats aggregations; also serves as `service_day` base | +| `order_number` | VARCHAR(20) | NO | — | UNIQUE | format lisible par l'humain : `K`/`C`/`D`-YYYY-MM-DD-NNN. Prefixe par canal : K=kiosk, C=counter, D=drive. Voir note 4. | +| `idempotency_key` | VARCHAR(36) | YES | NULL | UNIQUE | UUID genere par le client pour dedupliquer un `POST /api/orders` reessaye (anti-double-charge). UNIQUE rejette les doublons ; plusieurs NULL autorises. Security-by-design, voir note 13 | +| `source` | ENUM('kiosk','counter','drive') | NO | — | INDEX | canal de saisie (qui a saisi la commande). Valeurs en anglais, voir note 5. | +| `acting_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | personnel back-office (counter/drive) ayant cree la commande, capture sous PIN. NULL pour `kiosk` (anonyme). Imputabilite ciblee sans imposer un login par personne sur la borne. Voir note 13 | +| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | mode de consommation, conserve pour les stats/KPI uniquement. Aucun role fiscal (voir note 9). La source `drive` implique le service_mode `drive` (contrainte croisee appliquee au niveau applicatif). | +| `status` | ENUM('pending_payment','paid','delivered','cancelled') | NO | 'pending_payment' | INDEX | machine a 4 etats : `pending_payment -> paid -> delivered` (+ `cancelled`). Voir note 6. | +| `total_ht_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | total hors TVA, snapshot a la validation de la commande | +| `total_vat_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | montant de TVA, snapshot | +| `total_ttc_cents` | INT UNSIGNED | NO | — | CHECK > 0 | total TTC ; doit egaler total_ht_cents + total_vat_cents (verifie a la couche MLT) | +| `paid_at` | DATETIME | YES | NULL | — | timestamp de la transition vers `paid` (NULL avant paiement) | +| `delivered_at` | DATETIME | YES | NULL | — | timestamp de la transition vers `delivered` (NULL avant la remise) | +| `cancelled_at` | DATETIME | YES | NULL | — | timestamp d'annulation (NULL si non annulee) | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | utilise pour les agregations de stats en direct ; sert aussi de base a `service_day` | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -**Dropped from v0.1**: `tva_taux_pourmille` (moved to line level — `order_item.vat_rate_snapshot`), -`paye_a` (renamed `paid_at`). Machine states `preparing` and `ready` dropped (see note 6). +**Retire de v0.1** : `tva_taux_pourmille` (deplace au niveau ligne — `order_item.vat_rate_snapshot`), +`paye_a` (renomme `paid_at`). Etats machine `preparing` et `ready` retires (voir note 6). -**`service_day` computation** (KPI grouping): +**Calcul de `service_day`** (regroupement KPI) : ``` CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END ``` -Computed at query time, not stored as a column (the generated-column formula with `INTERVAL 4 HOUR -30 MINUTE` in v0.1 MLD was incorrect and is dropped). Cutoff: 10:00. +Calcule au moment de la requete, non stocke comme colonne (la formule de colonne generee avec `INTERVAL 4 HOUR +30 MINUTE` dans le MLD v0.1 etait incorrecte et est retiree). Coupure : 10:00. -**Volume**: ~100-300 orders/day at peak, ~10k rows over a 6-month demo. +**Volume** : ~100-300 commandes/jour au pic, ~10k lignes sur une demo de 6 mois. --- ### 3.11 `order_item` -Line of an order: a single product or a menu, with price, label, and VAT rate -snapshotted at transaction time. +Ligne d'une commande : un seul produit ou un menu, avec prix, libelle et taux de TVA +snapshotes au moment de la transaction. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | | `order_id` | INT UNSIGNED | NO | — | FK -> `customer_order(id)`, ON DELETE CASCADE | | -| `item_type` | ENUM('product','menu') | NO | — | — | discriminator | -| `product_id` | INT UNSIGNED | YES | NULL | FK -> `product(id)`, ON DELETE RESTRICT | non-null if `item_type = 'product'` | -| `menu_id` | INT UNSIGNED | YES | NULL | FK -> `menu(id)`, ON DELETE RESTRICT | non-null if `item_type = 'menu'` | -| `format` | ENUM('normal','maxi') | NO | 'normal' | — | applies to menu items (Normal / Maxi). For standalone products, value is `normal` (no individual upsizing in this model). See note 7. | -| `label_snapshot` | VARCHAR(120) | NO | — | — | label at time of order (preserved if product is renamed) | -| `unit_price_cents_snapshot` | INT UNSIGNED | NO | — | CHECK > 0 | unit price incl. VAT at time of order | -| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | — | CHECK IN (55, 100) | VAT rate in per-mille at time of order (snapshotted from `product.vat_rate`) | -| `quantity` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | quantity ordered (e.g., 3 Cocas = 1 line with quantity=3) | +| `item_type` | ENUM('product','menu') | NO | — | — | discriminateur | +| `product_id` | INT UNSIGNED | YES | NULL | FK -> `product(id)`, ON DELETE RESTRICT | non nul si `item_type = 'product'` | +| `menu_id` | INT UNSIGNED | YES | NULL | FK -> `menu(id)`, ON DELETE RESTRICT | non nul si `item_type = 'menu'` | +| `format` | ENUM('normal','maxi') | NO | 'normal' | — | s'applique aux items menu (Normal / Maxi). Pour les produits autonomes, la valeur est `normal` (pas d'agrandissement individuel dans ce modele). Voir note 7. | +| `label_snapshot` | VARCHAR(120) | NO | — | — | libelle au moment de la commande (preserve si le produit est renomme) | +| `unit_price_cents_snapshot` | INT UNSIGNED | NO | — | CHECK > 0 | prix unitaire TTC au moment de la commande | +| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | — | CHECK IN (55, 100) | taux de TVA en pour-mille au moment de la commande (snapshote depuis `product.vat_rate`) | +| `quantity` | SMALLINT UNSIGNED | NO | 1 | CHECK > 0 | quantite commandee (ex. 3 Cocas = 1 ligne avec quantity=3) | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | -**CHECK constraint** (applicative or MariaDB CHECK >= 10.2): +**Contrainte CHECK** (applicative ou MariaDB CHECK >= 10.2) : `(item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL) OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)` -**Volume**: ~3-5 lines per order -> 30k-50k rows over 6 months. +**Volume** : ~3-5 lignes par commande -> 30k-50k lignes sur 6 mois. --- ### 3.12 `order_item_selection` -The actual choices made by the customer for each slot of a menu line. -1 row = 1 slot filled for 1 order_item of type `menu`. +Les choix reels effectues par le client pour chaque slot d'une ligne de menu. +1 ligne = 1 slot rempli pour 1 order_item de type `menu`. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | must reference an order_item with item_type='menu' | -| `menu_slot_id` | INT UNSIGNED | NO | — | FK -> `menu_slot(id)`, ON DELETE RESTRICT | which slot was filled | -| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | product chosen by the customer for this slot | -| `label_snapshot` | VARCHAR(120) | NO | — | — | product label at time of order | +| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | doit referencer un order_item avec item_type='menu' | +| `menu_slot_id` | INT UNSIGNED | NO | — | FK -> `menu_slot(id)`, ON DELETE RESTRICT | quel slot a ete rempli | +| `product_id` | INT UNSIGNED | NO | — | FK -> `product(id)`, ON DELETE RESTRICT | produit choisi par le client pour ce slot | +| `label_snapshot` | VARCHAR(120) | NO | — | — | libelle du produit au moment de la commande | -**Volume**: ~2-3 selections per menu line. -**KPI use**: enables analysis of which drink/side combinations are most chosen. +**Volume** : ~2-3 selections par ligne de menu. +**Usage KPI** : permet d'analyser quelles combinaisons boisson/accompagnement sont les plus choisies. --- ### 3.13 `order_item_modifier` -Ingredient-level modifications applied by the customer to a product or to the fixed -burger of a menu: removal (free) or addition (with optional surcharge). +Modifications au niveau ingredient appliquees par le client a un produit ou au burger fixe +d'un menu : retrait (gratuit) ou ajout (avec supplement optionnel). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | the order line being modified (product or menu) | -| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | the ingredient being modified | -| `action` | ENUM('remove','add') | NO | — | — | `remove` = free removal; `add` = extra unit (may have surcharge) | -| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | snapshot of `product_ingredient.extra_price_cents` at time of order (0 for removals) | +| `order_item_id` | INT UNSIGNED | NO | — | FK -> `order_item(id)`, ON DELETE CASCADE | la ligne de commande modifiee (produit ou menu) | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | l'ingredient modifie | +| `action` | ENUM('remove','add') | NO | — | — | `remove` = retrait gratuit ; `add` = unite supplementaire (peut avoir un supplement) | +| `extra_price_cents` | INT UNSIGNED | NO | 0 | CHECK >= 0 | snapshot de `product_ingredient.extra_price_cents` au moment de la commande (0 pour les retraits) | -**Modifier attachment rule** (see modeling note 10): -- For a standalone product (`item_type='product'`): the modifier targets the product - directly via `order_item_id`. -- For a menu (`item_type='menu'`): the modifier targets the menu line's fixed burger - via the same `order_item_id`. The burger is identified by `menu.burger_product_id`, - allowing the kitchen display to resolve which ingredients are modified without ambiguity. - No additional FK is needed: given `order_item_id`, the burger is +**Regle de rattachement du modificateur** (voir note de modelisation 10) : +- Pour un produit autonome (`item_type='product'`) : le modificateur cible le produit + directement via `order_item_id`. +- Pour un menu (`item_type='menu'`) : le modificateur cible le burger fixe de la ligne de menu + via le meme `order_item_id`. Le burger est identifie par `menu.burger_product_id`, + permettant a l'affichage cuisine de resoudre sans ambiguite quels ingredients sont modifies. + Aucune FK supplementaire n'est necessaire : etant donne `order_item_id`, le burger est `order_item.menu_id -> menu.burger_product_id`. -**Stock impact**: each modifier affects ingredient stock at `paid` transition -(`remove` -> no decrement for that ingredient; `add` -> extra decrement). +**Impact stock** : chaque modificateur affecte le stock d'ingredient a la transition `paid` +(`remove` -> pas de decrement pour cet ingredient ; `add` -> decrement supplementaire). --- ### 3.14 `user` -Back-office user (admin, manager, kitchen staff, counter, drive). Kiosk customers -are not authenticated and have no row here. +Utilisateur back-office (admin, manager, personnel cuisine, counter, drive). Les clients de la borne +ne sont pas authentifies et n'ont pas de ligne ici. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `email` | VARCHAR(254) | NO | — | UNIQUE | max length per RFC 5321 | -| `password_hash` | VARCHAR(255) | NO | — | — | argon2id hash (see `PASSWORD_ALGO` in `.env`); typical length 96 chars, margin to 255 | +| `email` | VARCHAR(254) | NO | — | UNIQUE | longueur max selon RFC 5321 | +| `password_hash` | VARCHAR(255) | NO | — | — | hash argon2id (voir `PASSWORD_ALGO` dans `.env`) ; longueur typique 96 caracteres, marge jusqu'a 255 | | `first_name` | VARCHAR(60) | NO | — | — | | | `last_name` | VARCHAR(60) | NO | — | — | | -| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE RESTRICT | a user cannot exist without a role | -| `is_active` | TINYINT(1) | NO | 1 | — | deactivation without deletion | -| `last_login_at` | DATETIME | YES | NULL | — | useful for audit and dormant account detection | +| `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE RESTRICT | un utilisateur ne peut exister sans role | +| `is_active` | TINYINT(1) | NO | 1 | — | desactivation sans suppression | +| `last_login_at` | DATETIME | YES | NULL | — | utile pour l'audit et la detection de comptes dormants | +| `pin_hash` | VARCHAR(255) | YES | NULL | — | hash argon2id du PIN par membre du personnel qui autorise les actions sensibles (prix/RBAC/utilisateur/annulation/inventaire). NULL = aucun PIN defini. Security-by-design, voir note 13 | +| `failed_login_attempts` | SMALLINT UNSIGNED | NO | 0 | — | logins echoues consecutifs ; pilote le throttling degressif (note 13) | +| `last_failed_login_at` | DATETIME | YES | NULL | — | timestamp du dernier login echoue | +| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de throttling courante (backoff degressif, pas un verrouillage dur indefini) | +| `password_reset_token_hash` | VARCHAR(255) | YES | NULL | — | hash du token de reset (pas le token brut) ; NULL quand aucun reset en attente | +| `password_reset_expires_at` | DATETIME | YES | NULL | — | expiration du token de reset | +| `anonymized_at` | DATETIME | YES | NULL | — | marqueur tombstone RGPD : quand renseigne, les colonnes PII sont mises a NULL/remplacees (note 13). La ligne est conservee pour l'integrite referentielle | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -**Volume**: 5-20 rows (restaurant team + 1-2 admins). +**Volume** : 5-20 lignes (equipe du restaurant + 1-2 admins). -RFC 5321 email length: local-part <= 64, domain <= 255, total <= 254 (including `@`). -VARCHAR(254) is the spec-compliant value. +Longueur d'email RFC 5321 : local-part <= 64, domaine <= 255, total <= 254 (incluant `@`). +VARCHAR(254) est la valeur conforme a la spec. + +**Colonnes PII** : `email`, `first_name`, `last_name`. Soumises a l'anonymisation RGPD +(voir note 13). `password_hash` et `pin_hash` sont des credentials, tenus hors des logs et +des reponses d'API. --- ### 3.15 `role` -Back-office roles (RBAC). Creatable / modifiable / deactivatable from admin UI. -Seed provides 5 roles; custom roles (e.g., "chef-patissier") can be added without deployment. +Roles back-office (RBAC). Creables / modifiables / desactivables depuis l'UI admin. +Le seed fournit 5 roles ; des roles personnalises (ex. "chef-patissier") peuvent etre ajoutes sans deploiement. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `code` | VARCHAR(40) | NO | — | UNIQUE | machine code, e.g., `admin`, `manager`, `kitchen`, `counter`, `drive` | -| `label` | VARCHAR(80) | NO | — | — | display name, e.g., `Administrator`, `Kitchen Staff` | +| `code` | VARCHAR(40) | NO | — | UNIQUE | code machine, ex. `admin`, `manager`, `kitchen`, `counter`, `drive` | +| `label` | VARCHAR(80) | NO | — | — | nom d'affichage, ex. `Administrator`, `Kitchen Staff` | | `description` | TEXT | YES | NULL | — | | -| `default_route` | VARCHAR(120) | YES | NULL | — | landing screen for this role (e.g., `/admin/orders`, `/kitchen/display`). Makes routing dynamic — no hardcoded role names in front-end routing. | -| `order_source` | ENUM('kiosk','counter','drive') | YES | NULL | — | auto-tagged `source` when this role creates an order (NULL for admin/manager who can create on behalf of any channel) | -| `is_active` | TINYINT(1) | NO | 1 | — | deactivation preserves history of users who held this role | +| `default_route` | VARCHAR(120) | YES | NULL | — | ecran d'atterrissage pour ce role (ex. `/admin/orders`, `/kitchen/display`). Rend le routage dynamique — pas de noms de role en dur dans le routage front-end. | +| `order_source` | ENUM('kiosk','counter','drive') | YES | NULL | — | `source` auto-taggee quand ce role cree une commande (NULL pour admin/manager qui peuvent creer au nom de n'importe quel canal) | +| `is_active` | TINYINT(1) | NO | 1 | — | la desactivation preserve l'historique des utilisateurs ayant detenu ce role | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | | `updated_at` | DATETIME | NO | CURRENT_TIMESTAMP ON UPDATE | — | audit | -**Seed roles**: +**Roles du seed** : | Code | `default_route` | `order_source` | |---|---|---| | `admin` | `/admin/dashboard` | NULL | @@ -420,28 +444,28 @@ Seed provides 5 roles; custom roles (e.g., "chef-patissier") can be added withou | `counter` | `/counter/orders` | `counter` | | `drive` | `/drive/orders` | `drive` | -**RBAC architecture rule (P2)**: application code tests permissions, not role names. -Adding a new role with the right permissions requires no code change (permission-driven, -not role-name-driven — per Sandhu/NIST RBAC model). +**Regle d'architecture RBAC (P2)** : le code applicatif teste les permissions, pas les noms de role. +Ajouter un nouveau role avec les bonnes permissions ne requiert aucun changement de code (pilote par permission, +non par nom de role — selon le modele RBAC Sandhu/NIST). --- ### 3.16 `role_visible_source` -Defines which order sources are visible on the preparation dashboard for a given role. -Pure join table. +Definit quelles sources de commande sont visibles sur le tableau de bord de preparation pour un role donne. +Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE CASCADE | | -| `source` | ENUM('kiosk','counter','drive') | NO | — | — | source visible to this role on the kitchen/counter/drive display | +| `source` | ENUM('kiosk','counter','drive') | NO | — | — | source visible pour ce role sur l'affichage kitchen/counter/drive | -**Primary key**: composite `(role_id, source)`. +**Cle primaire** : composite `(role_id, source)`. -**Seed data**: -| Role | Visible sources | +**Donnees du seed** : +| Role | Sources visibles | |---|---| -| `kitchen` | kiosk, counter, drive (all) | +| `kitchen` | kiosk, counter, drive (toutes) | | `counter` | kiosk, counter | | `drive` | drive | @@ -449,19 +473,19 @@ Pure join table. ### 3.17 `permission` -Granular permissions assignable to roles. Catalogue is fixed at seed (no UI creation). +Permissions granulaires assignables aux roles. Le catalogue est fixe au seed (pas de creation via UI). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | | `code` | VARCHAR(60) | NO | — | UNIQUE | format `.` | -| `label` | VARCHAR(120) | NO | — | — | display name | +| `label` | VARCHAR(120) | NO | — | — | nom d'affichage | | `description` | TEXT | YES | NULL | — | | | `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | audit | -**Fixed permission catalogue** (23 codes — frozen before DDL): +**Catalogue de permissions fixe** (23 codes — gele avant le DDL) : -| Code | Granted to (seed default) | +| Code | Accorde a (defaut seed) | |---|---| | `product.create` | admin, manager | | `product.read` | admin, manager, kitchen, counter, drive | @@ -487,195 +511,271 @@ Granular permissions assignable to roles. Catalogue is fixed at seed (no UI crea | `role.manage` | admin | | `stats.read` | admin, manager | -**Volume**: 23 rows at seed. +**Volume** : 23 lignes au seed. --- ### 3.18 `role_permission` -N-N mapping between roles and permissions. Pure join table. +Mapping N-N entre roles et permissions. Table de jointure pure. -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `role_id` | INT UNSIGNED | NO | — | FK -> `role(id)`, ON DELETE CASCADE | | | `permission_id` | INT UNSIGNED | NO | — | FK -> `permission(id)`, ON DELETE CASCADE | | -**Primary key**: composite `(role_id, permission_id)`. +**Cle primaire** : composite `(role_id, permission_id)`. -**Volume**: ~50-100 rows at seed (admin covers all; others cover a subset). +**Volume** : ~50-100 lignes au seed (admin couvre tout ; les autres couvrent un sous-ensemble). --- ### 3.19 `stock_movement` -Append-only audit log of all stock changes per ingredient. -1 row per movement (sale, cancellation, restock, inventory correction). +Journal d'audit append-only de tous les changements de stock par ingredient. +1 ligne par mouvement (vente, annulation, reapprovisionnement, correction d'inventaire). -| Attribute | Type | NULL | Default | Constraint | Notes | +| Attribut | Type | NULL | Default | Contrainte | Notes | |---|---|---|---|---|---| | `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | -| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | ingredient affected | -| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | — | INDEX | nature of the movement | -| `delta` | INT | NO | — | — | signed change: negative for consumption (sale), positive for restock/cancellation/correction | -| `order_id` | INT UNSIGNED | YES | NULL | FK -> `customer_order(id)`, ON DELETE SET NULL | linked order for `sale` and `cancellation` movements; NULL for restock/correction | -| `user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | user who triggered the movement (NULL for automated sale decrements) | -| `note` | VARCHAR(255) | YES | NULL | — | optional human note (e.g., reason for correction, pack reference) | -| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | immutable timestamp | +| `ingredient_id` | INT UNSIGNED | NO | — | FK -> `ingredient(id)`, ON DELETE RESTRICT | ingredient affecte | +| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | — | INDEX | nature du mouvement | +| `delta` | INT | NO | — | — | changement signe : negatif pour la consommation (vente), positif pour reapprovisionnement/annulation/correction | +| `order_id` | INT UNSIGNED | YES | NULL | FK -> `customer_order(id)`, ON DELETE SET NULL | commande liee pour les mouvements `sale` et `cancellation` ; NULL pour restock/correction | +| `user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | utilisateur ayant declenche le mouvement (NULL pour les decrements de vente automatises) | +| `note` | VARCHAR(255) | YES | NULL | — | note humaine optionnelle (ex. raison de la correction, reference de pack) | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | timestamp immuable | -**Immutability**: no UPDATE or DELETE on this table. Corrections are new rows with -`movement_type='inventory_correction'` and a signed delta. +**Immuabilite** : aucun UPDATE ni DELETE sur cette table. Les corrections sont de nouvelles lignes avec +`movement_type='inventory_correction'` et un delta signe. -**Automatic movements** (triggered at status transitions): -- `paid` transition: 1 `sale` row per ingredient unit consumed (accounting for modifiers). -- `cancelled` (from `paid`): 1 `cancellation` row per ingredient unit re-credited. +**Mouvements automatiques** (declenches aux transitions de statut) : +- transition `paid` : 1 ligne `sale` par unite d'ingredient consommee (en tenant compte des modificateurs). +- `cancelled` (depuis `paid`) : 1 ligne `cancellation` par unite d'ingredient recreditee. -**Manual movements**: -- `restock`: manager or admin records a delivery (`+= N * pack_size`). -- `inventory_correction`: morning/evening physical count; system records the discrepancy - (delta = actual - theoretical). +**Mouvements manuels** : +- `restock` : le manager ou l'admin enregistre une livraison (`+= N * pack_size`). +- `inventory_correction` : comptage physique matin/soir ; le systeme enregistre l'ecart + (delta = reel - theorique). -**Volume**: ~5-15 movements per order across all ingredients; index on -`(ingredient_id, created_at)` is recommended for per-ingredient history queries. +**Volume** : ~5-15 mouvements par commande sur tous les ingredients ; un index sur +`(ingredient_id, created_at)` est recommande pour les requetes d'historique par ingredient. --- -## 4. Modeling notes +### 3.20 `audit_log` -### Note 1 — Why `INT UNSIGNED` in cents for prices +Journal append-only des **actions back-office sensibles**, pour l'imputabilite la ou elle importe +(menace interne, manipulation d'argent, changements RBAC). Complete `stock_movement` (specifique au +stock) ; couvre les evenements catalogue/prix, utilisateur, role/permission et annulation de commande. +Ajout security-by-design (voir note 13). -Storing a price as `FLOAT` or `DECIMAL(10,2)` is technically valid but introduces two risks: +| Attribut | Type | NULL | Default | Contrainte | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `actor_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | personnel ayant effectue l'action, capture via PIN pour les operations sensibles. NULL si non attribuable a un individu | +| `actor_role_id` | INT UNSIGNED | YES | NULL | FK -> `role(id)`, ON DELETE SET NULL | contexte de role au moment de l'action (denormalise pour que la trace survive a l'anonymisation de l'utilisateur) | +| `action_code` | VARCHAR(60) | NO | — | INDEX | code d'operation MCT / de permission, ex. `product.update`, `order.cancel`, `role.manage`, `user.deactivate` | +| `entity_type` | VARCHAR(40) | YES | NULL | — | nom de la table affectee, ex. `product`, `customer_order`, `role`, `user` | +| `entity_id` | INT UNSIGNED | YES | NULL | — | PK de la ligne affectee | +| `summary` | VARCHAR(255) | YES | NULL | — | courte description non personnelle, ex. "price_cents 880 -> 920", "added permission stock.manage" | +| `details` | JSON | YES | NULL | — | diff before/after optionnel. Pour les actions ciblant un utilisateur, stocke les **noms de champs** modifies, pas les valeurs PII | +| `created_at` | DATETIME | NO | CURRENT_TIMESTAMP | INDEX | timestamp immuable | -1. **FLOAT rounding**: `0.1 + 0.2 = 0.30000000000000004` in IEEE 754 floating-point. - Summing 100 order lines can produce cent-level discrepancies vs business reality. -2. **FLOAT-to-string conversion**: different PHP/MariaDB driver versions may serialize floats - with variable precision. +**Immuabilite** : aucun UPDATE ni DELETE au niveau applicatif (meme discipline que `stock_movement`). +**Index** : `(actor_user_id, created_at)`, `(entity_type, entity_id)`, `(action_code, created_at)`. +**Retention** : fenetre propre (~12 mois, interet legitime / tracabilite fiscale), decouplee +du cycle de vie des PII utilisateur (note 13). Une purge planifiee (cron) retire les lignes au-dela de la fenetre. -Storing as `INT UNSIGNED` (cents: 880 for EUR 8.80) eliminates these risks. Conversion to EUR -for display is done in PHP at output: `number_format($cents / 100, 2)`. +**Operations journalisees** (ensemble sensible) : `UPDATE_PRODUCT` (8.2, incl. prix), `DELETE_PRODUCT` +(8.3), `DELETE_MENU` (8.6), `CANCEL_ORDER` (7.1), `RESTOCK` (9.1), `INVENTORY_COUNT` (9.2), +`CREATE_USER` / `UPDATE_USER` / `DEACTIVATE_USER` (10.1-10.3), `MANAGE_RBAC` (10.4). -Reference: David Goldberg, *What Every Computer Scientist Should Know About Floating-Point +**Volume** : faible (~10-50 actions sensibles/jour) — des ordres de grandeur sous `stock_movement`. + +--- + +### 3.21 `login_throttle` + +Throttle anti-brute-force par IP source. Complete le compteur par compte deja present sur `user` +(`failed_login_attempts` / `lockout_until`), une ligne par IP source. Ajout security-by-design +(voir note 13). + +| Attribut | Type | NULL | Default | Contrainte | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `ip_address` | VARCHAR(45) | NO | — | UNIQUE | IP source, une ligne par IP, upsertee ; 45 caracteres contiennent un litteral IPv6 complet | +| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | logins echoues consecutifs depuis cette IP dans la fenetre courante | +| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de backoff degressif ; NULL = non throttle | +| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp de la derniere tentative echouee | + +**Pas de FK** : une IP n'est pas une entite modelisee. Les lignes sont appended/upsertees par IP ; la fenetre se reinitialise +a son expiration. Un cron quotidien purge les lignes sans lockout actif dont le `last_attempt_at` est plus ancien +que 24h. + +--- + +### 3.22 `pin_throttle` + +Throttle du PIN d'action sensible (RG-T22), complement de RG-T13. Une ligne par utilisateur AGISSANT +(l'identite de session qui soumet email+PIN), STRICTEMENT SEPAREE des compteurs de connexion +(`user.failed_login_attempts` / `login_throttle`) : un echec de PIN n'incremente aucun compteur de login. +Ajout security-by-design (voir note 13). + +| Attribut | Type | NULL | Default | Contrainte | Notes | +|---|---|---|---|---|---| +| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | | +| `actor_user_id` | INT UNSIGNED | NO | — | UNIQUE, FK -> `user(id)` ON DELETE CASCADE | l'utilisateur agissant (session), une ligne par acteur, upsertee | +| `failed_attempts` | SMALLINT UNSIGNED | NO | 0 | — | echecs de PIN consecutifs de cet acteur dans la fenetre courante | +| `window_started_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | NULL | — | fin de la fenetre de backoff degressif ; NULL = non throttle | +| `last_attempt_at` | DATETIME | NO | CURRENT_TIMESTAMP | — | timestamp de la derniere tentative echouee | + +**FK ON DELETE CASCADE** (contrairement a `login_throttle`) : la cle est un utilisateur back-office +authentifie, donc supprimer/anonymiser le compte purge proprement sa ligne de throttle. Memes bornes de +backoff que RG-8 mais PROPRES au PIN (PIN_THROTTLE_*, plus permissives). Meme purge cron quotidienne que +`login_throttle` (lignes sans lockout actif > 24h). + +--- + +## 4. Notes de modelisation + +### Note 1 — Pourquoi `INT UNSIGNED` en centimes pour les prix + +Stocker un prix en `FLOAT` ou `DECIMAL(10,2)` est techniquement valide mais introduit deux risques : + +1. **Arrondi FLOAT** : `0.1 + 0.2 = 0.30000000000000004` en virgule flottante IEEE 754. + Sommer 100 lignes de commande peut produire des ecarts au niveau du centime vs la realite metier. +2. **Conversion FLOAT-vers-chaine** : differentes versions de driver PHP/MariaDB peuvent serialiser les floats + avec une precision variable. + +Stocker en `INT UNSIGNED` (centimes : 880 pour 8,80 EUR) elimine ces risques. La conversion en EUR +pour l'affichage se fait en PHP a la sortie : `number_format($cents / 100, 2)`. + +Reference : David Goldberg, *What Every Computer Scientist Should Know About Floating-Point Arithmetic*, ACM Computing Surveys, 1991. -### Note 2 — Why `ENUM` rather than a reference table +### Note 2 — Pourquoi `ENUM` plutot qu'une table de reference -ENUMs (`service_mode`, `status`, `item_type`, `action`, `slot_type`) could have been reference -tables. Choice retained: ENUM. +Les ENUMs (`service_mode`, `status`, `item_type`, `action`, `slot_type`) auraient pu etre des tables de +reference. Choix retenu : ENUM. -Advantages in this context: -- Values are stable and limited (3-7 values max), unlikely to evolve frequently. -- DBMS-level constraint instead of runtime FK; simpler queries. -- Directly readable in SQL: `WHERE status = 'paid'`. +Avantages dans ce contexte : +- Les valeurs sont stables et limitees (3-7 valeurs max), peu susceptibles d'evoluer frequemment. +- Contrainte au niveau SGBD au lieu d'une FK a l'execution ; requetes plus simples. +- Directement lisible en SQL : `WHERE status = 'paid'`. -Cost of a future change: `ALTER TABLE ... MODIFY COLUMN ... ENUM(...)` to add a value. -Acceptable given changes are expected to be rare. +Cout d'un changement futur : `ALTER TABLE ... MODIFY COLUMN ... ENUM(...)` pour ajouter une valeur. +Acceptable etant donne que les changements sont attendus comme rares. -If these ENUMs later require multilingual labels or descriptions, they will be migrated to -reference tables. Not in scope for this iteration. +Si ces ENUMs requierent plus tard des libelles ou descriptions multilingues, ils seront migres vers des +tables de reference. Hors perimetre pour cette iteration. -### Note 3 — Why `customer_order` and not `order` +### Note 3 — Pourquoi `customer_order` et non `order` -`ORDER` is an SQL reserved word (used in `ORDER BY`). Three approaches exist: -- Quote the name everywhere: `` `order` `` — requires quoting in every SQL statement, - error-prone and non-portable across DBMS dialects. -- Use an alias at ORM level: possible but adds a mapping layer. -- Rename: `customer_order` (chosen) — unambiguous, self-documenting, no quoting required. +`ORDER` est un mot reserve SQL (utilise dans `ORDER BY`). Trois approches existent : +- Quoter le nom partout : `` `order` `` — requiert un quoting dans chaque instruction SQL, + source d'erreurs et non portable entre dialectes SGBD. +- Utiliser un alias au niveau ORM : possible mais ajoute une couche de mapping. +- Renommer : `customer_order` (choisi) — sans ambiguite, auto-documente, sans quoting requis. -Alternative considered and rejected: `purchase` (less domain-specific), -`transaction` (also reserved or ambiguous). `customer_order` matches the domain language -and avoids all conflicts. +Alternative consideree et rejetee : `purchase` (moins specifique au domaine), +`transaction` (egalement reserve ou ambigu). `customer_order` correspond au langage du domaine +et evite tous les conflits. -`order_item` is retained as the line table name: `item` is not reserved, and the -`order_` prefix makes the parent relationship clear. +`order_item` est conserve comme nom de table de ligne : `item` n'est pas reserve, et le +prefixe `order_` rend claire la relation parent. -### Note 4 — Order number prefix by channel +### Note 4 — Prefixe de numero de commande par canal -Format: `K`/`C`/`D`-YYYY-MM-DD-NNN (kiosk / counter / drive). +Format : `K`/`C`/`D`-YYYY-MM-DD-NNN (kiosk / counter / drive). -Rationale: the prefix encodes the channel, which is useful for rapid visual identification -by kitchen and counter staff without querying the `source` column. The sequential counter NNN -restarts daily per channel. Collision-free within a day given expected volume. +Rationale : le prefixe encode le canal, ce qui est utile pour une identification visuelle rapide +par le personnel cuisine et comptoir sans interroger la colonne `source`. Le compteur sequentiel NNN +repart chaque jour par canal. Sans collision sur une journee, vu le volume attendu. -Alternative rejected: neutral prefix `W-` for all channels (simpler, but loses channel -readability for staff). +Alternative rejetee : prefixe neutre `W-` pour tous les canaux (plus simple, mais perd la lisibilite +du canal pour le personnel). -### Note 5 — `source` vs `service_mode` (channel vs consumption mode) +### Note 5 — `source` vs `service_mode` (canal vs mode de consommation) -Two distinct dimensions, kept separate: +Deux dimensions distinctes, gardees separees : | | `source` | `service_mode` | |---|---|---| -| Nature | input channel (who entered the order) | consumption mode (where the customer eats) | -| Values | kiosk, counter, drive | dine_in, takeaway, drive | -| Used for | authentication, analytics, permission filtering | KPI, capacity (no fiscal role) | +| Nature | canal de saisie (qui a saisi la commande) | mode de consommation (ou le client mange) | +| Valeurs | kiosk, counter, drive | dine_in, takeaway, drive | +| Sert a | authentification, analytics, filtrage de permission | KPI, capacite (aucun role fiscal) | -The two dimensions are independent for `kiosk` and `counter` (a kiosk customer can choose -`dine_in` or `takeaway`). `drive` is the only case where both dimensions align: -`source=drive` implies `service_mode=drive`. This cross-constraint is verified at app layer. +Les deux dimensions sont independantes pour `kiosk` et `counter` (un client borne peut choisir +`dine_in` ou `takeaway`). `drive` est le seul cas ou les deux dimensions s'alignent : +`source=drive` implique `service_mode=drive`. Cette contrainte croisee est verifiee au niveau applicatif. -### Note 6 — Reduced 4-state machine +### Note 6 — Machine a 4 etats reduite -v0.1 had 6 states (`pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled`). -v0.2 reduces to 4 states: `pending_payment -> paid -> delivered` (+ `cancelled`). +v0.1 avait 6 etats (`pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled`). +v0.2 reduit a 4 etats : `pending_payment -> paid -> delivered` (+ `cancelled`). -Rationale (Decision 4 from `revue-alignement-p1.md` §7): in a fast-food context, the kitchen -display (KDS) is a visual system — staff see the ticket and act. `preparing` and `ready` were -intermediate states that added complexity without proportional business value. The single -kitchen action is `deliver` (counter/drive staff hands over the order), collapsing -`preparing + ready + delivered` into one gesture. KPI is total time: `delivered_at - paid_at` -(SLA ~10 min). KDS color coding is computed from `now - paid_at`, no extra stored state. +Rationale (Decision 4 de `revue-alignement-p1.md` §7) : dans un contexte fast-food, l'affichage +cuisine (KDS) est un systeme visuel — le personnel voit le ticket et agit. `preparing` et `ready` etaient +des etats intermediaires qui ajoutaient de la complexite sans valeur metier proportionnelle. L'unique +action cuisine est `deliver` (le personnel counter/drive remet la commande), fusionnant +`preparing + ready + delivered` en un seul geste. Le KPI est le temps total : `delivered_at - paid_at` +(SLA ~10 min). Le codage couleur du KDS est calcule depuis `now - paid_at`, sans etat stocke supplementaire. -**Dropped states and timestamps**: `preparing_at`, `ready_at` are not stored. +**Etats et timestamps retires** : `preparing_at`, `ready_at` ne sont pas stockes. -### Note 7 — Normal / Maxi format cascade +### Note 7 — Cascade de format Normal / Maxi -The Maxi format enlarges the side and the drink only. The burger is unchanged and the sauce -portion is unchanged (a sauce pot is the same in both formats). This scope is explicit so the -stock model stays faithful. +Le format Maxi agrandit uniquement l'accompagnement et la boisson. Le burger est inchange et la portion +de sauce est inchangee (un pot de sauce est identique dans les deux formats). Ce perimetre est explicite afin que le +modele de stock reste fidele. -**Price side** — not modeled at individual component price level: -- `menu` carries two prices: `price_normal_cents` and `price_maxi_cents`. -- `order_item.format` records which format the customer chose (`normal` or `maxi`). -- `order_item.unit_price_cents_snapshot` captures the actual price paid (Normal or Maxi). -- No individual price per slot component is stored; the price differential is a menu-level - attribute, consistent with how fast-food menus tend to be priced in practice. +**Cote prix** — non modelise au niveau du prix de composant individuel : +- `menu` porte deux prix : `price_normal_cents` et `price_maxi_cents`. +- `order_item.format` enregistre le format choisi par le client (`normal` ou `maxi`). +- `order_item.unit_price_cents_snapshot` capture le prix reellement paye (Normal ou Maxi). +- Aucun prix individuel par composant de slot n'est stocke ; le differentiel de prix est un attribut + au niveau menu, coherent avec la maniere dont les menus fast-food tendent a etre tarifes en pratique. -**Stock side** — modeled via a format multiplier on the recipe: -- `product_ingredient` carries `quantity_normal` and `quantity_maxi`. -- At the `paid` transition, the decrement uses `quantity_maxi` when `order_item.format='maxi'`, - otherwise `quantity_normal`. -- For burger and sauce ingredients, `quantity_maxi = quantity_normal` (format-invariant). -- For side and drink ingredients, `quantity_maxi > quantity_normal` (Maxi consumes more). -- The format cascades from the menu line (`order_item.format`) to its slot selections; a - standalone product line defaults to `normal`. -- Single product per choice (e.g., one `Fries` product), not separate medium/large products. +**Cote stock** — modelise via un multiplicateur de format sur la recette : +- `product_ingredient` porte `quantity_normal` et `quantity_maxi`. +- A la transition `paid`, le decrement utilise `quantity_maxi` quand `order_item.format='maxi'`, + sinon `quantity_normal`. +- Pour les ingredients burger et sauce, `quantity_maxi = quantity_normal` (invariants au format). +- Pour les ingredients accompagnement et boisson, `quantity_maxi > quantity_normal` (le Maxi consomme plus). +- Le format se propage de la ligne de menu (`order_item.format`) a ses selections de slot ; une + ligne de produit autonome est par defaut a `normal`. +- Un seul produit par choix (ex. un produit `Fries`), pas de produits medium/large separes. -Calibration: the Maxi surcharge is in the ~1.50 EUR range for this model (derived from -internal data; cross-checked against market magnitude for plausibility. Wakdo is a fictional -pastiche so exact prices are not copied from a real chain). +Calibration : le supplement Maxi est dans la fourchette ~1,50 EUR pour ce modele (derive de +donnees internes ; recoupe avec l'ordre de grandeur du marche pour plausibilite. Wakdo est un pastiche +fictif, donc les prix exacts ne sont pas copies d'une chaine reelle). -Calibration: the Maxi surcharge is in the ~1.50 EUR range for this model (derived from -internal data; cross-checked against market magnitude for plausibility. Wakdo is a fictional -pastiche so exact prices are not copied from a real chain). +Calibration : le supplement Maxi est dans la fourchette ~1,50 EUR pour ce modele (derive de +donnees internes ; recoupe avec l'ordre de grandeur du marche pour plausibilite. Wakdo est un pastiche +fictif, donc les prix exacts ne sont pas copies d'une chaine reelle). -### Note 8 — Image storage: path in VARCHAR vs BLOB in DB +### Note 8 — Stockage des images : chemin en VARCHAR vs BLOB en BDD -`image_path` columns (`category`, `product`, `menu`) store a **relative path** from the -public root (e.g., `/uploads/products/classic-burger.jpg`), not an absolute server path. -PHP resolves via a prefix from `.env` (`UPLOAD_DIR=public/uploads`). +Les colonnes `image_path` (`category`, `product`, `menu`) stockent un **chemin relatif** depuis la +racine publique (ex. `/uploads/products/classic-burger.jpg`), pas un chemin serveur absolu. +PHP resout via un prefixe depuis `.env` (`UPLOAD_DIR=public/uploads`). -BLOB storage was considered and rejected: +Le stockage BLOB a ete considere et rejete : -| Criterion | `image_path` VARCHAR (chosen) | BLOB in DB | +| Critere | `image_path` VARCHAR (choisi) | BLOB en BDD | |---|---|---| -| Kiosk performance | Apache serves files in ms (OS cache) | PHP reads DB + streams, multiplied latency | -| HTTP caching | ETag, Last-Modified, browser cache, CDN native | must be reimplemented in PHP | -| DB backup size | Megabytes (paths only) | Gigabytes (66 products x ~200 KB + responsive variants) | -| Image pipeline | `convert`, `webp`, optimization = standard filesystem tools | must be reinvented in PHP | +| Performance borne | Apache sert les fichiers en ms (cache OS) | PHP lit la BDD + streame, latence multipliee | +| Cache HTTP | ETag, Last-Modified, cache navigateur, CDN natifs | doit etre reimplemente en PHP | +| Taille de backup BDD | Megaoctets (chemins seulement) | Gigaoctets (66 produits x ~200 Ko + variantes responsive) | +| Pipeline d'images | `convert`, `webp`, optimisation = outils standard du systeme de fichiers | doit etre reinvente en PHP | -Sources: OWASP File Upload Cheat Sheet; MariaDB Knowledge Base — LONGBLOB performance; -Apache HTTP Server documentation — serving static content. +Sources : OWASP File Upload Cheat Sheet ; MariaDB Knowledge Base — LONGBLOB performance ; +documentation Apache HTTP Server — serving static content. -### Note 9 — VAT rule in French fast-food (fact-checked) +### Note 9 — Regle de TVA dans le fast-food francais (fact-checked) ``` FACT-CHECK @@ -688,98 +788,170 @@ Actual rule : 10% for immediate consumption (dine-in OR hot takeaway); Confidence : 95% (L1, official text) ``` -**Model consequence**: VAT rate is an attribute of the `product` (`vat_rate` in per-mille: -100 = 10%, 55 = 5.5%), not of the order or the service mode. Default: 100 (10%). -The 5.5% rate applies to products in resealable containers (bottled water, juice bottles). -VAT is computed line by line; the rate is snapshotted on `order_item.vat_rate_snapshot` -at transaction time to preserve historical integrity if legislation changes. +**Consequence sur le modele** : le taux de TVA est un attribut du `product` (`vat_rate` en pour-mille : +100 = 10%, 55 = 5,5%), pas de la commande ni du mode de service. Defaut : 100 (10%). +Le taux de 5,5% s'applique aux produits en contenants refermables (eau en bouteille, bouteilles de jus). +La TVA est calculee ligne par ligne ; le taux est snapshote sur `order_item.vat_rate_snapshot` +au moment de la transaction pour preserver l'integrite historique si la legislation change. -`service_mode` is retained on `customer_order` for stats and KPI only (capacity planning, -per-mode revenue breakdown). It has no fiscal computation role. +`service_mode` est conserve sur `customer_order` pour les stats et le KPI uniquement (planification de capacite, +repartition du chiffre d'affaires par mode). Il n'a aucun role de calcul fiscal. -### Note 10 — Ingredient configurator and modifier attachment +### Note 10 — Configurateur d'ingredients et rattachement du modificateur -`order_item_modifier` attaches to an `order_item` row via `order_item_id`, regardless of -whether the line is a standalone product or a menu. +`order_item_modifier` se rattache a une ligne `order_item` via `order_item_id`, que +la ligne soit un produit autonome ou un menu. -For a **standalone product** (`item_type='product'`): `order_item_id` directly identifies -the product being modified. +Pour un **produit autonome** (`item_type='product'`) : `order_item_id` identifie directement +le produit modifie. -For a **menu** (`item_type='menu'`): the modifiable product is the fixed burger, identified -via `order_item.menu_id -> menu.burger_product_id`. The kitchen display resolves: +Pour un **menu** (`item_type='menu'`) : le produit modifiable est le burger fixe, identifie +via `order_item.menu_id -> menu.burger_product_id`. L'affichage cuisine resout : `modifier.order_item_id -> order_item -> menu -> menu.burger_product_id -> product.name`. -No additional FK column is needed on `order_item_modifier`. This keeps the modifier table -simple and avoids a nullable `target_product_id` column that would only be populated for -menu lines. +Aucune colonne FK supplementaire n'est necessaire sur `order_item_modifier`. Cela garde la table modificateur +simple et evite une colonne nullable `target_product_id` qui ne serait peuplee que pour les +lignes de menu. -Constraint enforced at app layer: `order_item_modifier` rows for a menu line reference -only ingredients belonging to `menu.burger_product_id` via `product_ingredient`. +Contrainte appliquee au niveau applicatif : les lignes `order_item_modifier` pour une ligne de menu referencent +uniquement des ingredients appartenant a `menu.burger_product_id` via `product_ingredient`. -### Note 11 — `menu_slot` eligibility: category filter vs explicit product list +### Note 11 — Eligibilite `menu_slot` : filtre par categorie vs liste de produits explicite -Two options were considered: -- **Category filter**: `menu_slot.category_id` points to a category; all products in that - category are eligible. Simple, but a category may contain products not offered in this slot - (e.g., a premium drink added to the "drinks" category should not automatically appear in - all menu slots). -- **Explicit product list** `menu_slot_option(menu_slot_id, product_id)` (chosen): each - eligible product is listed explicitly per slot. More verbose at seed time but precise — - no accidental eligibility when the catalogue grows. Enables per-slot pricing overrides - in the future without structural change. +Deux options ont ete considerees : +- **Filtre par categorie** : `menu_slot.category_id` pointe vers une categorie ; tous les produits de cette + categorie sont eligibles. Simple, mais une categorie peut contenir des produits non proposes dans ce slot + (ex. une boisson premium ajoutee a la categorie "drinks" ne devrait pas apparaitre automatiquement dans + tous les slots de menu). +- **Liste de produits explicite** `menu_slot_option(menu_slot_id, product_id)` (choisie) : chaque + produit eligible est liste explicitement par slot. Plus verbeux au moment du seed mais precis — + pas d'eligibilite accidentelle quand le catalogue grandit. Permet des overrides de tarification par slot + a l'avenir sans changement structurel. -The explicit list adds one entity (`menu_slot_option`, entity 3.5) but eliminates a class -of correctness bugs. Consistent with the prod-like ambition of this model. +La liste explicite ajoute une entite (`menu_slot_option`, entite 3.5) mais elimine une classe +de bugs de justesse. Coherent avec l'ambition prod-like de ce modele. -### Note 12 — `commande_event` dropped +### Note 12 — `commande_event` retire -v0.1 carried a `commande_event` append-only audit table (event sourcing pattern). -Dropped in v0.2 (Decision 1, `revue-alignement-p1.md` §7). +v0.1 portait une table d'audit append-only `commande_event` (pattern event sourcing). +Retiree en v0.2 (Decision 1, `revue-alignement-p1.md` §7). -Rationale: in a restaurant context, the back-office account is shared per workstation, not -individual. Per-person attribution of a state transition has no business value. The actual -need (phase durations, time-of-day stats) is covered by phase timestamps on `customer_order` -(`paid_at`, `delivered_at`, `cancelled_at`) without the complexity of an event store. +Rationale : dans un contexte restaurant, le compte back-office est partage par poste de travail, non +individuel. L'attribution par personne d'une transition d'etat n'a aucune valeur metier. Le besoin reel +(durees de phase, stats par heure de la journee) est couvert par les timestamps de phase sur `customer_order` +(`paid_at`, `delivered_at`, `cancelled_at`) sans la complexite d'un event store. -The 4-state machine combined with 3 phase timestamps provides all KPI data needed: -- Time-to-deliver: `delivered_at - paid_at` -- Cancellation rate and timing: `cancelled_at - created_at` -- Volume by hour: `HOUR(created_at)` / `service_day` computation +La machine a 4 etats combinee a 3 timestamps de phase fournit toutes les donnees KPI necessaires : +- Temps de remise : `delivered_at - paid_at` +- Taux et timing d'annulation : `cancelled_at - created_at` +- Volume par heure : calcul `HOUR(created_at)` / `service_day` -For stock audit, `stock_movement` (entity 3.19) provides the append-only audit trail -where it is genuinely needed (inventory reconciliation). +Pour l'audit de stock, `stock_movement` (entite 3.19) fournit la trace d'audit append-only +la ou elle est genuinement necessaire (reconciliation d'inventaire). + +### Note 13 — Ajouts de donnees security-by-design (2026-06-11) + +Ces ajouts etendent le modele prod-like avec une couche security-by-design. Ils ne +remplacent aucune decision v0.2 ; ils ajoutent imputabilite, cycle de vie d'auth et resistance a l'abus. + +**Imputabilite — compte partage hybride + PIN.** Les sessions back-office restent partagees par +poste de travail pour le flux de routine (un terminal fast-food est partage, les `equipiers` tournent). Un +PIN par membre du personnel (`user.pin_hash`, argon2id) autorise un ensemble defini d'**actions sensibles** +(editions prix/menu 8.2/8.3/8.6, annulation de commande 7.1, correction d'inventaire 9.2, gestion +des utilisateurs 10.1-10.3, RBAC 10.4). Ces actions ecrivent le `user_id` agissant dans `audit_log` +(3.20). Cela resout la justification circulaire qui avait retire `commande_event` en v0.1 +(les events etaient juges inutiles parce que les comptes etaient partages) : l'imputabilite est enregistree +la ou elle importe, a friction quasi nulle pour les 95% de routine. `customer_order.acting_user_id` +capture le personnel pour les commandes counter/drive prises sous PIN ; les commandes borne restent anonymes. + +**Cycle de vie d'auth.** `password_reset_token_hash` + `password_reset_expires_at` permettent un parcours +de reset (le token est stocke hashe, le token brut est envoye par e-mail une seule fois). La resistance au brute-force utilise +un throttling degressif plutot qu'un verrouillage dur indefini : `failed_login_attempts` + +`lockout_until` implementent un backoff degressif par (compte + IP source), de sorte qu'une serie +de fautes de frappe ne verrouille pas toute une cuisine en plein service (15 h continues). Les logins echoues sont +ecrits dans `audit_log`. + +**Anonymisation RGPD vs retention d'audit.** Les PII de `user` (`email`, `first_name`, `last_name`) +sont soumises au droit a l'effacement (Cr 3.d). L'effacement **anonymise** plutot qu'il ne supprime durement : +la ligne est conservee, `email` devient un placeholder unique non identifiant (`anon-@wakdo.invalid`, +domaine reserve RFC 2606), les noms sont effaces, `password_hash`/`pin_hash` sont invalides, et +`anonymized_at` est renseigne. `audit_log` conserve sa propre fenetre de retention (~12 mois, +interet legitime / tracabilite fiscale) et continue de pointer vers le principal anonymise, de sorte que +effacement et imputabilite coexistent sans casser l'integrite referentielle. + +**Resistance a l'abus sur la borne anonyme.** `customer_order.idempotency_key` (UUID client, +UNIQUE) deduplique un `POST /api/orders` reessaye de sorte qu'un retry reseau ne cree pas de +commande payee dupliquee. Le stock est decremente avec une seule instruction atomique +(`UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id`) : aucune operation +ne depend d'une lecture de stock, donc la ligne s'auto-verrouille pour la duree de l'ecriture — pas de lost update et +pas de souci d'ordre des deadlocks. Cela remplace l'approche pessimiste anterieure `SELECT ... FOR UPDATE` +(regle de la couche traitement, voir `mlt.md`) ; elle n'ajoute aucune colonne ici. + +**Modele de stock en pourcentage + disponibilite calculee.** `ingredient` porte `stock_capacity` (la +reference 100%), `low_stock_pct` (bande d’alerte) et `critical_stock_pct` (seuil de rupture +automatique) — voir 3.6. `stock_quantity` est signe et peut devenir negatif (ampleur de survente remontee aux +managers) ; le systeme ne bloque pas une commande sur le stock. La commandabilite effective du produit est +calculee (regle RG-T21 dans `mlt.md`) : `product.is_available = 1` ET chaque ingredient non retirable +(`is_removable=0`) de son `product_ingredient` a +`stock_quantity > stock_capacity * critical_stock_pct/100`. A la bande critique, un produit +passe automatiquement en rupture sans ecriture ni cascade ; un retrait manuel (`product.is_available=0`) est +une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le produit a nouveau commandable de lui-meme. + +**Throttle anti-brute-force par IP.** `login_throttle` (3.21) suit `failed_attempts` et +`lockout_until` par IP source (une ligne upsertee par IP), completant le compteur par compte +sur `user`. Cela ajoute une seconde dimension de throttling, de sorte qu'une seule IP martelant de nombreux comptes soit +ralentie independamment du compteur de n'importe quel compte. Un cron quotidien purge les lignes inactives et non verrouillees. + +**Throttle du PIN d'action sensible (par acteur).** `pin_throttle` (3.22) suit `failed_attempts` et +`lockout_until` par utilisateur AGISSANT (l'identite de session qui valide une action sensible), +dans une table separee des compteurs de connexion. La dimension est l'acteur (et non l'email cible, +contournable par rotation, ni l'IP, qui penaliserait tous les equipiers d'un poste partage) ; le verrou +est un backoff degressif aux bornes propres (PIN_THROTTLE_*). Meme purge cron que `login_throttle`. RG-T22. + +References : `docs/notes/revue-alignement-p1.md` §7 (decisions D), carte d'impact security-by-design +(2026-06-11). Modele de menace et matrice de classification des donnees : `PROJECT_CONTEXT.md` §19 (a venir). --- -## 5. Entity count summary +## 5. Synthese du decompte des entites -| # | Entity | Type | Replaces / new | +| # | Entite | Type | Remplace / nouveau | |---|---|---|---| -| 1 | `category` | business | v0.1 `categorie` (renamed + translated) | +| 1 | `category` | business | v0.1 `categorie` (renommee + traduite) | | 2 | `product` | business | v0.1 `produit` (+ `vat_rate`) | -| 3 | `menu` | business | v0.1 `menu` (+ burger FK, 2 prices) | -| 4 | `menu_slot` | business | new — replaces `menu_produit` fixed composition | -| 5 | `menu_slot_option` | join | new — eligibility list per slot | -| 6 | `ingredient` | business | new — ingredient configurator + stock | -| 7 | `product_ingredient` | join | new — recipe + customization metadata | -| 8 | `allergen` | reference | new — INCO 1169/2011 | -| 9 | `ingredient_allergen` | join | new — maps allergens to ingredients | -| 10 | `customer_order` | business | v0.1 `commande` (renamed, 4-state machine, phase timestamps) | +| 3 | `menu` | business | v0.1 `menu` (+ FK burger, 2 prix) | +| 4 | `menu_slot` | business | nouveau — remplace la composition fixe `menu_produit` | +| 5 | `menu_slot_option` | join | nouveau — liste d'eligibilite par slot | +| 6 | `ingredient` | business | nouveau — configurateur d'ingredients + stock | +| 7 | `product_ingredient` | join | nouveau — recette + metadonnees de personnalisation | +| 8 | `allergen` | reference | nouveau — INCO 1169/2011 | +| 9 | `ingredient_allergen` | join | nouveau — mappe les allergenes aux ingredients | +| 10 | `customer_order` | business | v0.1 `commande` (renommee, machine a 4 etats, timestamps de phase) | | 11 | `order_item` | business | v0.1 `ligne_commande` (+ format, vat_rate_snapshot) | -| 12 | `order_item_selection` | business | new — customer menu slot choices | -| 13 | `order_item_modifier` | business | new — ingredient-level modifications | -| 14 | `user` | business | v0.1 `user` (translated field names) | +| 12 | `order_item_selection` | business | nouveau — choix de slot de menu du client | +| 13 | `order_item_modifier` | business | nouveau — modifications au niveau ingredient | +| 14 | `user` | business | v0.1 `user` (noms de champs traduits) | | 15 | `role` | business | v0.1 `role` (+ default_route, order_source) | -| 16 | `role_visible_source` | join | new — per-role dashboard filter | -| 17 | `permission` | reference | v0.1 `permission` (translated, catalogue frozen) | -| 18 | `role_permission` | join | v0.1 `role_permission` (unchanged) | -| 19 | `stock_movement` | audit | new — append-only stock audit log | +| 16 | `role_visible_source` | join | nouveau — filtre de tableau de bord par role | +| 17 | `permission` | reference | v0.1 `permission` (traduite, catalogue gele) | +| 18 | `role_permission` | join | v0.1 `role_permission` (inchangee) | +| 19 | `stock_movement` | audit | nouveau — journal d'audit de stock append-only | +| 20 | `audit_log` | audit | nouveau (security-by-design) — journal append-only d'actions sensibles | +| 21 | `login_throttle` | security | nouveau (security-by-design) - throttle anti-brute-force par IP | +| 22 | `pin_throttle` | security | nouveau (security-by-design) - throttle du PIN d'action sensible par acteur (RG-T22) | -**Dropped from v0.1**: `commande_event` (replaced by phase timestamps on `customer_order`), -`menu_produit` (replaced by `menu_slot` + `menu_slot_option` model). +**Retire de v0.1** : `commande_event` (remplace par les timestamps de phase sur `customer_order`), +`menu_produit` (remplace par le modele `menu_slot` + `menu_slot_option`). -**Total: 19 entities.** +**Total : 22 entites** (19 prod-like v0.2 + `audit_log`, `login_throttle` et `pin_throttle` +de la couche security-by-design). + +Le security-by-design ajoute aussi des colonnes (au-dela des deux nouvelles entites) : cycle de vie d'auth de `user` + +`pin_hash` + `anonymized_at` (3.14), `customer_order.acting_user_id` + `idempotency_key` (3.10), +et le modele de stock en pourcentage sur `ingredient` (3.6) — `stock_capacity`, `critical_stock_pct`, +plus le renommage de `low_stock_threshold` en `low_stock_pct`. `login_throttle` (3.21) est la 21e +entite et `pin_throttle` (3.22) la 22e. Voir note 13. --- -*For the ER diagram and cardinality justifications, see [`mcd.md`](mcd.md) — the diagram is -the single source of truth for graphical representation.* +*Pour le diagramme ER et les justifications de cardinalite, voir [`mcd.md`](mcd.md) — le diagramme est +la source de verite unique pour la representation graphique.* diff --git a/docs/merise/mcd.md b/docs/merise/mcd.md index 80fcbf9..20a667c 100644 --- a/docs/merise/mcd.md +++ b/docs/merise/mcd.md @@ -1,88 +1,99 @@ -# Conceptual Data Model (MCD) — Wakdo +# Modele Conceptuel de Donnees (MCD) — Wakdo -**Merise phase** : P1 - Conception, step 2 (data dictionary first, mantra #33) -**Version** : v0.2 — prod-like, 19 entities -**Date** : 2026-06-04 -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 2 (data dictionary first, mantra #33) +**Version** : v0.3 — prod-like, 22 entites (19 prod-like + couche security-by-design) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design (audit_log + colonnes imputabilite/auth) en cours +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose of this document +## 1. Objectif de ce document -The MCD (Modele Conceptuel des Donnees) formalises the **entities** of the Wakdo domain, -their **associations**, and the **cardinalities** governing those associations. -It is the normalised translation of the data dictionary, and serves as the basis for the -MLD (relational mapping). +Le MCD (Modele Conceptuel des Donnees) formalise les **entites** du domaine Wakdo, +leurs **associations**, et les **cardinalites** qui regissent ces associations. +C'est la traduction normalisee du dictionnaire de donnees, et il sert de base au +MLD (mapping relationnel). -Unlike the dictionary (which details attributes and types), the MCD focuses on relational -structure: how many X per Y, whether participation is mandatory, whether associations carry -their own attributes. +Contrairement au dictionnaire (qui detaille les attributs et les types), le MCD se concentre sur la +structure relationnelle : combien de X par Y, si la participation est obligatoire, si les associations portent +leurs propres attributs. -**Sources**: -- `docs/merise/dictionary.md` (v0.2 — 19 entities, source of truth for all names, types, ENUMs) -- `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock) -- `docs/PROJECT_CONTEXT.md` (business rules: menu composition, order flow, RBAC, service modes) -- `docs/merise/_sources/` (school data: 9 categories, 53 products, 13 menus) +**Sources** : +- `docs/merise/dictionary.md` (v0.3 — 22 entites, source de verite pour tous les noms, types, ENUMs) +- `docs/notes/revue-alignement-p1.md` §7 (table de decisions D1-D8 + stock) +- `docs/PROJECT_CONTEXT.md` (regles metier : composition de menu, flux de commande, RBAC, modes de service) +- `docs/merise/_sources/` (donnees de l'ecole : 9 categories, 53 produits, 13 menus) --- -## 2. Merise notation used +## 2. Notation Merise utilisee -### Cardinalities at the association foot (French Merise style) +### Cardinalites au pied de l'association (style Merise francais) -At each end of an association, the cardinality `(min,max)` states how many times an -instance of the entity participates in the association. +A chaque extremite d'une association, la cardinalite `(min,max)` indique combien de fois une +instance de l'entite participe a l'association. ``` ENTITY_A (min,max) ----[ ASSOCIATION ]---- (min,max) ENTITY_B ``` -| Notation | Reading | Example | +| Notation | Lecture | Exemple | |---|---|---| -| `(0,1)` | Optional, at most 1 | A stock_movement links to (0,1) customer_order | -| `(1,1)` | Mandatory, exactly 1 | A product belongs to (1,1) category | -| `(0,N)` | Optional, unbounded | A category groups (0,N) products | -| `(1,N)` | At least 1, unbounded | An order contains (1,N) order_items | +| `(0,1)` | Optionnel, au plus 1 | Un stock_movement est lie a (0,1) customer_order | +| `(1,1)` | Obligatoire, exactement 1 | Un product appartient a (1,1) category | +| `(0,N)` | Optionnel, non borne | Une category regroupe (0,N) products | +| `(1,N)` | Au moins 1, non borne | Une commande contient (1,N) order_items | -Reading: "one instance of the source entity participates at least MIN times and at most -MAX times in the association". +Lecture : "une instance de l'entite source participe au moins MIN fois et au plus +MAX fois a l'association". -### Association naming convention +### Convention de nommage des associations -Active verb in business terms, e.g.: `groups`, `anchors`, `defines_slot`, `contains`, +Verbe d'action en termes metier, par exemple : `groups`, `anchors`, `defines_slot`, `contains`, `references_product`, `references_menu`, `fills_slot`, `modifies_ingredient`, `logs`, `holds`, `grants`, `filters_source`, `decrements`. -N-N associations that carry their own attributes become **associative entities** in the MLD -(join table with own columns). +Les associations N-N qui portent leurs propres attributs deviennent des **entites associatives** dans le MLD +(table de jointure avec colonnes propres). --- -## 3. Decomposition by sub-domain +## 3. Decomposition par sous-domaine -The 19-entity model is split into 4 sub-domains for readability. Beyond approximately -5 entities, a single flat diagram becomes difficult to read; decomposition is the standard -Merise practice for models of this size. +Le modele de 22 entites est divise en 4 sous-domaines pour la lisibilite. Au-dela d'environ +5 entites, un diagramme plat unique devient difficile a lire ; la decomposition est la pratique +Merise standard pour les modeles de cette taille. -| Sub-domain | Entities | Count | +| Sous-domaine | Entites | Nombre | |---|---|---| | Catalogue | category, product, menu, menu_slot, menu_slot_option | 5 | | Ingredients & Stock | ingredient, product_ingredient, allergen, ingredient_allergen, stock_movement | 5 | | Order | customer_order, order_item, order_item_selection, order_item_modifier | 4 | -| RBAC | user, role, role_visible_source, permission, role_permission | 5 | +| RBAC & Audit | user, role, role_visible_source, permission, role_permission, audit_log, login_throttle, pin_throttle | 8 | -**Note on the absence of a global diagram**: a single 19-entity ER diagram would be -unreadable and unmaintainable. The sub-domain decomposition below is the intentional -structural choice. The `.drawio` source files will be regenerated from this document as the -single reference once the MCD is stabilised (regeneration tracked in `docs/notes/`). +> **Couche security-by-design (2026-06-11)** : `audit_log` (entite 20) est un journal transverse, +> append-only des actions sensibles ; il est place dans le sous-domaine RBAC & Audit parce que +> ses references (`actor_user_id`, `actor_role_id`) sont des entites RBAC. `login_throttle` +> (entite 21) est un throttle anti-brute-force par IP source, indexe par IP et ne portant aucune FK ; il se situe +> dans le meme sous-domaine parce qu'il protege le chemin d'authentification. `pin_throttle` (entite 22, +> RG-T22) est un throttle du PIN d'action sensible par utilisateur AGISSANT (FK `actor_user_id -> user`, +> ON DELETE CASCADE), compteurs separes du login. Nouvelles colonnes sur des entites existantes : +> `user` cycle de vie auth + `pin_hash` + `anonymized_at`, `customer_order.acting_user_id` +> + `idempotency_key`. Voir note 13 du dictionnaire. + +**Note sur l'absence d'un diagramme global** : un unique diagramme ER de 22 entites serait +illisible et impossible a maintenir. La decomposition par sous-domaine ci-dessous est le choix +structurel intentionnel. Chaque sous-domaine est un `erDiagram` Mermaid (faisant autorite, rendu +nativement) avec un rendu SVG portable dans `docs/merise/_diagrams/` ; voir la section 11 pour les +sources et la commande de regeneration. --- -## 4. Sub-domain: Catalogue +## 4. Sous-domaine : Catalogue -### 4.1 Mermaid entity-relationship diagram +### 4.1 Diagramme entite-relation Mermaid ```mermaid erDiagram @@ -138,30 +149,30 @@ erDiagram product ||--o{ menu_slot_option : "is_eligible_for" ``` -### 4.2 Association cardinalities +### 4.2 Cardinalites des associations -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| C1 | groups (product) | category | (0,N) | product | (1,1) | A category can exist with no products yet (created empty). A product must belong to exactly one category to appear on the kiosk. | -| C2 | groups (menu) | category | (0,N) | menu | (1,1) | Same rationale as C1 for menus. All 13 menus belong to the `menus` category. | -| C3 | anchors | menu | (1,1) | product | (0,N) | Each menu is built around exactly one fixed burger product (`burger_product_id`). A product may anchor 0 or more menus (a burger not used in a menu yet; or a popular burger anchoring several formats). | -| C4 | defines_slot | menu | (1,N) | menu_slot | (1,1) | A menu must define at least one slot (drink, side, sauce) to have customisable composition. A slot belongs to exactly one menu. | -| C5 | lists | menu_slot | (1,N) | menu_slot_option | (1,1) | A slot must list at least one eligible product (otherwise the customer cannot fill it). Each option row belongs to exactly one slot. | -| C6 | is_eligible_for | product | (0,N) | menu_slot_option | (1,1) | A product may be eligible for any number of slots across all menus, or none if it is only sold a la carte. Each option row references exactly one product. | +| C1 | groups (product) | category | (0,N) | product | (1,1) | Une categorie peut exister sans aucun produit pour l'instant (creee vide). Un produit doit appartenir a exactement une categorie pour apparaitre sur la borne. | +| C2 | groups (menu) | category | (0,N) | menu | (1,1) | Meme raisonnement que C1 pour les menus. Les 13 menus appartiennent a la categorie `menus`. | +| C3 | anchors | menu | (1,1) | product | (0,N) | Chaque menu est construit autour d'exactement un produit burger fixe (`burger_product_id`). Un produit peut ancrer 0 ou plusieurs menus (un burger pas encore utilise dans un menu ; ou un burger populaire ancrant plusieurs formats). | +| C4 | defines_slot | menu | (1,N) | menu_slot | (1,1) | Un menu doit definir au moins un slot (boisson, accompagnement, sauce) pour avoir une composition personnalisable. Un slot appartient a exactement un menu. | +| C5 | lists | menu_slot | (1,N) | menu_slot_option | (1,1) | Un slot doit lister au moins un produit eligible (sinon le client ne peut pas le remplir). Chaque ligne d'option appartient a exactement un slot. | +| C6 | is_eligible_for | product | (0,N) | menu_slot_option | (1,1) | Un produit peut etre eligible pour un nombre quelconque de slots a travers tous les menus, ou aucun s'il n'est vendu qu'a la carte. Chaque ligne d'option reference exactement un produit. | -### 4.3 Notes on the Catalogue sub-domain +### 4.3 Notes sur le sous-domaine Catalogue -**`menu_slot` vs category filter**: the explicit eligibility list `menu_slot_option(menu_slot_id, product_id)` was chosen over a category-based filter (`menu_slot.category_id`). Rationale: a product added to the `drinks` category should not automatically appear in every drink slot of every menu. The explicit list avoids accidental eligibility when the catalogue grows (see dictionary note 11). +**`menu_slot` vs filtre par categorie** : la liste d'eligibilite explicite `menu_slot_option(menu_slot_id, product_id)` a ete choisie plutot qu'un filtre base sur la categorie (`menu_slot.category_id`). Raisonnement : un produit ajoute a la categorie `drinks` ne devrait pas apparaitre automatiquement dans chaque slot boisson de chaque menu. La liste explicite evite une eligibilite accidentelle quand le catalogue s'agrandit (voir note 11 du dictionnaire). -**`menu.burger_product_id` as anchor**: the menu references a specific burger product, not a generic slot. This allows the ingredient configurator (sub-domain Ingredients & Stock) to resolve which ingredients are modifiable for a menu line, via `menu -> burger_product_id -> product_ingredient`. +**`menu.burger_product_id` comme ancre** : le menu reference un produit burger specifique, pas un slot generique. Cela permet au configurateur d'ingredients (sous-domaine Ingredients & Stock) de resoudre quels ingredients sont modifiables pour une ligne de menu, via `menu -> burger_product_id -> product_ingredient`. -**Normal / Maxi format**: two prices (`price_normal_cents`, `price_maxi_cents`) on `menu`; format recorded at `order_item.format`. No individual slot-level price differential is stored (see dictionary note 7). +**Format Normal / Maxi** : deux prix (`price_normal_cents`, `price_maxi_cents`) sur `menu` ; format enregistre au niveau de `order_item.format`. Aucun differentiel de prix au niveau du slot individuel n'est stocke (voir note 7 du dictionnaire). --- -## 5. Sub-domain: Ingredients & Stock +## 5. Sous-domaine : Ingredients & Stock -### 5.1 Mermaid entity-relationship diagram +### 5.1 Diagramme entite-relation Mermaid ```mermaid erDiagram @@ -174,15 +185,18 @@ erDiagram varchar name varchar unit int stock_quantity + int stock_capacity smallint pack_size varchar pack_label - smallint low_stock_threshold + smallint low_stock_pct + smallint critical_stock_pct tinyint is_active } product_ingredient { int product_id FK int ingredient_id FK - smallint quantity + smallint quantity_normal + smallint quantity_maxi tinyint is_removable tinyint is_addable int extra_price_cents @@ -224,40 +238,44 @@ erDiagram user |o--o{ stock_movement : "logs" ``` -### 5.2 Association cardinalities +### 5.2 Cardinalites des associations -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| I1 | is_composed_of | product | (0,N) | product_ingredient | (1,1) | A product may have no ingredients entered in the system yet (catalogue row exists before recipe is entered). A recipe row belongs to exactly one product. | -| I2 | appears_in | ingredient | (1,N) | product_ingredient | (1,1) | An ingredient in active use appears in at least one product recipe. Each recipe row references exactly one ingredient. Newly created ingredients with no recipe row yet are modelled as (0,N) from a pure structural standpoint; the business rule of (1,N) applies to ingredients in production use. | -| I3 | contains (allergens) | ingredient | (0,N) | ingredient_allergen | (1,1) | An ingredient may contain no regulated allergens (e.g., pure salt). Each allergen-link row belongs to one ingredient. | -| I4 | is_present_in | allergen | (0,N) | ingredient_allergen | (1,1) | An allergen may initially have no linked ingredients (seed: allergen catalogue is complete before recipe data is entered). Each link row references one allergen. | -| I5 | decrements | ingredient | (0,N) | stock_movement | (1,1) | All movements affect exactly one ingredient. An ingredient may have no stock movement rows yet if it was recently created and no orders have been placed. Each movement row references exactly one ingredient. | -| I6 | triggers | customer_order | (0,1) | stock_movement | (0,N) | A `sale` or `cancellation` movement references the originating order. A `restock` or `inventory_correction` has no order (NULL). A given order triggers movements across all its ingredients; an order still `pending_payment` has triggered no movement yet. | -| I7 | logs | user | (0,1) | stock_movement | (0,N) | Automated sale decrements have no user (NULL). Manual restocks and corrections are attributed to a user. A user may log any number of movements. | +| I1 | is_composed_of | product | (0,N) | product_ingredient | (1,1) | Un produit peut n'avoir aucun ingredient encore saisi dans le systeme (la ligne de catalogue existe avant que la recette ne soit saisie). Une ligne de recette appartient a exactement un produit. | +| I2 | appears_in | ingredient | (1,N) | product_ingredient | (1,1) | Un ingredient en usage actif apparait dans au moins une recette de produit. Chaque ligne de recette reference exactement un ingredient. Les ingredients nouvellement crees sans ligne de recette sont modelises en (0,N) d'un point de vue purement structurel ; la regle metier de (1,N) s'applique aux ingredients en usage de production. | +| I3 | contains (allergens) | ingredient | (0,N) | ingredient_allergen | (1,1) | Un ingredient peut ne contenir aucun allergene reglemente (par exemple, du sel pur). Chaque ligne de lien d'allergene appartient a un ingredient. | +| I4 | is_present_in | allergen | (0,N) | ingredient_allergen | (1,1) | Un allergene peut initialement n'avoir aucun ingredient lie (seed : le catalogue d'allergenes est complet avant que les donnees de recette ne soient saisies). Chaque ligne de lien reference un allergene. | +| I5 | decrements | ingredient | (0,N) | stock_movement | (1,1) | Tous les mouvements affectent exactement un ingredient. Un ingredient peut n'avoir encore aucune ligne de mouvement de stock s'il a ete cree recemment et qu'aucune commande n'a ete passee. Chaque ligne de mouvement reference exactement un ingredient. | +| I6 | triggers | customer_order | (0,1) | stock_movement | (0,N) | Un mouvement `sale` ou `cancellation` reference la commande d'origine. Un `restock` ou `inventory_correction` n'a pas de commande (NULL). Une commande donnee declenche des mouvements sur tous ses ingredients ; une commande encore `pending_payment` n'a declenche aucun mouvement. | +| I7 | logs | user | (0,1) | stock_movement | (0,N) | Les decrements de vente automatises n'ont pas d'utilisateur (NULL). Les reapprovisionnements et corrections manuels sont attribues a un utilisateur. Un utilisateur peut journaliser un nombre quelconque de mouvements. | -### 5.3 Notes on the Ingredients & Stock sub-domain +### 5.3 Notes sur le sous-domaine Ingredients & Stock -**`product_ingredient` as an associative entity**: the N-N association between `product` and `ingredient` carries four attributes (`quantity`, `is_removable`, `is_addable`, `extra_price_cents`). It becomes a join table in the MLD with composite PK `(product_id, ingredient_id)`. +**`product_ingredient` comme entite associative** : l'association N-N entre `product` et `ingredient` porte cinq attributs (`quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`). Elle devient une table de jointure dans le MLD avec une PK composite `(product_id, ingredient_id)`. -**`ingredient_allergen` as a pure join table**: no own attributes. The allergen set for a product is computed at query time by joining `product_ingredient -> ingredient_allergen -> allergen`; no manual per-product entry is needed. +**`ingredient_allergen` comme table de jointure pure** : aucun attribut propre. L'ensemble des allergenes d'un produit est calcule au moment de la requete en joignant `product_ingredient -> ingredient_allergen -> allergen` ; aucune saisie manuelle par produit n'est necessaire. -**`stock_movement` immutability**: this table is append-only. No UPDATE or DELETE is permitted at application layer. Corrections are new rows with `movement_type = 'inventory_correction'` and a signed `delta`. +**Immuabilite de `stock_movement`** : cette table est append-only. Aucun UPDATE ni DELETE n'est autorise au niveau applicatif. Les corrections sont de nouvelles lignes avec `movement_type = 'inventory_correction'` et un `delta` signe. -**Low-stock alert**: computed at display time (`stock_quantity <= low_stock_threshold`); no additional stored column. +**Modele de stock base sur les pourcentages** : la sante du stock est ancree sur une `stock_capacity` par ingredient (la reference 100%, `CHECK > 0`). `stock_quantity` est signe et peut devenir negatif quand les ventes depassent le stock compte ; le systeme ne bloque pas une commande sur une lecture de stock bas. `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` est calcule, pas stocke. Deux seuils en pourcentage pilotent un comportement a trois bandes : `low_stock_pct` (bande d'alerte, defaut 10%) et `critical_stock_pct` (plancher de mise en rupture automatique, defaut 5%), avec l'invariant au niveau de la table `critical_stock_pct < low_stock_pct`. Au-dessus de la bande d'alerte, c'est normal ; entre critique et bas, le produit reste commandable et une alerte manager est levee (le manager soit retire le produit via `product.is_available = 0`, soit reapprovisionne pour lever l'alerte) ; au niveau ou en dessous de la bande critique, le produit passe automatiquement en rupture (calcule, voir ci-dessous). + +**Disponibilite produit calculee (regle RG-T21, voir `mlt.md`)** : la commandabilite effective est derivee, pas stockee. Un produit est commandable quand `product.is_available = 1` ET que chaque ingredient non retirable (`is_removable = 0`) de son `product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct / 100`. Un ingredient requis atteignant la bande critique met le produit en rupture automatique sans ecriture et sans cascade ; un retrait manuel (`product.is_available = 0`) est une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le produit commandable a nouveau de lui-meme. Un ingredient retirable/optionnel a la bande critique ne bloque pas le produit (seul son supplement devient indisponible). Le tableau de bord distingue un retrait manuel d'une rupture pilotee par le stock. --- -## 6. Sub-domain: Order +## 6. Sous-domaine : Order -### 6.1 Mermaid entity-relationship diagram +### 6.1 Diagramme entite-relation Mermaid ```mermaid erDiagram customer_order { int id PK varchar order_number + varchar idempotency_key enum source + int acting_user_id FK enum service_mode enum status int total_ht_cents @@ -320,49 +338,55 @@ erDiagram ingredient ||--o{ order_item_modifier : "modified_by" ``` -### 6.2 Association cardinalities +### 6.2 Cardinalites des associations -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| O1 | contains | customer_order | (1,N) | order_item | (1,1) | An order without at least one line has no business meaning. A line belongs to exactly one order. ON DELETE CASCADE: if the order is purged, its lines go with it. | -| O2 | references_product | order_item | (0,1) | product | (0,N) | When `item_type = 'product'`, `product_id` is non-null (1 product referenced). When `item_type = 'menu'`, `product_id` is NULL (0). A product may appear in any number of order lines across history. | -| O3 | references_menu | order_item | (0,1) | menu | (0,N) | Symmetric to O2 for the menu discriminator branch. Exactly one of O2/O3 is active per line (CHECK constraint in MLD). | -| O4 | fills_slot | order_item | (0,N) | order_item_selection | (1,1) | A `menu`-type order line has one selection per slot (typically 2-3). A `product`-type line has no selections (0). Each selection row belongs to exactly one order line. | -| O5 | slot_filled_by | menu_slot | (0,N) | order_item_selection | (1,1) | A slot definition may have been chosen many times across historical orders (0,N). Each selection row references exactly one slot. ON DELETE RESTRICT: preserves historical records if the slot definition is later changed. | -| O6 | chosen_for_slot | product | (0,N) | order_item_selection | (1,1) | A product may have been selected for many slot choices across history. Each selection references one product. | -| O7 | modifies_ingredient | order_item | (0,N) | order_item_modifier | (1,1) | An order line may have any number of ingredient modifications (remove onion, add cheese). Each modifier row belongs to one order line. | -| O8 | modified_by | ingredient | (0,N) | order_item_modifier | (1,1) | An ingredient may have been modified in many order lines across history. Each modifier references one ingredient. | +| O1 | contains | customer_order | (1,N) | order_item | (1,1) | Une commande sans au moins une ligne n'a aucun sens metier. Une ligne appartient a exactement une commande. ON DELETE CASCADE : si la commande est purgee, ses lignes partent avec elle. | +| O2 | references_product | order_item | (0,1) | product | (0,N) | Quand `item_type = 'product'`, `product_id` est non nul (1 produit reference). Quand `item_type = 'menu'`, `product_id` est NULL (0). Un produit peut apparaitre dans un nombre quelconque de lignes de commande a travers l'historique. | +| O3 | references_menu | order_item | (0,1) | menu | (0,N) | Symetrique a O2 pour la branche du discriminateur menu. Exactement un de O2/O3 est actif par ligne (contrainte CHECK dans le MLD). | +| O4 | fills_slot | order_item | (0,N) | order_item_selection | (1,1) | Une ligne de commande de type `menu` a une selection par slot (typiquement 2-3). Une ligne de type `product` n'a aucune selection (0). Chaque ligne de selection appartient a exactement une ligne de commande. | +| O5 | slot_filled_by | menu_slot | (0,N) | order_item_selection | (1,1) | Une definition de slot peut avoir ete choisie de nombreuses fois a travers les commandes historiques (0,N). Chaque ligne de selection reference exactement un slot. ON DELETE RESTRICT : preserve les enregistrements historiques si la definition de slot est modifiee ulterieurement. | +| O6 | chosen_for_slot | product | (0,N) | order_item_selection | (1,1) | Un produit peut avoir ete selectionne pour de nombreux choix de slot a travers l'historique. Chaque selection reference un produit. | +| O7 | modifies_ingredient | order_item | (0,N) | order_item_modifier | (1,1) | Une ligne de commande peut avoir un nombre quelconque de modifications d'ingredients (retirer l'oignon, ajouter du fromage). Chaque ligne de modificateur appartient a une ligne de commande. | +| O8 | modified_by | ingredient | (0,N) | order_item_modifier | (1,1) | Un ingredient peut avoir ete modifie dans de nombreuses lignes de commande a travers l'historique. Chaque modificateur reference un ingredient. | -### 6.3 Notes on the Order sub-domain +### 6.3 Notes sur le sous-domaine Order -**Polymorphism on `order_item`**: each line references either a `product` or a `menu` (not both, not neither). The discriminator `item_type` ENUM drives which FK is populated. The mutual exclusivity is enforced by a CHECK constraint in the MLD. This pattern (2 nullable FKs + discriminator + CHECK) is a standard relational approach to single-table inheritance without a separate table per type. +**Polymorphisme sur `order_item`** : chaque ligne reference soit un `product`, soit un `menu` (ni les deux, ni aucun). Le discriminateur `item_type` ENUM pilote quelle FK est renseignee. L'exclusivite mutuelle est imposee par une contrainte CHECK dans le MLD. Ce pattern (2 FK nullables + discriminateur + CHECK) est une approche relationnelle standard de l'heritage en table unique sans table separee par type. -**`order_item_selection` (menu slot choices)**: captures which product the customer chose for each slot of a menu line. One row per slot filled. Used for KPI analysis (most popular drink/side combinations). The `label_snapshot` preserves the product name at transaction time. +**`order_item_selection` (choix de slot de menu)** : capture quel produit le client a choisi pour chaque slot d'une ligne de menu. Une ligne par slot rempli. Utilise pour l'analyse de KPI (combinaisons boisson/accompagnement les plus populaires). Le `label_snapshot` preserve le nom du produit au moment de la transaction. -**`order_item_modifier` (ingredient modifications)**: attaches to an `order_item` regardless of whether the line is a standalone product or a menu. For a menu line, the modifiable product is the fixed burger, resolved via `order_item.menu_id -> menu.burger_product_id` (see dictionary note 10). No additional FK column is needed on `order_item_modifier`. +**`order_item_modifier` (modifications d'ingredients)** : se rattache a un `order_item` que la ligne soit un produit autonome ou un menu. Pour une ligne de menu, le produit modifiable est le burger fixe, resolu via `order_item.menu_id -> menu.burger_product_id` (voir note 10 du dictionnaire). Aucune colonne FK supplementaire n'est necessaire sur `order_item_modifier`. -**Price snapshots**: `label_snapshot`, `unit_price_cents_snapshot`, and `vat_rate_snapshot` on `order_item` preserve the state at transaction time. If a product is later renamed or repriced, historical order data remains consistent. ON DELETE RESTRICT on `product_id` and `menu_id` is a secondary safeguard. +**Snapshots de prix** : `label_snapshot`, `unit_price_cents_snapshot`, et `vat_rate_snapshot` sur `order_item` preservent l'etat au moment de la transaction. Si un produit est ulterieurement renomme ou retarife, les donnees de commande historiques restent coherentes. ON DELETE RESTRICT sur `product_id` et `menu_id` est une protection secondaire. -**`service_day` computation** (KPI grouping): not stored as a column. Computed at query time: +**Calcul de `service_day`** (regroupement KPI) : non stocke comme colonne. Calcule au moment de la requete : ```sql CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END ``` -Cutoff: 10:00. The generated-column formula with `INTERVAL 4 HOUR 30 MINUTE` from the v0.1 MLD -was incorrect and is dropped (decision D6, `revue-alignement-p1.md` §7). +Seuil : 10:00. La formule de colonne generee avec `INTERVAL 4 HOUR 30 MINUTE` du MLD v0.1 +etait incorrecte et est abandonnee (decision D6, `revue-alignement-p1.md` §7). -**`source = 'drive' => service_mode = 'drive'`**: cross-constraint. A drive-channel order can -only have `service_mode = 'drive'`. Enforced at application layer (and optionally as a CHECK in -the MLD). +**`source = 'drive' => service_mode = 'drive'`** : contrainte croisee. Une commande du canal drive ne peut +avoir que `service_mode = 'drive'`. Imposee au niveau applicatif (et optionnellement comme CHECK dans +le MLD). -**4-state machine** (`pending_payment -> paid -> delivered` + `cancelled`): -`preparing` and `ready` are dropped (decision D4, `revue-alignement-p1.md` §7). KPI timing is -`delivered_at - paid_at`; KDS colour coding is computed from `NOW() - paid_at`. +**Machine a 4 etats** (`pending_payment -> paid -> delivered` + `cancelled`) : +`preparing` et `ready` sont abandonnes (decision D4, `revue-alignement-p1.md` §7). Le timing KPI est +`delivered_at - paid_at` ; le codage couleur KDS est calcule a partir de `NOW() - paid_at`. + +**Colonnes security-by-design (2026-06-11)** : `idempotency_key` (UUID client, UNIQUE) +deduplique un `POST /api/orders` rejoue. `acting_user_id` (FK -> `user`, ON DELETE SET NULL) +enregistre l'employe de comptoir/drive qui a pris la commande sous PIN ; NULL pour les commandes anonymes de la borne. +Cela ajoute une association `customer_order |o--o| user : "taken_by"` (cardinalite : une commande est +prise par (0,1) user ; un user prend (0,N) commandes). Voir note 13 du dictionnaire. --- -## 7. Sub-domain: RBAC +## 7. Sous-domaine : RBAC -### 7.1 Mermaid entity-relationship diagram +### 7.1 Diagramme entite-relation Mermaid ```mermaid erDiagram @@ -370,11 +394,15 @@ erDiagram int id PK varchar email varchar password_hash + varchar pin_hash varchar first_name varchar last_name int role_id FK tinyint is_active datetime last_login_at + smallint failed_login_attempts + datetime lockout_until + datetime anonymized_at } role { int id PK @@ -399,135 +427,224 @@ erDiagram int role_id FK int permission_id FK } + audit_log { + int id PK + int actor_user_id FK + int actor_role_id FK + varchar action_code + varchar entity_type + int entity_id + varchar summary + json details + datetime created_at + } + login_throttle { + int id PK + varchar ip_address UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } + pin_throttle { + int id PK + int actor_user_id FK,UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } user }o--|| role : "holds" role ||--o{ role_visible_source : "sees_source" role ||--o{ role_permission : "grants" permission ||--o{ role_permission : "granted_to" + user |o--o{ audit_log : "performs" + role |o--o{ audit_log : "context_of" + user ||--o{ pin_throttle : "pin_throttled_as" ``` -### 7.2 Association cardinalities +> `login_throttle` est une entite autonome sans association : elle est indexee par IP source +> (`ip_address UNIQUE`), pas par un acteur modelise, donc elle ne porte aucune FK et ne se connecte a aucune +> autre entite du diagramme. `pin_throttle` (RG-T22), au contraire, est cle par l'utilisateur AGISSANT +> (`actor_user_id UNIQUE`, FK -> `user` ON DELETE CASCADE) : c'est la dimension qui rend le throttle du PIN +> non contournable par rotation d'email et sans collateral sur un poste partage. -| # | Association | Side A | Cardinality A | Side B | Cardinality B | Justification | +### 7.2 Cardinalites des associations + +| # | Association | Cote A | Cardinalite A | Cote B | Cardinalite B | Justification | |---|---|---|---|---|---|---| -| R1 | holds | user | (1,1) | role | (0,N) | A user must have exactly one role to access the back-office. A role may have no current users (created but not yet assigned). ON DELETE RESTRICT on `role_id`: a role cannot be deleted while users hold it. | -| R2 | sees_source | role | (0,N) | role_visible_source | (1,1) | A role may see 0 or more order sources on the preparation dashboard (admin/manager use a global view with no source filter). Each visibility row belongs to exactly one role. | -| R3 | grants | role | (0,N) | role_permission | (1,1) | A role may have no permissions (a newly created role before assignment) or many. Each mapping row belongs to one role. | -| R4 | granted_to | permission | (0,N) | role_permission | (1,1) | A permission may be granted to no roles yet (declared at seed, not yet distributed) or to several. Each mapping row references one permission. | +| R1 | holds | user | (1,1) | role | (0,N) | Un utilisateur doit avoir exactement un role pour acceder au back-office. Un role peut n'avoir aucun utilisateur actuel (cree mais pas encore assigne). ON DELETE RESTRICT sur `role_id` : un role ne peut etre supprime tant que des utilisateurs le detiennent. | +| R2 | sees_source | role | (0,N) | role_visible_source | (1,1) | Un role peut voir 0 ou plusieurs sources de commande sur le tableau de bord de preparation (admin/manager utilisent une vue globale sans filtre de source). Chaque ligne de visibilite appartient a exactement un role. | +| R3 | grants | role | (0,N) | role_permission | (1,1) | Un role peut n'avoir aucune permission (un role nouvellement cree avant assignation) ou plusieurs. Chaque ligne de mapping appartient a un role. | +| R4 | granted_to | permission | (0,N) | role_permission | (1,1) | Une permission peut n'etre encore accordee a aucun role (declaree au seed, pas encore distribuee) ou a plusieurs. Chaque ligne de mapping reference une permission. | +| R5 | performs | user | (0,1) | audit_log | (0,N) | Une action sensible capturee sous PIN enregistre son utilisateur agissant ; les entrees automatisees/non attribuables portent NULL. Un utilisateur peut avoir journalise un nombre quelconque d'actions. ON DELETE SET NULL preserve la trace lors de l'anonymisation/suppression de l'utilisateur. | +| R6 | context_of | role | (0,1) | audit_log | (0,N) | Chaque ligne d'audit peut denormaliser le role de l'acteur au moment de l'action (NULL autorise). Un role peut etre le contexte de nombreuses lignes d'audit. ON DELETE SET NULL preserve la trace. | +| R9 | pin_throttled_as | user | (1,1) | pin_throttle | (0,1) | Throttle du PIN d'action sensible (RG-T22) : au plus une ligne `pin_throttle` par utilisateur agissant (cle UNIQUE `actor_user_id`), creee au premier echec et upsertee ensuite. ON DELETE CASCADE : l'etat de throttle (ephemere) part avec le compte supprime/anonymise. | -### 7.3 Notes on the RBAC sub-domain +### 7.3 Notes sur le sous-domaine RBAC -**RBAC architecture**: roles are dynamic (creatable and modifiable via admin UI). Permissions are static (declared in migration, tied to application code). Application code tests permissions, not role names: adding a new role with the right permissions requires no code change (permission-driven, per Sandhu/NIST RBAC model — decision D4, `revue-alignement-p1.md` §7). +**Architecture RBAC** : les roles sont dynamiques (creables et modifiables via l'UI admin). Les permissions sont statiques (declarees en migration, liees au code applicatif). Le code applicatif teste les permissions, pas les noms de role : ajouter un nouveau role avec les bonnes permissions ne necessite aucun changement de code (permission-driven, selon le modele RBAC Sandhu/NIST — decision D4, `revue-alignement-p1.md` §7). -**`role.order_source`**: when a counter or drive staff member creates an order, the `source` column on `customer_order` is automatically populated from their role's `order_source`. NULL for admin and manager (they can create on behalf of any channel). +**`role.order_source`** : quand un employe de comptoir ou de drive cree une commande, la colonne `source` sur `customer_order` est automatiquement renseignee a partir de l'`order_source` de son role. NULL pour admin et manager (ils peuvent creer pour le compte de n'importe quel canal). -**`role.default_route`**: the landing screen for each role, stored in the database. Front-end routing reads this value at login; no role name is hardcoded in routing logic. +**`role.default_route`** : l'ecran d'arrivee pour chaque role, stocke en base de donnees. Le routage front-end lit cette valeur au login ; aucun nom de role n'est code en dur dans la logique de routage. -**`role_visible_source`**: a pure join table linking a role to the set of order sources visible on the preparation dashboard. A `kitchen` role sees all three sources; a `counter` role sees `kiosk` and `counter`; a `drive` role sees only `drive`. +**`role_visible_source`** : une table de jointure pure liant un role a l'ensemble des sources de commande visibles sur le tableau de bord de preparation. Un role `kitchen` voit les trois sources ; un role `counter` voit `kiosk` et `counter` ; un role `drive` ne voit que `drive`. -**`role_permission`** and **`role_visible_source`** both use composite PKs. ON DELETE CASCADE on both FKs of `role_permission` (deleting a role or a permission removes its mappings). ON DELETE CASCADE on `role_id` of `role_visible_source`. +**`role_permission`** et **`role_visible_source`** utilisent tous deux des PK composites. ON DELETE CASCADE sur les deux FK de `role_permission` (supprimer un role ou une permission retire ses mappings). ON DELETE CASCADE sur le `role_id` de `role_visible_source`. -**Seed roles** (5 roles, frozen at DDL; extendable without code change): +**Roles de seed** (5 roles, figes au DDL ; extensibles sans changement de code) : `admin`, `manager`, `kitchen`, `counter`, `drive`. +**`audit_log` (security-by-design)** : journal append-only des actions sensibles, immuable comme +`stock_movement`. Les deux FK (`actor_user_id`, `actor_role_id`) sont nullables avec ON DELETE +SET NULL, de sorte que la trace survit a l'anonymisation de l'utilisateur (RGPD) et a la suppression de role. Le `actor_role_id` +est denormalise a dessein : meme si l'utilisateur est ulterieurement anonymise, le contexte de role de +l'action est preserve. Il ne porte aucune PII (le JSON `details` stocke les noms des champs modifies, pas les +valeurs pour les actions ciblant un utilisateur). Voir dictionnaire 3.20 et note 13. + +**`login_throttle` (security-by-design)** : throttle anti-brute-force par IP source, complementant +le compteur par compte deja present sur `user` (`failed_login_attempts` / `lockout_until`). Une ligne +par IP (`ip_address VARCHAR(45) UNIQUE`, 45 caracteres pour contenir un litteral IPv6 complet), upsertee a chaque +echec de login : `failed_attempts` compte les echecs consecutifs depuis cette IP dans la fenetre courante, +`window_started_at` marque le debut de cette fenetre (qui se reinitialise a son expiration), `lockout_until` +contient la fin du backoff degressif (NULL = non throttle), `last_attempt_at` l'horodatage +de la derniere tentative echouee. Elle n'a aucune FK (une IP n'est pas une entite modelisee) et aucune association. Un +cron quotidien purge les lignes sans lockout actif dont le `last_attempt_at` est plus ancien que 24h. Voir +dictionnaire 3.21 et note 13. + +**`pin_throttle` (security-by-design, RG-T22)** : throttle du PIN d'action sensible, distinct du throttle +de connexion. La dimension est l'utilisateur AGISSANT (l'identite de session qui soumet email+PIN), pas +l'email cible (contournable par rotation) ni l'IP (qui penaliserait tous les equipiers d'un poste partage). +Une ligne par acteur (`actor_user_id UNIQUE`, FK -> `user` ON DELETE CASCADE), upsertee a chaque echec hors +verrou ; memes colonnes que `login_throttle` mais des bornes propres (PIN_THROTTLE_*, plus permissives). +Compteurs physiquement separes du login : un echec de PIN n'incremente aucun compteur de connexion. Meme +purge cron quotidienne. Association R9 (`user` 1 -- 0,N `pin_throttle`). Voir dictionnaire 3.22 et note 13. + --- -## 8. Cross-validation MCD <-> dictionary +## 8. Validation croisee MCD <-> dictionnaire -Verification that all 19 dictionary entities appear in the MCD and vice versa. +Verification que les 22 entites du dictionnaire apparaissent dans le MCD et reciproquement. -| # | Dictionary entity (section 3) | Sub-domain in MCD | Present | +| # | Entite du dictionnaire (section 3) | Sous-domaine dans le MCD | Presente | |---|---|---|---| -| 1 | `category` (3.1) | Catalogue | Yes | -| 2 | `product` (3.2) | Catalogue + Ingredients + Order | Yes | -| 3 | `menu` (3.3) | Catalogue + Order | Yes | -| 4 | `menu_slot` (3.4) | Catalogue + Order | Yes | -| 5 | `menu_slot_option` (3.5) | Catalogue | Yes | -| 6 | `ingredient` (3.6) | Ingredients + Order | Yes | -| 7 | `product_ingredient` (3.7) | Ingredients | Yes | -| 8 | `allergen` (3.8) | Ingredients | Yes | -| 9 | `ingredient_allergen` (3.9) | Ingredients | Yes | -| 10 | `customer_order` (3.10) | Order | Yes | -| 11 | `order_item` (3.11) | Order | Yes | -| 12 | `order_item_selection` (3.12) | Order | Yes | -| 13 | `order_item_modifier` (3.13) | Order | Yes | -| 14 | `user` (3.14) | RBAC | Yes | -| 15 | `role` (3.15) | RBAC | Yes | -| 16 | `role_visible_source` (3.16) | RBAC | Yes | -| 17 | `permission` (3.17) | RBAC | Yes | -| 18 | `role_permission` (3.18) | RBAC | Yes | -| 19 | `stock_movement` (3.19) | Ingredients & Stock | Yes | +| 1 | `category` (3.1) | Catalogue | Oui | +| 2 | `product` (3.2) | Catalogue + Ingredients + Order | Oui | +| 3 | `menu` (3.3) | Catalogue + Order | Oui | +| 4 | `menu_slot` (3.4) | Catalogue + Order | Oui | +| 5 | `menu_slot_option` (3.5) | Catalogue | Oui | +| 6 | `ingredient` (3.6) | Ingredients + Order | Oui | +| 7 | `product_ingredient` (3.7) | Ingredients | Oui | +| 8 | `allergen` (3.8) | Ingredients | Oui | +| 9 | `ingredient_allergen` (3.9) | Ingredients | Oui | +| 10 | `customer_order` (3.10) | Order | Oui | +| 11 | `order_item` (3.11) | Order | Oui | +| 12 | `order_item_selection` (3.12) | Order | Oui | +| 13 | `order_item_modifier` (3.13) | Order | Oui | +| 14 | `user` (3.14) | RBAC | Oui | +| 15 | `role` (3.15) | RBAC | Oui | +| 16 | `role_visible_source` (3.16) | RBAC | Oui | +| 17 | `permission` (3.17) | RBAC | Oui | +| 18 | `role_permission` (3.18) | RBAC | Oui | +| 19 | `stock_movement` (3.19) | Ingredients & Stock | Oui | +| 20 | `audit_log` (3.20) | RBAC & Audit | Oui | +| 21 | `login_throttle` (3.21) | RBAC & Audit | Oui | +| 22 | `pin_throttle` (3.22) | RBAC & Audit | Oui | -**Result**: 19/19 entities traced. No entity from the dictionary is absent from the MCD. -No entity in the MCD falls outside the dictionary. +**Resultat** : 22/22 entites tracees (19 prod-like + `audit_log`, `login_throttle` et `pin_throttle` +security-by-design). Aucune entite du dictionnaire n'est absente du MCD. Aucune entite du MCD +ne tombe en dehors du dictionnaire. -**Entities appearing in multiple sub-domains** (cross-domain shared entities): -- `product`: Catalogue (sold item, slot eligibility) + Ingredients (recipe) + Order (line reference, slot choice) -- `menu`: Catalogue (definition, slots) + Order (line reference) -- `menu_slot`: Catalogue (slot definition) + Order (slot choices via `order_item_selection`) -- `ingredient`: Ingredients (recipe, stock) + Order (modifiers) -- `customer_order`: Order (order lifecycle) + Ingredients (stock movement trigger) -- `user`: RBAC (authentication) + Ingredients (stock movement author) +**Entites apparaissant dans plusieurs sous-domaines** (entites partagees inter-domaines) : +- `product` : Catalogue (article vendu, eligibilite de slot) + Ingredients (recette) + Order (reference de ligne, choix de slot) +- `menu` : Catalogue (definition, slots) + Order (reference de ligne) +- `menu_slot` : Catalogue (definition de slot) + Order (choix de slot via `order_item_selection`) +- `ingredient` : Ingredients (recette, stock) + Order (modificateurs) +- `customer_order` : Order (cycle de vie de la commande) + Ingredients (declencheur de mouvement de stock) + RBAC & Audit (employe taken_by via `acting_user_id`) +- `user` : RBAC (authentification) + Ingredients (auteur de mouvement de stock) + Order (`acting_user_id` sur les commandes comptoir/drive) + Audit (acteur de `audit_log`) +- `role` : RBAC (permissions, sources visibles) + Audit (contexte `actor_role_id` denormalise sur `audit_log`) -This is expected in a normalised model. The sub-domain split is for readability; the actual -relational schema is a unified graph. +C'est attendu dans un modele normalise. La division par sous-domaine est pour la lisibilite ; le schema +relationnel reel est un graphe unifie. --- -## 9. Decisions deferred to the MLD +## 9. Decisions reportees au MLD -The MCD remains at the conceptual level. The following decisions are deferred to the MLD: +Le MCD reste au niveau conceptuel. Les decisions suivantes sont reportees au MLD : -1. **Resolution of associative entities into tables**: `product_ingredient`, `menu_slot_option`, - `ingredient_allergen`, `role_visible_source`, `role_permission` become join tables with - composite PKs. -2. **Technical PK vs business identifier**: `id INT UNSIGNED AUTO_INCREMENT` on all main entities. - `customer_order` additionally carries `order_number VARCHAR(20) UNIQUE` (human-readable, - format `K/C/D-YYYY-MM-DD-NNN` per channel). -3. **ON DELETE rules**: CASCADE vs RESTRICT vs SET NULL. Detailed in the MLD. -4. **CHECK constraints**: polymorphism exclusivity on `order_item`, cross-constraint - `source/service_mode` on `customer_order`, arithmetic invariant on totals. -5. **Indexes**: not discussed at MCD level. Defined in the MLD for frequent query patterns. -6. **`service_day` formula**: applicative CASE expression, not a stored generated column. - Documented in the MLD. +1. **Resolution des entites associatives en tables** : `product_ingredient`, `menu_slot_option`, + `ingredient_allergen`, `role_visible_source`, `role_permission` deviennent des tables de jointure avec + des PK composites. +2. **PK technique vs identifiant metier** : `id INT UNSIGNED AUTO_INCREMENT` sur toutes les entites principales. + `customer_order` porte en plus `order_number VARCHAR(20) UNIQUE` (lisible par un humain, + format `K/C/D-YYYY-MM-DD-NNN` par canal). +3. **Regles ON DELETE** : CASCADE vs RESTRICT vs SET NULL. Detaillees dans le MLD. +4. **Contraintes CHECK** : exclusivite de polymorphisme sur `order_item`, contrainte croisee + `source/service_mode` sur `customer_order`, invariant arithmetique sur les totaux. +5. **Index** : non discutes au niveau MCD. Definis dans le MLD pour les patterns de requete frequents. +6. **Formule `service_day`** : expression applicative CASE, pas une colonne generee stockee. + Documentee dans le MLD. --- -## 10. MCD <-> MCT coherence (mantra #34) +## 10. Coherence MCD <-> MCT (mantra #34) -Pre-validation: each entity participates in at least one treatment. +Pre-validation : chaque entite participe a au moins un traitement. -| Entity | Expected treatment(s) | +| Entite | Traitement(s) attendu(s) | |---|---| -| `category` | Admin CRUD | -| `product` | Admin CRUD + kiosk cart add | -| `menu` | Admin CRUD + kiosk cart add | -| `menu_slot` | Admin CRUD (menu composition) | -| `menu_slot_option` | Admin CRUD (slot eligibility management) | -| `ingredient` | Admin CRUD + stock movements | -| `product_ingredient` | Admin recipe management | -| `allergen` | Admin CRUD (seed: read-only catalogue) | -| `ingredient_allergen` | Admin allergen mapping | -| `customer_order` | Full order lifecycle (create -> pay -> deliver / cancel) | -| `order_item` | Cart building, line creation at validation | -| `order_item_selection` | Menu slot selection during cart building | -| `order_item_modifier` | Ingredient modification during cart building | -| `user` | Admin CRUD + login | -| `role` | Admin CRUD + user assignment | -| `role_visible_source` | Admin role configuration | -| `permission` | Admin permission matrix management | -| `role_permission` | Admin permission matrix management | -| `stock_movement` | Automatic at `paid` transition; manual restock and inventory correction | +| `category` | CRUD admin | +| `product` | CRUD admin + ajout au panier borne | +| `menu` | CRUD admin + ajout au panier borne | +| `menu_slot` | CRUD admin (composition de menu) | +| `menu_slot_option` | CRUD admin (gestion de l'eligibilite des slots) | +| `ingredient` | CRUD admin + mouvements de stock | +| `product_ingredient` | Gestion des recettes admin | +| `allergen` | CRUD admin (seed : catalogue en lecture seule) | +| `ingredient_allergen` | Mapping des allergenes admin | +| `customer_order` | Cycle de vie complet de la commande (create -> pay -> deliver / cancel) | +| `order_item` | Construction du panier, creation de ligne a la validation | +| `order_item_selection` | Selection de slot de menu pendant la construction du panier | +| `order_item_modifier` | Modification d'ingredient pendant la construction du panier | +| `user` | CRUD admin + login | +| `role` | CRUD admin + assignation d'utilisateur | +| `role_visible_source` | Configuration de role admin | +| `permission` | Gestion de la matrice de permissions admin | +| `role_permission` | Gestion de la matrice de permissions admin | +| `stock_movement` | Automatique a la transition `paid` ; reapprovisionnement manuel et correction d'inventaire | +| `audit_log` | Ecrit par les operations sensibles : UPDATE/DELETE product/menu (8.2/8.3/8.6), CANCEL_ORDER (7.1), RESTOCK/INVENTORY_COUNT (9.1/9.2), operations utilisateur (10.1-10.3), MANAGE_RBAC (10.4), et logins echoues/reussis (12.1) | +| `login_throttle` | Lu et ecrit par AUTHENTICATE_USER (12.1) : throttle par IP source upserte a chaque echec de login, lu pour imposer la fenetre de backoff, purge par un cron quotidien | -Cross-validation MCD <-> MCT (mantra #34) to be completed exhaustively in `mct.md` -once the MCT is updated to the 4-state machine and 19-entity model. +La validation croisee MCD <-> MCT (mantra #34) sera completee de maniere exhaustive dans `mct.md` +une fois que le MCT integrera les operations security-by-design (actions sensibles protegees par PIN, +ecritures d'audit, reset/lockout, anonymisation). Les ajouts de la couche traitements y sont suivis. --- -## 11. Note on .drawio diagram regeneration +## 11. Sources des diagrammes et regeneration -The `.drawio` XML sources in `docs/merise/_diagrams/` reflect the v0.1 model (11 entities, -French naming). They are scheduled for regeneration from this v0.2 MCD as a separate task. -Until regenerated, this Markdown document is the authoritative conceptual model. The Mermaid -`erDiagram` blocks in sections 4-7 render natively on GitHub and serve as the interim -graphical reference. +Le modele graphique faisant autorite est l'ensemble des blocs `erDiagram` Mermaid des sections 4-7, +un par sous-domaine. Ils s'affichent nativement sur Forgejo et GitHub. Le MCD est decompose par +sous-domaine a dessein : un unique diagramme de 22 entites ne peut etre dispose sans croisement de +lignes de relation (limite de planarite intrinseque, et `erDiagram` n'offre aucun controle de mise en page +manuel). Chaque sous-domaine reste a 5-8 entites, ce que la mise en page automatique gere proprement. La +vue integree a travers les sous-domaines est la table de validation croisee de la section 8. + +Des rendus SVG portables se trouvent dans `docs/merise/_diagrams/` (pour l'export PDF / consultation hors ligne) : + +| Sous-domaine | Source | Rendu | +|---|---|---| +| Catalogue | `mcd-catalogue.mmd` | `mcd-catalogue.svg` | +| Ingredients & Stock | `mcd-ingredients-stock.mmd` | `mcd-ingredients-stock.svg` | +| Order | `mcd-order.mmd` | `mcd-order.svg` | +| RBAC | `mcd-rbac.mmd` | `mcd-rbac.svg` | + +Les fichiers `.mmd` sont extraits des blocs `erDiagram` ci-dessus ; les `.svg` sont produits par +`make docs-render` (mmdc). Si un bloc ici change, re-extraire le `.mmd` correspondant et relancer +`make docs-render`. Les anciennes sources `.drawio` v0.1 ont ete supprimees : drawio offrait un controle de mise en page +manuel mais necessitait une edition a la main et ne s'affichait pas dans les apercus Markdown, alors que +les blocs Mermaid decomposes sont versionnes, s'affichent partout, et restent synchronises avec +ce document. diff --git a/docs/merise/mct.md b/docs/merise/mct.md index 05b3935..0295db2 100644 --- a/docs/merise/mct.md +++ b/docs/merise/mct.md @@ -1,44 +1,44 @@ -# Model of Conceptual Treatments (MCT) — Wakdo +# Modele Conceptuel des Traitements (MCT) — Wakdo -**Merise phase** : P1 - Conception, step 3 (after MCD) -**Version** : v0.2 — prod-like, 4-state machine -**Date** : 2026-06-04 -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 3 (apres le MCD) +**Version** : v0.2 — prod-like, machine a 4 etats (+ couche security-by-design 2026-06-11) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; operations security-by-design ajoutees (ERASE_USER_PII, RESET_PASSWORD, ensemble sensible protege par PIN, ecritures audit_log, throttling d'authentification) — 28 operations +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose +## 1. Objectif -The MCT (Model of Conceptual Treatments) describes the **business operations** of the Wakdo -domain in the canonical Merise form: **triggering event -> operation -> emitted result**. +Le MCT (Modele Conceptuel des Traitements) decrit les **operations metier** du domaine +Wakdo sous la forme canonique Merise : **evenement declencheur -> operation -> resultat emis**. -It answers the question: what happens in the domain, and when? -It does not answer: who does what, on which workstation, in which organisational order -(the MOT level is intentionally skipped — agile shortcut, consistent with the solo RNCP -framework). +Il repond a la question : que se passe-t-il dans le domaine, et quand ? +Il ne repond pas a : qui fait quoi, sur quel poste de travail, dans quel ordre organisationnel +(le niveau MOT est volontairement saute — raccourci agile, coherent avec le cadre RNCP +solo). -The MCT covers: -- The order lifecycle end-to-end (kiosk, counter, drive) -- Catalogue management (manager / admin) -- User and role management (admin) -- Back-office authentication (all back-office actors) +Le MCT couvre : +- Le cycle de vie de la commande de bout en bout (kiosk, comptoir, drive) +- La gestion du catalogue (manager / admin) +- La gestion des utilisateurs et des roles (admin) +- L'authentification back-office (tous les acteurs back-office) -**Identified actors**: +**Acteurs identifies** : -| Actor | Code | Interface | +| Acteur | Code | Interface | |-------|------|-----------| -| Customer (kiosk) | CUSTOMER | Touch kiosk (public, unauthenticated) | -| Counter staff | COUNTER | Back-office, role `counter` | -| Drive staff | DRIVE | Back-office, role `drive` | -| Kitchen staff | KITCHEN | Back-office, role `kitchen` (read-only on orders) | +| Client (kiosk) | CUSTOMER | Borne tactile (public, non authentifie) | +| Personnel comptoir | COUNTER | Back-office, role `counter` | +| Personnel drive | DRIVE | Back-office, role `drive` | +| Personnel cuisine | KITCHEN | Back-office, role `kitchen` (lecture seule sur les commandes) | | Manager | MANAGER | Back-office, role `manager` | -| Administrator | ADMIN | Back-office, role `admin` | -| System | SYS | Internal API / PHP logic | +| Administrateur | ADMIN | Back-office, role `admin` | +| Systeme | SYS | API interne / logique PHP | -**MCD cross-reference**: each operation references entities from the MCD (section 14). -The MCT is consistent with the `customer_order.status` state machine: +**Reference croisee MCD** : chaque operation reference des entites du MCD (section 14). +Le MCT est coherent avec la machine a etats de `customer_order.status` : ``` pending_payment -> paid -> delivered @@ -46,21 +46,32 @@ pending_payment -> paid -> delivered +--------------+-----------> cancelled (from any non-terminal state) ``` -**Dropped states** (compared to v0.1): `preparing` and `ready` are removed. -Rationale: in a fast-food context the kitchen display (KDS) is a visual system; staff read -the ticket and act. The single staff gesture is "deliver". KPI is total time -`delivered_at - paid_at` (SLA approx. 10 min). KDS colour coding is computed from -`now - paid_at`; no additional stored state is required. +**Etats supprimes** (par rapport a v0.1) : `preparing` et `ready` sont retires. +Justification : dans un contexte fast-food, l'affichage cuisine (KDS) est un systeme visuel ; +le personnel lit le ticket et agit. L'unique geste du personnel est « delivrer ». Le KPI est +le temps total `delivered_at - paid_at` (SLA approx. 10 min). Le code couleur du KDS est calcule a partir de +`now - paid_at` ; aucun etat stocke supplementaire n'est requis. -**Dropped operations** (compared to v0.1): `MARK_IN_PREPARATION` (`MARQUER_EN_PREPARATION`) -and `MARK_READY` (`MARQUER_PRETE`) are removed because their intermediate states no longer -exist. `DELIVER_ORDER` becomes the sole status-advancing action for counter/drive staff. +**Operations supprimees** (par rapport a v0.1) : `MARK_IN_PREPARATION` (`MARQUER_EN_PREPARATION`) +et `MARK_READY` (`MARQUER_PRETE`) sont retirees car leurs etats intermediaires n'existent plus. +`DELIVER_ORDER` devient la seule action faisant avancer le statut pour le personnel comptoir/drive. + +**Couche security-by-design (2026-06-11)** : deux operations sont ajoutees — `RESET_PASSWORD` (12.3) +et `ERASE_USER_PII` (10.5, anonymisation RGPD). Un sous-ensemble d'operations est **protege par PIN** : +les sessions back-office restent partagees par poste de travail, mais un PIN par membre du personnel +re-autorise l'ensemble sensible — `CANCEL_ORDER` (7.1), `UPDATE_PRODUCT`/`DELETE_PRODUCT` (8.2/8.3), +`DELETE_MENU` (8.6), `INVENTORY_COUNT` (9.2), gestion des utilisateurs (10.1-10.3), `MANAGE_RBAC` +(10.4), `ERASE_USER_PII` (10.5). Ces actions hors stock ajoutent une ligne `audit_log` immuable +(acteur, action, cible) ; les actions de stock enregistrent l'attribution dans `stock_movement`. La logique +de traitement (PIN, audit, throttling, idempotence, decrement atomique du stock, disponibilite produit +calculee) est specifiee dans `mlt.md` (regles RG-T13-T21). Cela ajoute les entites 20 `audit_log` +et 21 `login_throttle` au modele. --- -## 2. Representation conventions +## 2. Conventions de representation -### Operation format +### Format des operations ``` [TRIGGERING EVENT(S)] @@ -73,437 +84,467 @@ exist. `DELIVER_ORDER` becomes the sole status-advancing action for counter/driv [EMITTED RESULT(S)] ``` -**Synchronisations**: -- `AND`: all events must be present simultaneously to trigger the operation. -- `OR`: any one of the events is sufficient. +**Synchronisations** : +- `AND` : tous les evenements doivent etre presents simultanement pour declencher l'operation. +- `OR` : l'un quelconque des evenements suffit. -**Conditions**: expressed in square brackets `[condition]` on the incoming arc. +**Conditions** : exprimees entre crochets `[condition]` sur l'arc entrant. -### Textual notation +### Notation textuelle -For each operation the document provides: -- **Triggering event(s)**: what occurs and causes the operation. -- **Actor(s)**: who initiates (or validates). -- **Synchronisation**: `AND` / `OR` if multiple events, plus condition. -- **Operation**: name and description of what it does. -- **MCD entities touched**: read (R) or write (W). -- **Result(s)**: what is emitted or produced. +Pour chaque operation, le document fournit : +- **Evenement(s) declencheur(s)** : ce qui survient et provoque l'operation. +- **Acteur(s)** : qui initie (ou valide). +- **Synchronisation** : `AND` / `OR` si plusieurs evenements, plus la condition. +- **Operation** : nom et description de ce qu'elle fait. +- **Entites MCD touchees** : lecture (R) ou ecriture (W). +- **Resultat(s)** : ce qui est emis ou produit. --- -## 3. Domain 1 — Order lifecycle (kiosk) +## 3. Domaine 1 — Cycle de vie de la commande (kiosk) ### 3.1 LOAD_CATALOGUE -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Customer opens the kiosk (connection to the kiosk endpoint) | -| **Actor** | CUSTOMER | -| **Synchronisation** | None (single event) | -| **Condition** | The kiosk is in service (within business hours 10:00-01:00) | +| **Evenement declencheur** | Le client ouvre le kiosk (connexion a l'endpoint du kiosk) | +| **Acteur** | CUSTOMER | +| **Synchronisation** | Aucune (evenement unique) | +| **Condition** | Le kiosk est en service (dans les horaires d'ouverture 10:00-01:00) | | **Operation** | LOAD_CATALOGUE | -| **Description** | Retrieval of active categories, available products, and available menus (with their slots and eligible options) for display on the kiosk screen. | -| **MCD entities** | R: `category` (is_active=1), `product` (is_available=1), `menu` (is_available=1), `menu_slot`, `menu_slot_option`, `ingredient` (is_active=1), `allergen`, `ingredient_allergen` | -| **Result** | Catalogue loaded; kiosk displays the home screen | +| **Description** | Recuperation des categories actives, des produits disponibles et des menus disponibles (avec leurs slots et options eligibles) pour affichage sur l'ecran du kiosk. La disponibilite des produits est CALCULEE : un produit est commandable seulement si son flag `is_available` est positionne ET que chaque ingredient non retirable (`is_removable=0`) de son `product_ingredient` est au-dessus de la bande critique (`stock_quantity > stock_capacity * critical_stock_pct/100`). Voir la regle RG-T21 dans `mlt.md`. | +| **Entites MCD** | R: `category` (is_active=1), `product` (is_available=1), `menu` (is_available=1), `menu_slot`, `menu_slot_option`, `ingredient` (is_active=1), `allergen`, `ingredient_allergen` | +| **Resultat** | Catalogue charge ; le kiosk affiche l'ecran d'accueil | --- ### 3.2 COMPOSE_CART -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Customer selects a product or a menu on the kiosk | -| **Actor** | CUSTOMER | -| **Synchronisation** | Repeatable event (OR: add product, add menu, change quantity, remove item, choose menu slot, choose format Normal/Maxi, add/remove ingredient modifier) | -| **Condition** | The selected product or menu has `is_available=1` | +| **Evenement declencheur** | Le client selectionne un produit ou un menu sur le kiosk | +| **Acteur** | CUSTOMER | +| **Synchronisation** | Evenement repetable (OR : ajouter produit, ajouter menu, modifier quantite, retirer un article, choisir un slot de menu, choisir le format Normal/Maxi, ajouter/retirer un modificateur d'ingredient) | +| **Condition** | Le produit ou le menu selectionne a `is_available=1` | | **Operation** | COMPOSE_CART | -| **Description** | In-memory cart construction: add an item (standalone product or menu), select slot products (`order_item_selection`), optionally modify ingredients (`order_item_modifier`), choose Normal or Maxi format for menus, recalculate TTC total. The cart is a volatile client-side structure; no database write at this stage. | -| **MCD entities** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: none (volatile front-end state) | -| **Result** | Cart updated, total recalculated, summary displayed | +| **Description** | Construction du panier en memoire : ajouter un article (produit autonome ou menu), selectionner les produits des slots (`order_item_selection`), modifier optionnellement les ingredients (`order_item_modifier`), choisir le format Normal ou Maxi pour les menus, recalculer le total TTC. Le panier est une structure volatile cote client ; aucune ecriture en base a ce stade. | +| **Entites MCD** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: aucune (etat volatile front-end) | +| **Resultat** | Panier mis a jour, total recalcule, recapitulatif affiche | --- ### 3.3 CREATE_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering events** | 1. Customer confirms cart (presses "Validate") AND 2. Customer enters their order number (RNCP payment substitute) | -| **Actor** | CUSTOMER | -| **Synchronisation** | AND (both actions required) | -| **Condition** | Cart contains at least 1 item. The order number entered is non-empty. | +| **Evenements declencheurs** | 1. Le client confirme le panier (appuie sur « Valider ») AND 2. Le client saisit son numero de commande (substitut de paiement RNCP) | +| **Acteur** | CUSTOMER | +| **Synchronisation** | AND (les deux actions requises) | +| **Condition** | Le panier contient au moins 1 article. Le numero de commande saisi est non vide. | | **Operation** | CREATE_ORDER | -| **Description** | Atomic order creation: INSERT `customer_order` with status `pending_payment`, source `kiosk`, snapshot of HT/VAT/TTC totals (computed line by line using `vat_rate` snapshotted per item). INSERT `order_item` lines with `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`. INSERT `order_item_selection` for each slot filled in a menu item. INSERT `order_item_modifier` for each ingredient modification. Decrement `ingredient.stock_quantity` for each ingredient consumed (adjusted by modifiers: remove => no decrement; add => extra decrement); INSERT one `stock_movement` row of type `sale` per affected ingredient unit. Stock decrements and order insert are within the same transaction. After the customer enters their order number, the status transitions `pending_payment -> paid` within the same transaction; `paid_at` is set. The system generates the order number in format `K-YYYY-MM-DD-NNN`. | -| **MCD entities** | R: `product`, `menu`, `ingredient`, `product_ingredient` (snapshot) — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item` (INSERT N lines), `order_item_selection` (INSERT per menu slot chosen), `order_item_modifier` (INSERT per modification), `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `sale` per unit) | -| **Result** | Order created (status `paid` at end of operation), order number displayed to customer, logical event ORDER_CREATED emitted toward the preparation domain | +| **Description** | Creation atomique de la commande : INSERT `customer_order` avec statut `pending_payment`, source `kiosk`, snapshot des totaux HT/TVA/TTC (calcules ligne par ligne en utilisant `vat_rate` snapshote par article). INSERT des lignes `order_item` avec `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`. INSERT `order_item_selection` pour chaque slot rempli dans un article de menu. INSERT `order_item_modifier` pour chaque modification d'ingredient. Decrement de `ingredient.stock_quantity` pour chaque ingredient consomme (ajuste par les modificateurs : retrait => pas de decrement ; ajout => decrement supplementaire) ; INSERT d'une ligne `stock_movement` de type `sale` par unite d'ingredient affectee. Les decrements de stock et l'insertion de la commande sont dans la meme transaction. Apres que le client a saisi son numero de commande, le statut passe `pending_payment -> paid` dans la meme transaction ; `paid_at` est positionne. Le systeme genere le numero de commande au format `K-YYYY-MM-DD-NNN`. | +| **Entites MCD** | R: `product`, `menu`, `ingredient`, `product_ingredient` (snapshot) — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item` (INSERT N lines), `order_item_selection` (INSERT per menu slot chosen), `order_item_modifier` (INSERT per modification), `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `sale` per unit) | +| **Resultat** | Commande creee (statut `paid` en fin d'operation), numero de commande affiche au client, evenement logique ORDER_CREATED emis vers le domaine de preparation | --- ### 3.4 DISPLAY_CONFIRMATION -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | ORDER_CREATED (API response 201 after CREATE_ORDER) | -| **Actor** | SYS | -| **Synchronisation** | None | -| **Condition** | API response contains an id, an order_number and status `paid` | +| **Evenement declencheur** | ORDER_CREATED (reponse API 201 apres CREATE_ORDER) | +| **Acteur** | SYS | +| **Synchronisation** | Aucune | +| **Condition** | La reponse API contient un id, un order_number et le statut `paid` | | **Operation** | DISPLAY_CONFIRMATION | -| **Description** | Display of the confirmation screen on the kiosk with the order number. The kiosk then resets for the next customer. | -| **MCD entities** | R: none (data is in the API response) | -| **Result** | Confirmation screen displayed; kiosk available for next order | +| **Description** | Affichage de l'ecran de confirmation sur le kiosk avec le numero de commande. Le kiosk se reinitialise ensuite pour le client suivant. | +| **Entites MCD** | R: aucune (les donnees sont dans la reponse API) | +| **Resultat** | Ecran de confirmation affiche ; kiosk disponible pour la commande suivante | --- -## 4. Domain 2 — Order lifecycle (counter and drive) +## 4. Domaine 2 — Cycle de vie de la commande (comptoir et drive) ### 4.1 CREATE_COUNTER_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | A counter or drive staff member initiates a new order from the back-office | -| **Actor** | COUNTER or DRIVE | -| **Synchronisation** | None | -| **Condition** | The actor is authenticated and holds permission `order.create`. The `source` is `counter` or `drive` (auto-tagged from `role.order_source`). | +| **Evenement declencheur** | Un membre du personnel comptoir ou drive initie une nouvelle commande depuis le back-office | +| **Acteur** | COUNTER ou DRIVE | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur est authentifie et detient la permission `order.create`. La `source` est `counter` ou `drive` (auto-taggee depuis `role.order_source`). | | **Operation** | CREATE_COUNTER_ORDER | -| **Description** | Manual order composition via the back-office: select products and menus, choose service mode (`dine_in`/`takeaway`/`drive`), fill menu slots, add ingredient modifiers. Identical creation logic to CREATE_ORDER (snapshot, stock decrement in same transaction, atomic `pending_payment -> paid` transition). The `source` is auto-tagged from `role.order_source` (counter -> `counter`, drive -> `drive`). Order number format: `C-YYYY-MM-DD-NNN` (counter) or `D-YYYY-MM-DD-NNN` (drive). Cross-constraint: if `source = 'drive'` then `service_mode = 'drive'` (verified at creation). | -| **MCD entities** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient` (stock decrement), `stock_movement` (INSERT type `sale`) | -| **Result** | Order created (status `paid`), order number communicated to customer | +| **Description** | Composition manuelle de la commande via le back-office : selectionner produits et menus, choisir le mode de service (`dine_in`/`takeaway`/`drive`), remplir les slots de menu, ajouter des modificateurs d'ingredient. Logique de creation identique a CREATE_ORDER (snapshot, decrement de stock dans la meme transaction, transition atomique `pending_payment -> paid`). La `source` est auto-taggee depuis `role.order_source` (counter -> `counter`, drive -> `drive`). Format du numero de commande : `C-YYYY-MM-DD-NNN` (comptoir) ou `D-YYYY-MM-DD-NNN` (drive). Contrainte croisee : si `source = 'drive'` alors `service_mode = 'drive'` (verifie a la creation). | +| **Entites MCD** | R: `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` — W: `customer_order` (INSERT status `pending_payment` then UPDATE status `paid`, `paid_at`), `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient` (stock decrement), `stock_movement` (INSERT type `sale`) | +| **Resultat** | Commande creee (statut `paid`), numero de commande communique au client | --- -## 5. Domain 3 — Preparation display (kitchen) +## 5. Domaine 3 — Affichage de preparation (cuisine) ### 5.1 LIST_ORDERS_DISPLAY -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Kitchen staff accesses or refreshes the preparation display | -| **Actor** | KITCHEN (or COUNTER, DRIVE, ADMIN) | -| **Synchronisation** | None | -| **Condition** | The actor is authenticated and holds permission `order.read`. | +| **Evenement declencheur** | Le personnel cuisine accede a l'affichage de preparation ou le rafraichit | +| **Acteur** | KITCHEN (ou COUNTER, DRIVE, ADMIN) | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur est authentifie et detient la permission `order.read`. | | **Operation** | LIST_ORDERS_DISPLAY | -| **Description** | Read `customer_order` rows with status `paid`, filtered by sources visible to the actor's role (from `role_visible_source`): kitchen sees all sources; counter sees kiosk+counter; drive sees drive. Orders are sorted by `paid_at` ascending (oldest first). For each order, display: order number, source, content (`order_item` with `label_snapshot`, `quantity`, format, slot selections, ingredient modifiers). KDS colour is computed from `now - paid_at` against the SLA threshold (approx. 10 min), not stored. Kitchen staff performs no status transition — this is a read-only operation. | -| **MCD entities** | R: `customer_order` (status=`paid`), `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | -| **Result** | Preparation display list shown, sorted by payment time ascending | +| **Description** | Lecture des lignes `customer_order` avec statut `paid`, filtrees par les sources visibles selon le role de l'acteur (depuis `role_visible_source`) : la cuisine voit toutes les sources ; le comptoir voit kiosk+counter ; le drive voit drive. Les commandes sont triees par `paid_at` ascendant (les plus anciennes en premier). Pour chaque commande, afficher : numero de commande, source, contenu (`order_item` avec `label_snapshot`, `quantity`, format, selections de slots, modificateurs d'ingredient). La couleur KDS est calculee a partir de `now - paid_at` par rapport au seuil de SLA (approx. 10 min), non stockee. Le personnel cuisine n'effectue aucune transition de statut — c'est une operation en lecture seule. | +| **Entites MCD** | R: `customer_order` (status=`paid`), `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | +| **Resultat** | Liste d'affichage de preparation montree, triee par heure de paiement ascendante | --- -## 6. Domain 4 — Delivery to customer +## 6. Domaine 4 — Livraison au client ### 6.1 DELIVER_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering events** | 1. The order is at status `paid` AND 2. Counter or drive staff clicks "Delivered" | -| **Actor** | COUNTER or DRIVE | +| **Evenements declencheurs** | 1. La commande est au statut `paid` AND 2. Le personnel comptoir ou drive clique sur « Livre » | +| **Acteur** | COUNTER ou DRIVE | | **Synchronisation** | AND | -| **Condition** | The order has status `paid`. The actor holds permission `order.deliver`. The actor's role is consistent with the order source (counter staff handles kiosk+counter orders; drive staff handles drive orders — filtered by role_visible_source). | +| **Condition** | La commande a le statut `paid`. L'acteur detient la permission `order.deliver`. Le role de l'acteur est coherent avec la source de la commande (le personnel comptoir traite les commandes kiosk+counter ; le personnel drive traite les commandes drive — filtre par role_visible_source). | | **Operation** | DELIVER_ORDER | -| **Description** | Single-gesture transition `paid -> delivered`. Sets `delivered_at = NOW()`. The order moves to history. This operation replaces the v0.1 two-step sequence (mark-ready then deliver); the kitchen's visual confirmation (KDS) is sufficient before this action. | -| **MCD entities** | W: `customer_order` (UPDATE status `paid` -> `delivered`, `delivered_at = NOW()`) | -| **Result** | Order at status `delivered`, lifecycle complete | +| **Description** | Transition en geste unique `paid -> delivered`. Positionne `delivered_at = NOW()`. La commande passe en historique. Cette operation remplace la sequence en deux etapes de v0.1 (marquer-prete puis livrer) ; la confirmation visuelle de la cuisine (KDS) suffit avant cette action. | +| **Entites MCD** | W: `customer_order` (UPDATE status `paid` -> `delivered`, `delivered_at = NOW()`) | +| **Resultat** | Commande au statut `delivered`, cycle de vie complet | --- -## 7. Domain 5 — Cancellation +## 7. Domaine 5 — Annulation ### 7.1 CANCEL_ORDER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | An authorised actor requests cancellation of an order | -| **Actor** | COUNTER, DRIVE, or ADMIN | -| **Synchronisation** | None | -| **Condition** | The order exists. `customer_order.status` is in `['pending_payment', 'paid']`. Terminal statuses `delivered` and `cancelled` cannot transition to `cancelled`. The actor holds permission `order.cancel`. | +| **Evenement declencheur** | Un acteur autorise demande l'annulation d'une commande | +| **Acteur** | COUNTER, DRIVE ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | La commande existe. `customer_order.status` est dans `['pending_payment', 'paid']`. Les statuts terminaux `delivered` et `cancelled` ne peuvent pas transiter vers `cancelled`. L'acteur detient la permission `order.cancel`. | | **Operation** | CANCEL_ORDER | -| **Description** | Transition from current status to `cancelled`. Sets `cancelled_at = NOW()`. The order is retained in the database for history and stats (no physical deletion). If the current status is `paid`, stock is re-credited: for each ingredient consumed by the order (accounting for modifiers), `ingredient.stock_quantity` is incremented; one `stock_movement` row of type `cancellation` is inserted per affected ingredient unit. Stock re-credit and status update are within the same transaction. | -| **MCD entities** | R: `order_item`, `order_item_modifier`, `ingredient`, `product_ingredient` — W: `customer_order` (UPDATE status -> `cancelled`, `cancelled_at = NOW()`), `ingredient` (UPDATE stock_quantity, conditional on status `paid`), `stock_movement` (INSERT type `cancellation`, conditional on status `paid`) | -| **Result** | Order at status `cancelled`, visible in admin history | +| **Description** | Transition du statut courant vers `cancelled`. Positionne `cancelled_at = NOW()`. La commande est conservee en base pour l'historique et les stats (pas de suppression physique). Si le statut courant est `paid`, le stock est recredite : pour chaque ingredient consomme par la commande (en tenant compte des modificateurs), `ingredient.stock_quantity` est incremente ; une ligne `stock_movement` de type `cancellation` est inseree par unite d'ingredient affectee. Le recredit du stock et la mise a jour du statut sont dans la meme transaction. | +| **Entites MCD** | R: `order_item`, `order_item_modifier`, `ingredient`, `product_ingredient` — W: `customer_order` (UPDATE status -> `cancelled`, `cancelled_at = NOW()`), `ingredient` (UPDATE stock_quantity, conditional on status `paid`), `stock_movement` (INSERT type `cancellation`, conditional on status `paid`) | +| **Resultat** | Commande au statut `cancelled`, visible dans l'historique admin | --- -## 8. Domain 6 — Catalogue management +## 8. Domaine 6 — Gestion du catalogue ### 8.1 CREATE_PRODUCT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the product creation form | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `product.create`. Target category exists and `is_active=1`. `name` is non-empty. `price_cents > 0`. | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de creation de produit | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `product.create`. La categorie cible existe et `is_active=1`. `name` est non vide. `price_cents > 0`. | | **Operation** | CREATE_PRODUCT | -| **Description** | INSERT a new `product` with its category, name, price in cents, VAT rate in per-mille (`vat_rate`: 100=10%, 55=5.5%, default 100), optional image path. `is_available=1` by default. | -| **MCD entities** | R: `category` (FK validation) — W: `product` (INSERT) | -| **Result** | Product created, redirect to product list | +| **Description** | INSERT d'un nouveau `product` avec sa categorie, son nom, son prix en centimes, son taux de TVA en pour-mille (`vat_rate` : 100=10%, 55=5.5%, defaut 100), chemin d'image optionnel. `is_available=1` par defaut. | +| **Entites MCD** | R: `category` (FK validation) — W: `product` (INSERT) | +| **Resultat** | Produit cree, redirection vers la liste des produits | --- ### 8.2 UPDATE_PRODUCT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the product update form | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `product.update`. Product exists. New values respect constraints (`price_cents > 0`, non-empty name). | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de modification de produit | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `product.update`. Le produit existe. Les nouvelles valeurs respectent les contraintes (`price_cents > 0`, nom non vide). | | **Operation** | UPDATE_PRODUCT | -| **Description** | UPDATE modifiable columns (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`). Snapshots already stored in `order_item` are not affected (historical integrity guaranteed by design). | -| **MCD entities** | W: `product` (UPDATE) | -| **Result** | Product updated, product list refreshed | +| **Description** | UPDATE des colonnes modifiables (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`). Les snapshots deja stockes dans `order_item` ne sont pas affectes (integrite historique garantie par conception). | +| **Entites MCD** | W: `product` (UPDATE) | +| **Resultat** | Produit mis a jour, liste des produits rafraichie | --- ### 8.3 DELETE_PRODUCT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin confirms deletion of a product | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `product.delete`. Product is not a slot option in any `menu_slot_option` (FK `ON DELETE RESTRICT`). Product is not referenced in any `order_item` historical line (FK `ON DELETE RESTRICT`). Preliminary check required. | +| **Evenement declencheur** | L'admin confirme la suppression d'un produit | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `product.delete`. Le produit n'est option de slot dans aucun `menu_slot_option` (FK `ON DELETE RESTRICT`). Le produit n'est reference dans aucune ligne historique `order_item` (FK `ON DELETE RESTRICT`). Verification prealable requise. | | **Operation** | DELETE_PRODUCT | -| **Description** | Physical deletion of the product if no FK constraint blocks. If the product is referenced in a menu slot or historical order line, deletion is blocked. The recommended alternative is to deactivate (`is_available=0`). Also blocks if the product is the `burger_product_id` of any `menu`. | -| **MCD entities** | W: `product` (DELETE — blocked if referenced in `menu_slot_option`, `order_item`, or `menu.burger_product_id`) | -| **Result** | Product deleted OR error "product in use" | +| **Description** | Suppression physique du produit si aucune contrainte FK ne la bloque. Si le produit est reference dans un slot de menu ou une ligne de commande historique, la suppression est bloquee. L'alternative recommandee est de le desactiver (`is_available=0`). Bloque egalement si le produit est le `burger_product_id` d'un `menu`. | +| **Entites MCD** | W: `product` (DELETE — blocked if referenced in `menu_slot_option`, `order_item`, or `menu.burger_product_id`) | +| **Resultat** | Produit supprime OU erreur « produit utilise » | --- ### 8.4 CREATE_MENU -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the menu creation form with its slot configuration | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `menu.create`. `name` is non-empty. `price_normal_cents > 0`, `price_maxi_cents > 0`. `burger_product_id` references an existing product. At least one slot is defined with at least one option. | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de creation de menu avec sa configuration de slots | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `menu.create`. `name` est non vide. `price_normal_cents > 0`, `price_maxi_cents > 0`. `burger_product_id` reference un produit existant. Au moins un slot est defini avec au moins une option. | | **Operation** | CREATE_MENU | -| **Description** | Transaction: INSERT `menu` (with `burger_product_id`, `price_normal_cents`, `price_maxi_cents`), then INSERT `menu_slot` rows (one per slot: drink, side, sauce...), then INSERT `menu_slot_option` rows (eligible products per slot). | -| **MCD entities** | R: `product` (burger FK validation, slot options validation), `category` — W: `menu` (INSERT), `menu_slot` (INSERT), `menu_slot_option` (INSERT) | -| **Result** | Menu created with its slot configuration, visible on the kiosk | +| **Description** | Transaction : INSERT `menu` (avec `burger_product_id`, `price_normal_cents`, `price_maxi_cents`), puis INSERT des lignes `menu_slot` (une par slot : boisson, accompagnement, sauce...), puis INSERT des lignes `menu_slot_option` (produits eligibles par slot). | +| **Entites MCD** | R: `product` (burger FK validation, slot options validation), `category` — W: `menu` (INSERT), `menu_slot` (INSERT), `menu_slot_option` (INSERT) | +| **Resultat** | Menu cree avec sa configuration de slots, visible sur le kiosk | --- ### 8.5 UPDATE_MENU -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager submits the menu update form | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `menu.update`. Menu exists. Updated configuration preserves at least one slot with at least one option. | +| **Evenement declencheur** | L'admin ou le manager soumet le formulaire de modification de menu | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `menu.update`. Le menu existe. La configuration mise a jour preserve au moins un slot avec au moins une option. | | **Operation** | UPDATE_MENU | -| **Description** | UPDATE `menu` columns. If slot configuration is modified: DELETE all `menu_slot_option` rows for this menu's slots, DELETE `menu_slot` rows, then re-INSERT (delete-and-reinsert pattern, atomic in transaction). Snapshots in `order_item` are not affected. | -| **MCD entities** | W: `menu` (UPDATE), `menu_slot` (DELETE + INSERT), `menu_slot_option` (DELETE + INSERT) | -| **Result** | Menu updated | +| **Description** | UPDATE des colonnes `menu`. Si la configuration des slots est modifiee : DELETE de toutes les lignes `menu_slot_option` pour les slots de ce menu, DELETE des lignes `menu_slot`, puis re-INSERT (pattern delete-and-reinsert, atomique en transaction). Les snapshots dans `order_item` ne sont pas affectes. | +| **Entites MCD** | W: `menu` (UPDATE), `menu_slot` (DELETE + INSERT), `menu_slot_option` (DELETE + INSERT) | +| **Resultat** | Menu mis a jour | --- ### 8.6 DELETE_MENU -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin confirms deletion of a menu | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `menu.delete`. Menu is not referenced in any `order_item` historical line (FK `ON DELETE RESTRICT`). Preliminary check required. | +| **Evenement declencheur** | L'admin confirme la suppression d'un menu | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `menu.delete`. Le menu n'est reference dans aucune ligne historique `order_item` (FK `ON DELETE RESTRICT`). Verification prealable requise. | | **Operation** | DELETE_MENU | -| **Description** | If no `order_item` references this menu: DELETE `menu_slot_option` (CASCADE from `menu_slot`), DELETE `menu_slot` (CASCADE from `menu`), DELETE `menu`. If historical references exist, propose deactivation (`is_available=0`) instead. | -| **MCD entities** | W: `menu_slot_option` (DELETE CASCADE), `menu_slot` (DELETE CASCADE), `menu` (DELETE — blocked if referenced in `order_item`) | -| **Result** | Menu deleted OR error "menu present in historical orders" | +| **Description** | Si aucun `order_item` ne reference ce menu : DELETE `menu_slot_option` (CASCADE from `menu_slot`), DELETE `menu_slot` (CASCADE from `menu`), DELETE `menu`. Si des references historiques existent, proposer la desactivation (`is_available=0`) a la place. | +| **Entites MCD** | W: `menu_slot_option` (DELETE CASCADE), `menu_slot` (DELETE CASCADE), `menu` (DELETE — blocked if referenced in `order_item`) | +| **Resultat** | Menu supprime OU erreur « menu present dans des commandes historiques » | --- ### 8.7 MANAGE_CATEGORY -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager creates, updates, or deactivates a category | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | OR (create, update, deactivation) | -| **Condition** | Actor holds permission `category.manage`. For deactivation: products and menus in the category are not auto-deactivated in DB (no CASCADE on `is_active`); the application layer proposes deactivating child products/menus. | +| **Evenement declencheur** | L'admin ou le manager cree, modifie ou desactive une categorie | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | OR (creation, modification, desactivation) | +| **Condition** | L'acteur detient la permission `category.manage`. Pour la desactivation : les produits et menus de la categorie ne sont pas auto-desactives en base (pas de CASCADE sur `is_active`) ; la couche applicative propose de desactiver les produits/menus enfants. | | **Operation** | MANAGE_CATEGORY | -| **Description** | CRUD on `category`. Deactivation (`is_active=0`) hides the category and its products from the kiosk without physical deletion. Physical deletion is blocked if products or menus reference this category (FK `ON DELETE RESTRICT`). | -| **MCD entities** | W: `category` (INSERT / UPDATE / conditional DELETE) | -| **Result** | Category created / updated / deactivated | +| **Description** | CRUD sur `category`. La desactivation (`is_active=0`) masque la categorie et ses produits du kiosk sans suppression physique. La suppression physique est bloquee si des produits ou des menus referencent cette categorie (FK `ON DELETE RESTRICT`). | +| **Entites MCD** | W: `category` (INSERT / UPDATE / conditional DELETE) | +| **Resultat** | Categorie creee / modifiee / desactivee | --- ### 8.8 MANAGE_INGREDIENT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin or manager creates, updates, or deactivates an ingredient; or manages product composition (`product_ingredient`) or allergen mapping (`ingredient_allergen`) | -| **Actor** | ADMIN or MANAGER | -| **Synchronisation** | OR (create ingredient, update ingredient, update composition, update allergen mapping) | -| **Condition** | Actor holds permission `ingredient.manage`. | +| **Evenement declencheur** | L'admin ou le manager cree, modifie ou desactive un ingredient ; ou gere la composition produit (`product_ingredient`) ou le mapping allergene (`ingredient_allergen`) | +| **Acteur** | ADMIN ou MANAGER | +| **Synchronisation** | OR (creer ingredient, modifier ingredient, modifier composition, modifier mapping allergene) | +| **Condition** | L'acteur detient la permission `ingredient.manage`. | | **Operation** | MANAGE_INGREDIENT | -| **Description** | CRUD on `ingredient` (name, unit, pack_size, pack_label, low_stock_threshold, is_active). Manage `product_ingredient` composition (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) for any product. Manage `ingredient_allergen` mapping (14 EU regulated allergens). Deactivating an ingredient (`is_active=0`) hides it from the configurator without deletion. Physical deletion of `ingredient` is blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). | -| **MCD entities** | R: `product` (FK validation), `allergen` (FK validation) — W: `ingredient` (INSERT/UPDATE/DELETE conditional), `product_ingredient` (INSERT/UPDATE/DELETE), `ingredient_allergen` (INSERT/DELETE) | -| **Result** | Ingredient / composition / allergen mapping updated | +| **Description** | CRUD sur `ingredient` (name, unit, pack_size, pack_label, stock_capacity, low_stock_pct, critical_stock_pct, is_active). Gestion de la composition `product_ingredient` (quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) pour tout produit. Gestion du mapping `ingredient_allergen` (14 allergenes reglementes UE). Desactiver un ingredient (`is_active=0`) le masque du configurateur sans suppression. La suppression physique de `ingredient` est bloquee s'il est reference dans `product_ingredient` (FK `ON DELETE RESTRICT`) ou `stock_movement` (FK `ON DELETE RESTRICT`). | +| **Entites MCD** | R: `product` (FK validation), `allergen` (FK validation) — W: `ingredient` (INSERT/UPDATE/DELETE conditional), `product_ingredient` (INSERT/UPDATE/DELETE), `ingredient_allergen` (INSERT/DELETE) | +| **Resultat** | Ingredient / composition / mapping allergene mis a jour | --- -## 9. Domain 7 — Stock management +## 9. Domaine 7 — Gestion du stock ### 9.1 RESTOCK -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Manager or admin records a delivery of ingredient packs | -| **Actor** | MANAGER or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stock.manage`. Ingredient exists and `is_active=1`. Number of packs `N >= 1`. | +| **Evenement declencheur** | Le manager ou l'admin enregistre une livraison de packs d'ingredient | +| **Acteur** | MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stock.manage`. L'ingredient existe et `is_active=1`. Nombre de packs `N >= 1`. | | **Operation** | RESTOCK | -| **Description** | UPDATE `ingredient.stock_quantity += N * pack_size`. INSERT one `stock_movement` row: type `restock`, delta `+= N * pack_size`, `user_id` of the actor, optional `note` (e.g. delivery reference). Both writes are in the same transaction. | -| **MCD entities** | R: `ingredient` — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `restock`) | -| **Result** | Stock incremented, movement logged | +| **Description** | UPDATE `ingredient.stock_quantity += N * pack_size`. INSERT d'une ligne `stock_movement` : type `restock`, delta `+= N * pack_size`, `user_id` de l'acteur, `note` optionnelle (ex. reference de livraison). Les deux ecritures sont dans la meme transaction. | +| **Entites MCD** | R: `ingredient` — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `restock`) | +| **Resultat** | Stock incremente, mouvement journalise | --- ### 9.2 INVENTORY_COUNT -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | A staff member or manager records the result of a physical inventory count | -| **Actor** | KITCHEN, COUNTER, DRIVE, MANAGER, or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stock.count`. Ingredient exists. Physical count `actual_quantity >= 0`. | +| **Evenement declencheur** | Un membre du personnel ou un manager enregistre le resultat d'un inventaire physique | +| **Acteur** | KITCHEN, COUNTER, DRIVE, MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stock.count`. L'ingredient existe. Comptage physique `actual_quantity >= 0`. | | **Operation** | INVENTORY_COUNT | -| **Description** | Compute `delta = actual_quantity - ingredient.stock_quantity` (may be negative or positive). UPDATE `ingredient.stock_quantity = actual_quantity`. INSERT one `stock_movement` row: type `inventory_correction`, delta = computed discrepancy, `user_id` of the actor, optional `note`. Both writes in the same transaction. | -| **MCD entities** | R: `ingredient` (read current stock_quantity) — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `inventory_correction`) | -| **Result** | Stock reconciled to physical count, discrepancy logged | +| **Description** | Calcul de `delta = actual_quantity - ingredient.stock_quantity` (peut etre negatif ou positif). UPDATE `ingredient.stock_quantity = actual_quantity`. INSERT d'une ligne `stock_movement` : type `inventory_correction`, delta = ecart calcule, `user_id` de l'acteur, `note` optionnelle. Les deux ecritures dans la meme transaction. | +| **Entites MCD** | R: `ingredient` (read current stock_quantity) — W: `ingredient` (UPDATE stock_quantity), `stock_movement` (INSERT type `inventory_correction`) | +| **Resultat** | Stock reconcilie au comptage physique, ecart journalise | --- ### 9.3 READ_STOCK -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | An authorised actor accesses the stock view | -| **Actor** | KITCHEN, COUNTER, DRIVE, MANAGER, or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stock.read`. | +| **Evenement declencheur** | Un acteur autorise accede a la vue du stock | +| **Acteur** | KITCHEN, COUNTER, DRIVE, MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stock.read`. | | **Operation** | READ_STOCK | -| **Description** | Read `ingredient` list with current `stock_quantity`, `low_stock_threshold`, `pack_size`, `pack_label`. Low-stock alert computed at display time: `stock_quantity <= low_stock_threshold`. Optional: read `stock_movement` history for a given ingredient, filtered by date range. | -| **MCD entities** | R: `ingredient`, `stock_movement` (optional history) | -| **Result** | Stock list displayed with low-stock indicators | +| **Description** | Lecture de la liste `ingredient` avec le `stock_quantity` courant, `stock_capacity`, `stock_pct` calcule, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`. Bandes de stock calculees au moment de l'affichage : `low_stock` lorsque `stock_quantity <= stock_capacity * low_stock_pct/100`, `critical_stock` lorsque `stock_quantity <= stock_capacity * critical_stock_pct/100`. Optionnel : lecture de l'historique `stock_movement` pour un ingredient donne, filtre par plage de dates. | +| **Entites MCD** | R: `ingredient`, `stock_movement` (optional history) | +| **Resultat** | Liste du stock affichee avec indicateurs de stock bas | --- -## 10. Domain 8 — User and role management (admin) +## 10. Domaine 8 — Gestion des utilisateurs et des roles (admin) ### 10.1 CREATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin submits the user creation form | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `user.create`. Email does not already exist in `user.email` (UNIQUE constraint). A valid and active `role_id` is selected. | +| **Evenement declencheur** | L'admin soumet le formulaire de creation d'utilisateur | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.create`. L'email n'existe pas deja dans `user.email` (contrainte UNIQUE). Un `role_id` valide et actif est selectionne. | | **Operation** | CREATE_USER | -| **Description** | INSERT user with argon2id password hash. Email is unique. `role_id` is mandatory (FK NOT NULL). `is_active=1` by default. `last_login_at=NULL` at creation. | -| **MCD entities** | R: `role` (FK validation) — W: `user` (INSERT) | -| **Result** | User created, can log into the back-office | +| **Description** | INSERT de l'utilisateur avec un hash de mot de passe argon2id. L'email est unique. `role_id` est obligatoire (FK NOT NULL). `is_active=1` par defaut. `last_login_at=NULL` a la creation. | +| **Entites MCD** | R: `role` (FK validation) — W: `user` (INSERT) | +| **Resultat** | Utilisateur cree, peut se connecter au back-office | --- ### 10.2 UPDATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin submits the user update form | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `user.update`. User exists. If a new password is provided, it is re-hashed. | +| **Evenement declencheur** | L'admin soumet le formulaire de modification d'utilisateur | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.update`. L'utilisateur existe. Si un nouveau mot de passe est fourni, il est re-hashe. | | **Operation** | UPDATE_USER | -| **Description** | UPDATE modifiable fields (`first_name`, `last_name`, `email`, `role_id`, `is_active`). If a new password is supplied, it replaces the existing hash (argon2id rehash). | -| **MCD entities** | W: `user` (UPDATE) | -| **Result** | User updated | +| **Description** | UPDATE des champs modifiables (`first_name`, `last_name`, `email`, `role_id`, `is_active`). Si un nouveau mot de passe est fourni, il remplace le hash existant (rehash argon2id). | +| **Entites MCD** | W: `user` (UPDATE) | +| **Resultat** | Utilisateur mis a jour | --- ### 10.3 DEACTIVATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin clicks "Deactivate" for a user | -| **Actor** | ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `user.deactivate`. Admin cannot deactivate their own account (application-level protection). | +| **Evenement declencheur** | L'admin clique sur « Desactiver » pour un utilisateur | +| **Acteur** | ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.deactivate`. L'admin ne peut pas desactiver son propre compte (protection au niveau applicatif). | | **Operation** | DEACTIVATE_USER | -| **Description** | UPDATE `is_active=0`. The user's active session is invalidated on next access (middleware checks `is_active=1` on each authenticated request). User is not deleted; history remains traceable. | -| **MCD entities** | W: `user` (UPDATE is_active=0) | -| **Result** | User deactivated, back-office access blocked | +| **Description** | UPDATE `is_active=0`. La session active de l'utilisateur est invalidee au prochain acces (le middleware verifie `is_active=1` a chaque requete authentifiee). L'utilisateur n'est pas supprime ; l'historique reste tracable. | +| **Entites MCD** | W: `user` (UPDATE is_active=0) | +| **Resultat** | Utilisateur desactive, acces back-office bloque | --- ### 10.4 MANAGE_RBAC -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Admin modifies permission assignments for a role, or creates / updates a custom role | -| **Actor** | ADMIN | -| **Synchronisation** | OR (update role permissions, create custom role, update role attributes) | -| **Condition** | Actor holds permission `role.manage`. Selected permissions exist in the `permission` catalogue. | +| **Evenement declencheur** | L'admin modifie les affectations de permissions pour un role, ou cree / modifie un role personnalise | +| **Acteur** | ADMIN | +| **Synchronisation** | OR (modifier les permissions du role, creer un role personnalise, modifier les attributs du role) | +| **Condition** | L'acteur detient la permission `role.manage`. Les permissions selectionnees existent dans le catalogue `permission`. | | **Operation** | MANAGE_RBAC | -| **Description** | Update `role_permission` for a given role: DELETE existing assignments, INSERT new ones (delete-and-reinsert, atomic in transaction). Permissions themselves are static (declared in migration, not modifiable via UI). Also covers: CREATE/UPDATE custom `role` (code, label, description, default_route, order_source), UPDATE `role_visible_source` (visible dashboard sources for the role). RBAC architecture rule: application code tests permissions, not role names — adding a new role with correct permissions requires no code change. | -| **MCD entities** | R: `role`, `permission` — W: `role_permission` (DELETE + INSERT), `role` (INSERT/UPDATE for custom roles), `role_visible_source` (INSERT/DELETE) | -| **Result** | RBAC matrix updated, effective immediately for new requests of users bearing this role | +| **Description** | Mise a jour de `role_permission` pour un role donne : DELETE des affectations existantes, INSERT des nouvelles (delete-and-reinsert, atomique en transaction). Les permissions elles-memes sont statiques (declarees en migration, non modifiables via l'UI). Couvre egalement : CREATE/UPDATE d'un `role` personnalise (code, label, description, default_route, order_source), UPDATE de `role_visible_source` (sources de tableau de bord visibles pour le role). Regle d'architecture RBAC : le code applicatif teste les permissions, pas les noms de role — ajouter un nouveau role avec les bonnes permissions ne requiert aucun changement de code. | +| **Entites MCD** | R: `role`, `permission` — W: `role_permission` (DELETE + INSERT), `role` (INSERT/UPDATE for custom roles), `role_visible_source` (INSERT/DELETE) | +| **Resultat** | Matrice RBAC mise a jour, effective immediatement pour les nouvelles requetes des utilisateurs porteurs de ce role | --- -## 11. Domain 9 — Stats and KPI +### 10.5 ERASE_USER_PII (security-by-design) + +| Champ | Valeur | +|-------|-------| +| **Evenement declencheur** | Une demande d'effacement RGPD est traitee pour un utilisateur back-office | +| **Acteur** | ADMIN (protege par PIN) | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `user.update` et s'est re-autorise via PIN. L'utilisateur cible existe et n'est pas deja anonymise. | +| **Operation** | ERASE_USER_PII | +| **Description** | Le droit a l'effacement RGPD est honore par **anonymisation**, non par suppression physique : les PII (`email`, `first_name`, `last_name`) sont effacees/remplacees par un placeholder non identifiant, les identifiants invalides, `anonymized_at` positionne. La ligne persiste afin que les liens referentiels (`stock_movement`, `customer_order`, `audit_log`) restent valides et se resolvent vers un principal anonymise. Voir `mlt.md` 10.5 et la note 13 du dictionnaire. | +| **Entites MCD** | W: `user` (UPDATE — PII cleared, `anonymized_at` set), `audit_log` (INSERT) | +| **Resultat** | Utilisateur anonymise ; PII supprimees ; liens d'imputabilite preserves ; une ligne `audit_log` enregistree | + +--- + +## 11. Domaine 9 — Stats et KPI ### 11.1 READ_STATS -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Manager or admin accesses the stats dashboard | -| **Actor** | MANAGER or ADMIN | -| **Synchronisation** | None | -| **Condition** | Actor holds permission `stats.read`. | +| **Evenement declencheur** | Le manager ou l'admin accede au tableau de bord des stats | +| **Acteur** | MANAGER ou ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | L'acteur detient la permission `stats.read`. | | **Operation** | READ_STATS | -| **Description** | Aggregate queries on `customer_order` and `order_item`. Key aggregations: order count and revenue (TTC) by `service_day` (computed with CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END; cutoff at 10:00); top products by `label_snapshot` COUNT in `order_item`; cancellation rate; average delivery time `delivered_at - paid_at`; breakdown by `source` and `service_mode`. Queries exclude cancelled orders from revenue sums but include them in volume counts. No additional stored column for `service_day`; computation at query time. | -| **MCD entities** | R: `customer_order`, `order_item` | -| **Result** | Stats dashboard displayed | +| **Description** | Requetes d'agregation sur `customer_order` et `order_item`. Agregations cles : nombre de commandes et chiffre d'affaires (TTC) par `service_day` (calcule avec CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END ; coupure a 10:00) ; top produits par COUNT de `label_snapshot` dans `order_item` ; taux d'annulation ; temps de livraison moyen `delivered_at - paid_at` ; ventilation par `source` et `service_mode`. Les requetes excluent les commandes annulees des sommes de chiffre d'affaires mais les incluent dans les comptages de volume. Pas de colonne stockee supplementaire pour `service_day` ; calcul au moment de la requete. | +| **Entites MCD** | R: `customer_order`, `order_item` | +| **Resultat** | Tableau de bord des stats affiche | --- -## 12. Domain 10 — Back-office authentication +## 12. Domaine 10 — Authentification back-office ### 12.1 AUTHENTICATE_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | An actor submits the login form | -| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | -| **Synchronisation** | None | -| **Condition** | Email exists in database. Password matches argon2id hash. User `is_active=1`. | +| **Evenement declencheur** | Un acteur soumet le formulaire de connexion | +| **Acteur** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | +| **Synchronisation** | Aucune | +| **Condition** | Le compte n'est pas dans une fenetre de throttling (`lockout_until`). L'email existe en base. Le mot de passe correspond au hash argon2id. L'utilisateur `is_active=1`. | | **Operation** | AUTHENTICATE_USER | -| **Description** | Credential verification. If valid: session ID regeneration (protection against session fixation), storage of `user_id` and `role_id` in session, UPDATE `last_login_at`. Idle timeout: 4h. Absolute timeout: 10h. Redirect to `role.default_route`. | -| **MCD entities** | R: `user` (verification), `role` (load permissions, default_route), `role_permission` — W: `user` (UPDATE last_login_at) | -| **Result** | Session opened, redirect to role-specific default view | +| **Description** | Verification des identifiants. Si valide : regeneration de l'ID de session (protection contre la fixation de session), stockage de `user_id` et `role_id` en session, UPDATE `last_login_at`, remise a zero du compteur d'echecs de connexion. En cas d'echec : incrementation de `failed_login_attempts` et application d'un backoff degressif (`lockout_until`), erreur generique resistante a l'enumeration. Idle timeout : 4h. Absolute timeout : 10h. Redirection vers `role.default_route`. Voir `mlt.md` 12.1. | +| **Entites MCD** | R: `user` (verification), `role` (load permissions, default_route), `role_permission`, `login_throttle` (the per-IP throttle gate) — W: `user` (UPDATE last_login_at, `failed_login_attempts`, `lockout_until`), `login_throttle` (upsert `failed_attempts`/`lockout_until` on failure, clear on success), `audit_log` (INSERT login success/failure) | +| **Resultat** | Session ouverte, redirection vers la vue par defaut specifique au role ; ou echec throttle journalise | --- ### 12.2 LOGOUT_USER -| Field | Value | +| Champ | Valeur | |-------|-------| -| **Triggering event** | Actor clicks "Logout" OR session expires | -| **Actor** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN / SYS (expiry) | +| **Evenement declencheur** | L'acteur clique sur « Deconnexion » OU la session expire | +| **Acteur** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN / SYS (expiration) | | **Synchronisation** | OR | -| **Condition** | A valid session is open | +| **Condition** | Une session valide est ouverte | | **Operation** | LOGOUT_USER | -| **Description** | PHP session destruction (`session_destroy()`). Session deleted server-side. Session cookie invalidated. | -| **MCD entities** | No database write (session management is in PHP native, outside DB for this project) | -| **Result** | Session destroyed, redirect to login page | +| **Description** | Destruction de la session PHP (`session_destroy()`). Session supprimee cote serveur. Cookie de session invalide. | +| **Entites MCD** | Aucune ecriture en base (la gestion des sessions est en PHP natif, hors base pour ce projet) | +| **Resultat** | Session detruite, redirection vers la page de connexion | --- -## 13. State machine — customer_order.status +### 12.3 RESET_PASSWORD (security-by-design) -Summary of transitions covered by MCT operations. +| Champ | Valeur | +|-------|-------| +| **Evenement declencheur** | Un utilisateur demande une reinitialisation de mot de passe, puis la confirme via le lien envoye par email | +| **Acteur** | COUNTER / DRIVE / KITCHEN / MANAGER / ADMIN | +| **Synchronisation** | Sequentielle en deux phases : demande, puis confirmation | +| **Condition** | Demande : l'email soumis est traite de maniere resistante a l'enumeration (meme reponse neutre qu'il existe ou non). Confirmation : un token valide et non expire est presente. | +| **Operation** | RESET_PASSWORD | +| **Description** | La phase de demande genere un token aleatoire, stocke son hash + expiration, et envoie le token brut une seule fois par email. La phase de confirmation valide le hash du token + expiration, remplace `password_hash` (argon2id), efface le token et remet a zero le compteur d'echecs de connexion. Voir `mlt.md` 12.3. | +| **Entites MCD** | W: `user` (UPDATE `password_reset_token_hash` + `password_reset_expires_at` on request; UPDATE `password_hash`, clear token, reset `failed_login_attempts`/`lockout_until` on confirm), `audit_log` (INSERT) | +| **Resultat** | Mot de passe reinitialise via un token a usage unique et a duree limitee ; une ligne `audit_log` enregistree | + +--- + +## 13. Machine a etats — customer_order.status + +Recapitulatif des transitions couvertes par les operations MCT. ``` [CUSTOMER / COUNTER / DRIVE] @@ -532,20 +573,20 @@ Summary of transitions covered by MCT operations. [ cancelled ] (terminal) ``` -**Note on the `pending_payment -> paid` transition**: in the RNCP context, payment is -replaced by the customer entering their order number (kiosk) or by staff validation -(counter/drive). The transition is atomic within CREATE_ORDER and CREATE_COUNTER_ORDER. -The `pending_payment` status is not observable outside the transaction. +**Note sur la transition `pending_payment -> paid`** : dans le contexte RNCP, le paiement est +remplace par la saisie du numero de commande par le client (kiosk) ou par la validation du personnel +(comptoir/drive). La transition est atomique au sein de CREATE_ORDER et CREATE_COUNTER_ORDER. +Le statut `pending_payment` n'est pas observable en dehors de la transaction. -**Dropped from v0.1**: `preparing` and `ready` states; `MARK_IN_PREPARATION` and `MARK_READY` -operations. Kitchen staff have a read-only view of `paid` orders (LIST_ORDERS_DISPLAY). The -single delivery action (DELIVER_ORDER) collapses the v0.1 three-step sequence into one gesture. +**Supprime de v0.1** : etats `preparing` et `ready` ; operations `MARK_IN_PREPARATION` et `MARK_READY`. +Le personnel cuisine a une vue en lecture seule des commandes `paid` (LIST_ORDERS_DISPLAY). L'unique +action de livraison (DELIVER_ORDER) condense la sequence en trois etapes de v0.1 en un seul geste. --- -## 14. Operations summary table +## 14. Tableau recapitulatif des operations -| # | Operation | Domain | Actor | W Entities | R Entities | +| # | Operation | Domaine | Acteur | Entites W | Entites R | |---|-----------|--------|-------|------------|------------| | 1 | LOAD_CATALOGUE | Order kiosk | CUSTOMER | — | category, product, menu, menu_slot, menu_slot_option, ingredient, allergen, ingredient_allergen | | 2 | COMPOSE_CART | Order kiosk | CUSTOMER | — (volatile) | product, menu, menu_slot, menu_slot_option, ingredient, product_ingredient | @@ -573,16 +614,25 @@ single delivery action (DELIVER_ORDER) collapses the v0.1 three-step sequence in | 24 | READ_STATS | Stats | MANAGER/ADMIN | — | customer_order, order_item | | 25 | AUTHENTICATE_USER | Auth | ALL BACK | user | user, role, role_permission | | 26 | LOGOUT_USER | Auth | ALL BACK | — | — | +| 27 | ERASE_USER_PII | RBAC | ADMIN | user, audit_log | user | +| 28 | RESET_PASSWORD | Auth | ALL BACK | user, audit_log | user | -**Total: 26 operations** covering the complete Wakdo business lifecycle. +**Total : 28 operations** (26 prod-like + `ERASE_USER_PII` et `RESET_PASSWORD` de la +couche security-by-design). + +**Ecritures du journal d'audit (security-by-design)** : les operations sensibles 7.1 (annulation), 8.2/8.3 +(modification/suppression de produit), 8.6 (suppression de menu), 10.1-10.5 (utilisateur/RBAC/effacement) et 12.1 (connexion) +ecrivent egalement une ligne `audit_log` (entite W non repetee par ligne ci-dessus pour garder le tableau lisible). +Les operations de stock 9.1/9.2 enregistrent leur attribution via `stock_movement.user_id`. Ensemble protege par PIN +selon `mlt.md` RG-T13. --- -## 15. MCT -> MCD cross-validation (mantra #34) +## 15. Verification croisee MCT -> MCD (mantra #34) -Verification that each MCD entity participates in at least one MCT operation. +Verification que chaque entite MCD participe a au moins une operation MCT. -| MCD entity | Operations that read | Operations that write | Coverage | +| Entite MCD | Operations en lecture | Operations en ecriture | Couverture | |------------|---------------------|----------------------|----------| | `category` | 1, 9, 12, 15 | 15 | OK | | `product` | 1, 2, 3, 5, 9, 11, 12 | 9, 10, 11 | OK | @@ -603,9 +653,25 @@ Verification that each MCD entity participates in at least one MCT operation. | `permission` | 23 | — (static seed) | OK (*) | | `role_permission` | 25 | 23 | OK | | `stock_movement` | 19 | 3, 5, 8, 17, 18 | OK | +| `audit_log` | (vue d'audit admin) | 8, 10, 11, 14, 20, 21, 22, 23, 25, 27, 28 | OK | +| `login_throttle` | 25 | 25 | OK | -(*) `allergen` and `permission` are read-only at the MCT level: their values are declared -in seed migrations and are not modifiable via the UI. `allergen` is managed indirectly -via `ingredient_allergen` in MANAGE_INGREDIENT. +(*) `allergen` et `permission` sont en lecture seule au niveau MCT : leurs valeurs sont declarees +dans les migrations de seed et ne sont pas modifiables via l'UI. `allergen` est gere indirectement +via `ingredient_allergen` dans MANAGE_INGREDIENT. -**Conclusion**: 19/19 entities covered. MCT <-> MCD consistency validated. +(**) `audit_log` (entite 20, security-by-design) est principalement en ecriture : il est ajoute par les +operations sensibles ci-dessus et lu via une vue d'audit admin (une operation de lecture dediee +peut etre formalisee lorsque l'UI d'audit sera specifiee en P3). + +(***) `login_throttle` (entite 21, security-by-design) est le verrou de throttling anti-force-brute par IP source : +il est lu ET ecrit (upserte) par `AUTHENTICATE_USER` (25). Sa purge quotidienne +des lignes obsoletes est un cron, documente dans `mlt.md`, hors du perimetre des operations MCT. + +(****) `pin_throttle` (entite 22, security-by-design, RG-T22) est le verrou de throttling du PIN d'action +sensible par utilisateur AGISSANT : il est lu (gate avant verification) ET ecrit (upserte sur echec, remis +a zero sur succes) par les operations sensibles sous PIN (ex. UPDATE_PRODUCT prix/TVA, DELETE_PRODUCT). Sa +purge quotidienne suit celle de `login_throttle` (cron, `mlt.md`), hors du perimetre des operations MCT. + +**Conclusion** : 22/22 entites couvertes (19 prod-like + `audit_log` + `login_throttle` + `pin_throttle`). +Coherence MCT <-> MCD validee. diff --git a/docs/merise/mld.md b/docs/merise/mld.md index a48d6e2..8d0c57d 100644 --- a/docs/merise/mld.md +++ b/docs/merise/mld.md @@ -1,39 +1,39 @@ -# Logical Data Model (MLD) — Wakdo +# Modele Logique de Donnees (MLD) — Wakdo -**Merise phase** : P1 - Conception, step 5 (after MCD, MCT, MLT) -**Version** : v0.2 — prod-like, 19 tables -**Date** : 2026-06-04 -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 5 (apres MCD, MCT, MLT) +**Version** : v0.3 — prod-like, 22 tables (19 prod-like + couche security-by-design) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; couche security-by-design (audit_log + colonnes imputabilite/auth) en cours +**Auteur** : BYAN (couche methodologique) --- -## 1. Purpose of this document +## 1. Objectif de ce document -The MLD transcribes the MCD into a formal relational schema: 1 entity -> 1 table, each -association translated according to its cardinality, referential constraints materialised, -indexes sized for frequent access patterns. +Le MLD transcrit le MCD en un schema relationnel formel : 1 entite -> 1 table, chaque +association traduite selon sa cardinalite, contraintes referentielles materialisees, +index dimensionnes pour les patterns d'acces frequents. -This is the step that transforms conceptual modelling into an implementable specification. -The DDL SQL (`db/migrations/0001_init_schema.sql`) will be derived directly from this -document at P2. +C'est l'etape qui transforme la modelisation conceptuelle en une specification implementable. +Le DDL SQL (`db/migrations/0001_init_schema.sql`) sera derive directement de ce +document en P2. -**Sources**: -- `docs/merise/dictionary.md` (v0.2 — types and constraints per attribute, source of truth) -- `docs/merise/mcd.md` (v0.2 — entities + cardinalities + deferred decisions) -- `docs/notes/revue-alignement-p1.md` §7 (decision table D1-D8 + stock) +**Sources** : +- `docs/merise/dictionary.md` (v0.2 — types et contraintes par attribut, source de verite) +- `docs/merise/mcd.md` (v0.2 — entites + cardinalites + decisions reportees) +- `docs/notes/revue-alignement-p1.md` §7 (table de decisions D1-D8 + stock) -**Target platform**: +**Plateforme cible** : - MariaDB 11.4 LTS (cf. `docker-compose.yml` service `wakdo-db`) -- Engine InnoDB (ACID, FK support, row-level locking, CHECK from 10.2.1) +- Moteur InnoDB (ACID, support des FK, verrouillage au niveau ligne, CHECK depuis 10.2.1) - Charset `utf8mb4`, collation `utf8mb4_unicode_ci` --- -## 2. Notation conventions +## 2. Conventions de notation -### Relational notation +### Notation relationnelle ``` table_name (col1, col2, #col_fk, [col_nullable]) @@ -45,57 +45,340 @@ table_name (col1, col2, #col_fk, [col_nullable]) CHK : ``` -| Symbol | Meaning | +| Symbole | Signification | |---|---| -| `col` | NOT NULL column | -| `[col]` | Nullable column | -| `#col` | FK column | +| `col` | Colonne NOT NULL | +| `[col]` | Colonne nullable | +| `#col` | Colonne FK | -Notation follows Merise French usage (Nanci/Espinasse convention adapted for ASCII). +La notation suit l'usage Merise francais (convention Nanci/Espinasse adaptee a l'ASCII). -### Type summary +### Resume des types -All exact types are defined in `dictionary.md` section 2. Conventions retained: -- `INT UNSIGNED AUTO_INCREMENT` for all technical PKs -- `INT UNSIGNED` for all monetary amounts in cents (anti-FLOAT, see dictionary note 1) -- `SMALLINT UNSIGNED` for `vat_rate` per-mille values (55 or 100) -- `ENUM(...)` for stable business values (see dictionary note 2) -- `DATETIME` for timestamps (not TIMESTAMP, which implicitly converts to UTC in MariaDB) +Tous les types exacts sont definis dans `dictionary.md` section 2. Conventions retenues : +- `INT UNSIGNED AUTO_INCREMENT` pour toutes les PK techniques +- `INT UNSIGNED` pour tous les montants monetaires en centimes (anti-FLOAT, voir note 1 du dictionnaire) +- `SMALLINT UNSIGNED` pour les valeurs pour-mille de `vat_rate` (55 ou 100) +- `ENUM(...)` pour les valeurs metier stables (voir note 2 du dictionnaire) +- `DATETIME` pour les horodatages (pas TIMESTAMP, qui se convertit implicitement en UTC dans MariaDB) --- -## 3. MCD -> MLD translation rules applied +## 3. Regles de traduction MCD -> MLD appliquees -### 3.1 Entity -> Table +### 3.1 Entite -> Table -Each MCD entity becomes one table. The conceptual identifier `id` becomes PK -`INT UNSIGNED AUTO_INCREMENT`. Attributes retain their names and types. +Chaque entite MCD devient une table. L'identifiant conceptuel `id` devient une PK +`INT UNSIGNED AUTO_INCREMENT`. Les attributs conservent leurs noms et types. -### 3.2 `(1,1) - (1,N)` association -> simple FK +### 3.2 Association `(1,1) - (1,N)` -> FK simple -The entity on the `(1,1)` side carries the FK toward the `(0,N)` or `(1,N)` entity. +L'entite du cote `(1,1)` porte la FK vers l'entite `(0,N)` ou `(1,N)`. -### 3.3 `(0,N) - (0,N)` or `(1,N) - (1,N)` association -> join table +### 3.3 Association `(0,N) - (0,N)` ou `(1,N) - (1,N)` -> table de jointure -The association becomes its own table with a composite PK of the two FKs. Applied to: +L'association devient sa propre table avec une PK composite des deux FK. Applique a : `product_ingredient`, `menu_slot_option`, `ingredient_allergen`, `role_visible_source`, `role_permission`. -### 3.4 Associative entity with own attributes -> join table with columns +### 3.4 Entite associative avec attributs propres -> table de jointure avec colonnes -When an N-N association carries its own attributes, it becomes a table with those attributes -in addition to the composite FK PK. Applied to `product_ingredient`. +Quand une association N-N porte ses propres attributs, elle devient une table avec ces attributs +en plus de la PK composite des FK. Applique a `product_ingredient`. -### 3.5 Polymorphism -> 2 nullable FKs + discriminator + CHECK +### 3.5 Polymorphisme -> 2 FK nullables + discriminateur + CHECK -`order_item` references either `product` or `menu`. Translated as 2 nullable FK columns + -1 discriminator ENUM + 1 CHECK constraint enforcing mutual exclusivity. +`order_item` reference soit `product` soit `menu`. Traduit en 2 colonnes FK nullables + +1 discriminateur ENUM + 1 contrainte CHECK imposant l'exclusivite mutuelle. --- -## 4. Relational schema (19 tables) +## 4. Schema relationnel (22 tables) -Tables are ordered by dependency (no-FK tables first, then tables that depend on them). +Les tables sont ordonnees par dependance (tables sans FK d'abord, puis tables qui en dependent). + +### Diagrammes relationnels (par sous-domaine) + +Le schema relationnel est presente sous forme de quatre vues Mermaid `erDiagram`, une par sous-domaine (meme +decomposition que le MCD ; un unique diagramme de 22 tables ne se disposerait pas proprement). Elles different +du MCD : les entites associatives sont resolues en tables de jointure avec PK composites, le +polymorphisme de `order_item` apparait sous forme de deux FK nullables (`product_id` / `menu_id`), et chaque +cle etrangere est explicite. Les horodatages d'audit (`created_at` / `updated_at`) sont presents sur la plupart des +tables (voir les sections par table ci-dessous) mais omis des diagrammes pour les garder lisibles. +Les libelles de relation portent la colonne FK et son comportement `ON DELETE`. Les cibles de FK +inter-sous-domaines sont representees comme des tables stub (id + name). Les rendus SVG portables sont dans `_diagrams/` +(`mld-catalogue.svg`, `mld-ingredients-stock.svg`, `mld-order.svg`, `mld-rbac.svg`). + +#### Catalogue + +```mermaid +erDiagram + category { + int id PK + varchar name UK + varchar slug UK + smallint display_order + tinyint is_active + } + product { + int id PK + int category_id FK + varchar name + int price_cents + smallint vat_rate + tinyint is_available + smallint display_order + } + menu { + int id PK + int category_id FK + int burger_product_id FK + varchar name + int price_normal_cents + int price_maxi_cents + tinyint is_available + smallint display_order + } + menu_slot { + int id PK + int menu_id FK + varchar name + enum slot_type + tinyint is_required + smallint display_order + } + menu_slot_option { + int menu_slot_id PK,FK + int product_id PK,FK + } + + category ||--o{ product : "category_id (RESTRICT)" + category ||--o{ menu : "category_id (RESTRICT)" + product ||--o{ menu : "burger_product_id (RESTRICT)" + menu ||--o{ menu_slot : "menu_id (CASCADE)" + menu_slot ||--o{ menu_slot_option : "menu_slot_id (CASCADE)" + product ||--o{ menu_slot_option : "product_id (RESTRICT)" +``` + +#### Ingredients et Stock + +```mermaid +erDiagram + ingredient { + int id PK + varchar name UK + varchar unit + int stock_quantity + int stock_capacity + smallint pack_size + smallint low_stock_pct + smallint critical_stock_pct + tinyint is_active + } + product_ingredient { + int product_id PK,FK + int ingredient_id PK,FK + smallint quantity_normal + smallint quantity_maxi + tinyint is_removable + tinyint is_addable + int extra_price_cents + } + allergen { + int id PK + varchar code UK + varchar name + } + ingredient_allergen { + int ingredient_id PK,FK + int allergen_id PK,FK + } + stock_movement { + int id PK + int ingredient_id FK + enum movement_type + int delta + int order_id FK + int user_id FK + varchar note + } + product { + int id PK + varchar name + } + customer_order { + int id PK + varchar order_number + } + user { + int id PK + varchar email + } + + product ||--o{ product_ingredient : "product_id (CASCADE)" + ingredient ||--o{ product_ingredient : "ingredient_id (RESTRICT)" + ingredient ||--o{ ingredient_allergen : "ingredient_id (CASCADE)" + allergen ||--o{ ingredient_allergen : "allergen_id (RESTRICT)" + ingredient ||--o{ stock_movement : "ingredient_id (RESTRICT)" + customer_order ||--o{ stock_movement : "order_id (SET NULL, nullable)" + user ||--o{ stock_movement : "user_id (SET NULL, nullable)" +``` + +#### Commande + +```mermaid +erDiagram + customer_order { + int id PK + varchar order_number UK + varchar idempotency_key UK + enum source + int acting_user_id FK + enum service_mode + enum status + int total_ht_cents + int total_vat_cents + int total_ttc_cents + datetime paid_at + datetime delivered_at + datetime cancelled_at + } + order_item { + int id PK + int order_id FK + enum item_type + int product_id FK + int menu_id FK + enum format + varchar label_snapshot + int unit_price_cents_snapshot + smallint vat_rate_snapshot + smallint quantity + } + order_item_selection { + int id PK + int order_item_id FK + int menu_slot_id FK + int product_id FK + varchar label_snapshot + } + order_item_modifier { + int id PK + int order_item_id FK + int ingredient_id FK + enum action + int extra_price_cents + } + user { + int id PK + varchar email + } + product { + int id PK + varchar name + } + menu { + int id PK + varchar name + } + menu_slot { + int id PK + varchar name + } + ingredient { + int id PK + varchar name + } + + user ||--o{ customer_order : "acting_user_id (SET NULL, nullable)" + customer_order ||--o{ order_item : "order_id (CASCADE)" + product ||--o{ order_item : "product_id (RESTRICT, polymorphic)" + menu ||--o{ order_item : "menu_id (RESTRICT, polymorphic)" + order_item ||--o{ order_item_selection : "order_item_id (CASCADE)" + menu_slot ||--o{ order_item_selection : "menu_slot_id (RESTRICT)" + product ||--o{ order_item_selection : "product_id (RESTRICT)" + order_item ||--o{ order_item_modifier : "order_item_id (CASCADE)" + ingredient ||--o{ order_item_modifier : "ingredient_id (RESTRICT)" +``` + +#### RBAC & securite + +```mermaid +erDiagram + role { + int id PK + varchar code UK + varchar label + varchar default_route + enum order_source + tinyint is_active + } + user { + int id PK + varchar email UK + varchar password_hash + varchar pin_hash + varchar first_name + varchar last_name + int role_id FK + tinyint is_active + smallint failed_login_attempts + datetime lockout_until + datetime anonymized_at + } + role_visible_source { + int role_id PK,FK + enum source PK + } + permission { + int id PK + varchar code UK + varchar label + } + role_permission { + int role_id PK,FK + int permission_id PK,FK + } + audit_log { + int id PK + int actor_user_id FK + int actor_role_id FK + varchar action_code + varchar entity_type + int entity_id + varchar summary + json details + datetime created_at + } + login_throttle { + int id PK + varchar ip_address UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } + pin_throttle { + int id PK + int actor_user_id FK,UK + smallint failed_attempts + datetime window_started_at + datetime lockout_until + datetime last_attempt_at + } + + role ||--o{ user : "role_id (RESTRICT)" + role ||--o{ role_visible_source : "role_id (CASCADE)" + role ||--o{ role_permission : "role_id (CASCADE)" + permission ||--o{ role_permission : "permission_id (CASCADE)" + user ||--o{ audit_log : "actor_user_id (SET NULL, nullable)" + role ||--o{ audit_log : "actor_role_id (SET NULL, nullable)" + user ||--o{ pin_throttle : "actor_user_id (CASCADE)" +``` + +> `login_throttle` n'a pas de FK (une IP n'est pas une entite modelisee) ; elle est autonome, cle par +> `ip_address`. `pin_throttle` (RG-T22) est cle par `actor_user_id` (FK -> `user`, ON DELETE CASCADE) : +> le throttle du PIN porte sur l'utilisateur AGISSANT, dimension distincte du login. --- @@ -109,18 +392,18 @@ category (id, name, slug, [image_path], display_order, is_active, created_at, up UK : slug ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `name` | VARCHAR(60) | NO | Unique display name (see dict 3.1) | -| `slug` | VARCHAR(60) | NO | URL slug, e.g. `burgers` | -| `image_path` | VARCHAR(255) | YES | Relative path from public root | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Kiosk display order | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Soft deactivation | +| `name` | VARCHAR(60) | NO | Nom d'affichage unique (voir dict 3.1) | +| `slug` | VARCHAR(60) | NO | Slug d'URL, p. ex. `burgers` | +| `image_path` | VARCHAR(255) | YES | Chemin relatif depuis la racine publique | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage borne | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactivation logique | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK. Root table for the Catalogue sub-domain. +Pas de FK. Table racine du sous-domaine Catalogue. --- @@ -137,22 +420,22 @@ product (id, #category_id, name, [description], price_cents, vat_rate, CHK : vat_rate IN (55, 100) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `category_id` | INT UNSIGNED | NO | FK -> category | -| `name` | VARCHAR(120) | NO | Product label | -| `description` | TEXT | YES | Optional long description | -| `price_cents` | INT UNSIGNED | NO | A la carte price, incl. VAT, in cents | -| `vat_rate` | SMALLINT UNSIGNED | NO | Per-mille: 100 = 10%, 55 = 5.5% | -| `image_path` | VARCHAR(255) | YES | Relative path from public root | -| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Manual availability toggle | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Within-category display order | +| `name` | VARCHAR(120) | NO | Libelle du produit | +| `description` | TEXT | YES | Description longue optionnelle | +| `price_cents` | INT UNSIGNED | NO | Prix a la carte, TVA incluse, en centimes | +| `vat_rate` | SMALLINT UNSIGNED | NO | Pour-mille : 100 = 10%, 55 = 5.5% | +| `image_path` | VARCHAR(255) | YES | Chemin relatif depuis la racine publique | +| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Bascule de disponibilite manuelle | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage au sein de la categorie | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE RESTRICT** on `category_id`: a category with products cannot be deleted. Prevents -orphaned products. +**ON DELETE RESTRICT** sur `category_id` : une categorie avec des produits ne peut pas etre supprimee. Empeche les +produits orphelins. --- @@ -171,23 +454,23 @@ menu (id, #category_id, #burger_product_id, name, [description], CHK : price_maxi_cents > 0 ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `category_id` | INT UNSIGNED | NO | FK -> category (typically the `menus` category) | -| `burger_product_id` | INT UNSIGNED | NO | FK -> product — the fixed burger that anchors this menu | -| `name` | VARCHAR(120) | NO | e.g. "Menu Le 280" | -| `description` | TEXT | YES | Optional | -| `price_normal_cents` | INT UNSIGNED | NO | Normal format price in cents | -| `price_maxi_cents` | INT UNSIGNED | NO | Maxi format price in cents (~+150 cents) | -| `image_path` | VARCHAR(255) | YES | Typically reuses the burger image | -| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Availability toggle | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Display order | +| `category_id` | INT UNSIGNED | NO | FK -> category (typiquement la categorie `menus`) | +| `burger_product_id` | INT UNSIGNED | NO | FK -> product — le burger fixe qui ancre ce menu | +| `name` | VARCHAR(120) | NO | p. ex. "Menu Le 280" | +| `description` | TEXT | YES | Optionnel | +| `price_normal_cents` | INT UNSIGNED | NO | Prix du format Normal en centimes | +| `price_maxi_cents` | INT UNSIGNED | NO | Prix du format Maxi en centimes (~+150 centimes) | +| `image_path` | VARCHAR(255) | YES | Reutilise typiquement l'image du burger | +| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Bascule de disponibilite | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE RESTRICT** on both FKs: prevents deletion of a category or burger product that -is still referenced by a menu definition. +**ON DELETE RESTRICT** sur les deux FK : empeche la suppression d'une categorie ou d'un produit burger +encore reference par une definition de menu. --- @@ -201,25 +484,25 @@ menu_slot (id, #menu_id, name, slot_type, is_required, display_order) IDX : (menu_id, display_order) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `menu_id` | INT UNSIGNED | NO | FK -> menu | -| `name` | VARCHAR(80) | NO | e.g. "Drink", "Side", "Sauce" | -| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | Semantic role | -| `is_required` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Whether the customer must fill this slot | -| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Display order within menu builder | +| `name` | VARCHAR(80) | NO | p. ex. "Drink", "Side", "Sauce" | +| `slot_type` | ENUM('drink','side','sauce','dessert','extra') | NO | Role semantique | +| `is_required` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Indique si le client doit remplir ce slot | +| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Ordre d'affichage dans le constructeur de menu | -**No audit fields**: a slot is part of menu definition; created and updated together with -the menu. +**Pas de champs d'audit** : un slot fait partie de la definition du menu ; cree et mis a jour en meme temps que +le menu. -**ON DELETE CASCADE** on `menu_id`: if a menu is deleted, its slots are deleted with it. +**ON DELETE CASCADE** sur `menu_id` : si un menu est supprime, ses slots sont supprimes avec lui. --- ### 4.5 `menu_slot_option` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` menu_slot_option (#menu_slot_id, #product_id) @@ -229,52 +512,76 @@ menu_slot_option (#menu_slot_id, #product_id) FK : product_id -> product(id) ON DELETE RESTRICT ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot | | `product_id` | INT UNSIGNED | NO | FK -> product | -**ON DELETE CASCADE** on `menu_slot_id`: if a slot is deleted, its eligibility list goes with it. -**ON DELETE RESTRICT** on `product_id`: a product listed as eligible in a slot cannot be -deleted without first removing it from the slot options. Prevents silent breakage of menus. +**ON DELETE CASCADE** sur `menu_slot_id` : si un slot est supprime, sa liste d'eligibilite disparait avec lui. +**ON DELETE RESTRICT** sur `product_id` : un produit liste comme eligible dans un slot ne peut pas etre +supprime sans le retirer d'abord des options du slot. Empeche la rupture silencieuse des menus. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. --- ### 4.6 `ingredient` ``` -ingredient (id, name, unit, stock_quantity, pack_size, [pack_label], - low_stock_threshold, is_active, created_at, updated_at) +ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_label], + low_stock_pct, critical_stock_pct, is_active, created_at, updated_at) PK : id UK : name - CHK : stock_quantity >= 0 + CHK : stock_capacity > 0 CHK : pack_size > 0 - CHK : low_stock_threshold >= 0 + CHK : low_stock_pct BETWEEN 0 AND 100 + CHK : critical_stock_pct BETWEEN 0 AND 100 + CHK : critical_stock_pct < low_stock_pct ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `name` | VARCHAR(120) | NO | Unique name, e.g. "Sesame Bun" | -| `unit` | VARCHAR(40) | NO | Packaging unit label (free-form, not ENUM) | -| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Current stock. Signed INT to detect negative (alert) | -| `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units per restocking pack | -| `pack_label` | VARCHAR(80) | YES | Human label of the pack | -| `low_stock_threshold` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Alert threshold | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivate obsolete ingredients | +| `name` | VARCHAR(120) | NO | Nom unique, p. ex. "Sesame Bun" | +| `unit` | VARCHAR(40) | NO | Libelle d'unite de conditionnement (libre, pas ENUM) | +| `stock_quantity` | INT NOT NULL DEFAULT 0 | NO | Stock courant. INT signe pouvant devenir negatif quand les ventes depassent le stock compte (ampleur de la survente, remontee aux managers) ; le systeme ne bloque pas une commande sur le stock | +| `stock_capacity` | INT NOT NULL | NO | Niveau "plein" de reference en unites = le 100% utilise pour calculer le pourcentage de stock ; CHECK > 0 protege aussi la division du pourcentage contre la division par zero | +| `pack_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites par lot de reapprovisionnement | +| `pack_label` | VARCHAR(80) | YES | Libelle humain du lot | +| `low_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Bande d’alerte, pourcentage de la capacite (CHECK BETWEEN 0 AND 100) | +| `critical_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 5 | NO | Plancher de rupture automatique, pourcentage de la capacite (CHECK BETWEEN 0 AND 100 ; CHECK de table `critical_stock_pct < low_stock_pct`) | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactiver les ingredients obsoletes | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK. Root table for the Ingredients & Stock sub-domain. +Pas de FK. Table racine du sous-domaine Ingredients & Stock. + +**Modele de stock base sur les pourcentages** : l'etat de stock est calcule (PAS stocke) comme +`stock_pct = ROUND(stock_quantity / stock_capacity * 100)`. Deux bandes en derivent : +`LOW` quand `stock_quantity <= stock_capacity * low_stock_pct/100`, et +`CRITICAL` quand `stock_quantity <= stock_capacity * critical_stock_pct/100`. +Comportement a trois bandes : au-dessus de `low` = normal ; entre `critical` et `low` = commandable +plus alerte manager (le manager soit retire le produit via `product.is_available=0`, soit +reapprovisionne pour lever l'alerte) ; au niveau ou en dessous de `critical` = rupture automatique (calculee, regle +RG-T21). `stock_quantity` est signe et peut devenir negatif ; le systeme ne bloque pas une commande +sur le stock, donc une valeur negative enregistre l'ampleur de la survente pour les managers. + +**Disponibilite calculee (regle RG-T21)** : un produit est effectivement commandable quand +`product.is_available = 1` ET chaque ingredient non-retirable (`is_removable=0`) de son +`product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct/100`. A la +bande critique, un produit passe automatiquement en rupture sans ecriture ni cascade ; un retrait manuel +(`product.is_available=0`) est une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le +produit a nouveau commandable de lui-meme ; un ingredient retirable/optionnel a la bande critique ne +bloque pas le produit (seul son supplement devient indisponible). Le tableau de bord distingue un +retrait manuel (`is_available=0`) d'une rupture pilotee par le stock (`is_available=1` mais un ingredient +requis est critique). --- ### 4.7 `product_ingredient` -Associative table carrying recipe and customisation metadata. Composite PK. +Table associative portant les metadonnees de recette et de personnalisation. PK composite. ``` product_ingredient (#product_id, #ingredient_id, quantity_normal, quantity_maxi, @@ -288,21 +595,21 @@ product_ingredient (#product_id, #ingredient_id, quantity_normal, quantity_maxi, CHK : extra_price_cents >= 0 ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `product_id` | INT UNSIGNED | NO | FK -> product | | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | -| `quantity_normal` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units consumed in Normal format | -| `quantity_maxi` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Units consumed in Maxi format; equals `quantity_normal` for burger/sauce (format-invariant), higher for side/drink | -| `is_removable` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Customer may remove at no cost | -| `is_addable` | TINYINT(1) NOT NULL DEFAULT 0 | NO | Customer may add an extra unit | -| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Surcharge if `is_addable=1` and customer adds it | +| `quantity_normal` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites consommees au format Normal | +| `quantity_maxi` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites consommees au format Maxi ; egal a `quantity_normal` pour burger/sauce (invariant au format), superieur pour side/drink | +| `is_removable` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Le client peut retirer sans frais | +| `is_addable` | TINYINT(1) NOT NULL DEFAULT 0 | NO | Le client peut ajouter une unite supplementaire | +| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Supplement si `is_addable=1` et que le client l'ajoute | -**ON DELETE CASCADE** on `product_id`: if a product is deleted, its recipe rows are deleted. -**ON DELETE RESTRICT** on `ingredient_id`: cannot delete an ingredient still referenced in a -recipe. Admin must remove the product-ingredient link first. +**ON DELETE CASCADE** sur `product_id` : si un produit est supprime, ses lignes de recette sont supprimees. +**ON DELETE RESTRICT** sur `ingredient_id` : impossible de supprimer un ingredient encore reference dans une +recette. L'administrateur doit d'abord retirer le lien produit-ingredient. -No timestamps. Join table with attributes. +Pas d'horodatages. Table de jointure avec attributs. --- @@ -315,21 +622,21 @@ allergen (id, code, name, [description]) UK : code ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `code` | VARCHAR(30) | NO | Machine code, e.g. `gluten`, `milk` | -| `name` | VARCHAR(80) | NO | Display name | -| `description` | TEXT | YES | Optional guidance | +| `code` | VARCHAR(30) | NO | Code machine, p. ex. `gluten`, `milk` | +| `name` | VARCHAR(80) | NO | Nom d'affichage | +| `description` | TEXT | YES | Indication optionnelle | -No FK. Reference table; 14 rows at seed (INCO Regulation (EU) 1169/2011). -No `updated_at`: allergen catalogue is considered stable (additions require a migration, not a UI action). +Pas de FK. Table de reference ; 14 lignes au seed (Reglement INCO (UE) 1169/2011). +Pas de `updated_at` : le catalogue d'allergenes est considere stable (les ajouts requierent une migration, pas une action UI). --- ### 4.9 `ingredient_allergen` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` ingredient_allergen (#ingredient_id, #allergen_id) @@ -339,21 +646,21 @@ ingredient_allergen (#ingredient_id, #allergen_id) FK : allergen_id -> allergen(id) ON DELETE RESTRICT ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | | `allergen_id` | INT UNSIGNED | NO | FK -> allergen | -**ON DELETE CASCADE** on `ingredient_id`: if an ingredient is deleted, its allergen links go with it. -**ON DELETE RESTRICT** on `allergen_id`: an allergen in the regulated catalogue cannot be deleted. +**ON DELETE CASCADE** sur `ingredient_id` : si un ingredient est supprime, ses liens d'allergenes disparaissent avec lui. +**ON DELETE RESTRICT** sur `allergen_id` : un allergene du catalogue reglemente ne peut pas etre supprime. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. --- ### 4.10 `role` -Placed before `user` because `user` depends on `role`. +Placee avant `user` car `user` depend de `role`. ``` role (id, code, label, [description], [default_route], [order_source], @@ -363,27 +670,29 @@ role (id, code, label, [description], [default_route], [order_source], UK : code ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `code` | VARCHAR(40) | NO | Machine code: `admin`, `manager`, `kitchen`, `counter`, `drive` | -| `label` | VARCHAR(80) | NO | Display name | -| `description` | TEXT | YES | Optional | -| `default_route` | VARCHAR(120) | YES | Landing screen, e.g. `/admin/dashboard` | -| `order_source` | ENUM('kiosk','counter','drive') | YES | Auto-tagged source when this role creates an order; NULL for admin/manager | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation preserves history | +| `code` | VARCHAR(40) | NO | Code machine : `admin`, `manager`, `kitchen`, `counter`, `drive` | +| `label` | VARCHAR(80) | NO | Nom d'affichage | +| `description` | TEXT | YES | Optionnel | +| `default_route` | VARCHAR(120) | YES | Ecran d'arrivee, p. ex. `/admin/dashboard` | +| `order_source` | ENUM('kiosk','counter','drive') | YES | Source auto-etiquetee quand ce role cree une commande ; NULL pour admin/manager | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | La desactivation preserve l'historique | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK. Root table for RBAC. +Pas de FK. Table racine pour le RBAC. --- ### 4.11 `user` ``` -user (id, email, password_hash, first_name, last_name, #role_id, - is_active, [last_login_at], created_at, updated_at) +user (id, email, password_hash, [pin_hash], first_name, last_name, #role_id, + is_active, [last_login_at], failed_login_attempts, [last_failed_login_at], + [lockout_until], [password_reset_token_hash], [password_reset_expires_at], + [anonymized_at], created_at, updated_at) PK : id UK : email @@ -391,27 +700,40 @@ user (id, email, password_hash, first_name, last_name, #role_id, IDX : (is_active, role_id) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `email` | VARCHAR(254) | NO | RFC 5321 max length | -| `password_hash` | VARCHAR(255) | NO | argon2id hash | -| `first_name` | VARCHAR(60) | NO | | -| `last_name` | VARCHAR(60) | NO | | +| `email` | VARCHAR(254) | NO | Longueur max RFC 5321. PII (anonymisation RGPD, voir ci-dessous) | +| `password_hash` | VARCHAR(255) | NO | hash argon2id | +| `pin_hash` | VARCHAR(255) | YES | hash argon2id du PIN par membre du personnel (autorisation d'action sensible). Security-by-design | +| `first_name` | VARCHAR(60) | NO | PII | +| `last_name` | VARCHAR(60) | NO | PII | | `role_id` | INT UNSIGNED | NO | FK -> role | -| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Deactivation without deletion | -| `last_login_at` | DATETIME | YES | Audit, dormant account detection | +| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactivation sans suppression | +| `last_login_at` | DATETIME | YES | Audit, detection de compte dormant | +| `failed_login_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Compteur de force brute (throttling degressif) | +| `last_failed_login_at` | DATETIME | YES | Horodatage de la derniere connexion echouee | +| `lockout_until` | DATETIME | YES | Fin de la fenetre de throttling courante (backoff, pas un verrou indefini) | +| `password_reset_token_hash` | VARCHAR(255) | YES | Hash du token de reinitialisation (pas le token brut) | +| `password_reset_expires_at` | DATETIME | YES | Expiration du token de reinitialisation | +| `anonymized_at` | DATETIME | YES | Marqueur tombstone RGPD ; PII annulees/remplacees quand defini | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE RESTRICT** on `role_id`: a role cannot be deleted while users hold it. -Deactivate the role first (`is_active = 0`), then reassign users before deleting. +**ON DELETE RESTRICT** sur `role_id` : un role ne peut pas etre supprime tant que des utilisateurs le detiennent. +Desactivez d'abord le role (`is_active = 0`), puis reaffectez les utilisateurs avant suppression. + +**Anonymisation RGPD** (security-by-design, note 13 du dict.) : le droit a l'effacement est honore en +anonymisant, pas en supprimant physiquement. `email` devient un placeholder unique non identifiant +(`anon-@wakdo.invalid`, domaine reserve RFC 2606 — preserve la contrainte UNIQUE), +`first_name`/`last_name` sont effaces, `password_hash`/`pin_hash` sont invalides, `is_active=0`, +`anonymized_at = NOW()`. La ligne persiste pour que les FK `audit_log` et `stock_movement` restent valides. --- ### 4.12 `role_visible_source` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` role_visible_source (#role_id, source) @@ -420,20 +742,20 @@ role_visible_source (#role_id, source) FK : role_id -> role(id) ON DELETE CASCADE ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `role_id` | INT UNSIGNED | NO | FK -> role | -| `source` | ENUM('kiosk','counter','drive') | NO | Order source visible on dashboard | +| `source` | ENUM('kiosk','counter','drive') | NO | Source de commande visible sur le tableau de bord | -**ON DELETE CASCADE** on `role_id`: if a role is deleted, its dashboard source filters go with it. +**ON DELETE CASCADE** sur `role_id` : si un role est supprime, ses filtres de source du tableau de bord disparaissent avec lui. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. -Seed data: -- `kitchen`: kiosk, counter, drive -- `counter`: kiosk, counter -- `drive`: drive -- `admin`, `manager`: no rows (global view, no source filter) +Donnees de seed : +- `kitchen` : kiosk, counter, drive +- `counter` : kiosk, counter +- `drive` : drive +- `admin`, `manager` : pas de lignes (vue globale, pas de filtre de source) --- @@ -446,22 +768,22 @@ permission (id, code, label, [description], created_at) UK : code ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `code` | VARCHAR(60) | NO | Format `.` | -| `label` | VARCHAR(120) | NO | Display name | -| `description` | TEXT | YES | Optional | +| `label` | VARCHAR(120) | NO | Nom d'affichage | +| `description` | TEXT | YES | Optionnel | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | -No `updated_at`: permissions are declared in migration and not modified via UI. -Catalogue is frozen at 23 codes (see dictionary section 3.17). +Pas de `updated_at` : les permissions sont declarees en migration et non modifiees via l'UI. +Le catalogue est fige a 23 codes (voir dictionnaire section 3.17). --- ### 4.14 `role_permission` -Pure join table. Composite PK. +Table de jointure pure. PK composite. ``` role_permission (#role_id, #permission_id) @@ -472,29 +794,32 @@ role_permission (#role_id, #permission_id) IDX : permission_id ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `role_id` | INT UNSIGNED | NO | FK -> role | | `permission_id` | INT UNSIGNED | NO | FK -> permission | -**ON DELETE CASCADE** on both FKs: deleting a role or a permission removes its mappings. -The secondary index on `permission_id` supports the reverse query "which roles have this -permission?" without scanning the full table. +**ON DELETE CASCADE** sur les deux FK : supprimer un role ou une permission retire ses associations. +L'index secondaire sur `permission_id` supporte la requete inverse "quels roles ont cette +permission ?" sans scanner la table entiere. -No timestamps. Pure join table. +Pas d'horodatages. Table de jointure pure. --- ### 4.15 `customer_order` ``` -customer_order (id, order_number, source, service_mode, status, +customer_order (id, order_number, [idempotency_key], source, [#acting_user_id], + service_mode, status, total_ht_cents, total_vat_cents, total_ttc_cents, [paid_at], [delivered_at], [cancelled_at], created_at, updated_at) PK : id UK : order_number + UK : idempotency_key + FK : acting_user_id -> user(id) ON DELETE SET NULL IDX : (status, created_at) IDX : (source, created_at) IDX : created_at @@ -505,44 +830,49 @@ customer_order (id, order_number, source, service_mode, status, CHK : source != 'drive' OR service_mode = 'drive' ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `order_number` | VARCHAR(20) | NO | Format `K/C/D-YYYY-MM-DD-NNN` by channel | -| `source` | ENUM('kiosk','counter','drive') | NO | Input channel | -| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Consumption mode (stats only, no fiscal role) | -| `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | 4-state machine | -| `total_ht_cents` | INT UNSIGNED | NO | Ex-VAT total snapshot | -| `total_vat_cents` | INT UNSIGNED | NO | VAT amount snapshot | -| `total_ttc_cents` | INT UNSIGNED | NO | Incl.-VAT total; must equal HT + VAT | -| `paid_at` | DATETIME | YES | Timestamp of transition to `paid` | -| `delivered_at` | DATETIME | YES | Timestamp of transition to `delivered` | -| `cancelled_at` | DATETIME | YES | Timestamp of cancellation | -| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Used as `service_day` base | +| `order_number` | VARCHAR(20) | NO | Format `K/C/D-YYYY-MM-DD-NNN` par canal | +| `idempotency_key` | VARCHAR(36) | YES | UUID client, UNIQUE ; deduplique un POST reessaye (security-by-design) | +| `source` | ENUM('kiosk','counter','drive') | NO | Canal de saisie | +| `acting_user_id` | INT UNSIGNED | YES | FK -> user ; personnel counter/drive sous PIN ; NULL pour kiosk | +| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Mode de consommation (stats uniquement, pas de role fiscal) | +| `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | Machine a 4 etats | +| `total_ht_cents` | INT UNSIGNED | NO | Snapshot du total HT | +| `total_vat_cents` | INT UNSIGNED | NO | Snapshot du montant de TVA | +| `total_ttc_cents` | INT UNSIGNED | NO | Total TTC ; doit egaler HT + TVA | +| `paid_at` | DATETIME | YES | Horodatage de la transition vers `paid` | +| `delivered_at` | DATETIME | YES | Horodatage de la transition vers `delivered` | +| `cancelled_at` | DATETIME | YES | Horodatage de l'annulation | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Utilise comme base de `service_day` | | `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit | -No FK toward `user`: staff attribution is not stored on the order. Operational accountability -is covered by `stock_movement.user_id` for stock actions. +**Attribution du personnel (security-by-design)** : `acting_user_id` (FK -> `user`, ON DELETE SET NULL) +enregistre le personnel counter/drive qui a pris la commande sous PIN ; NULL pour les commandes kiosk anonymes. +Les commandes kiosk restent anonymes par conception. `stock_movement.user_id` couvre l'attribution des actions +de stock. `idempotency_key` (UNIQUE, nullable) deduplique un `POST /api/orders` reessaye +(plusieurs NULL autorises par l'index UNIQUE, donc les chemins legacy non idempotents sont toleres). -**4-state machine**: `pending_payment -> paid -> delivered` (+ `cancelled`). States `preparing` -and `ready` are dropped (decision D4). KPI: `delivered_at - paid_at` (target SLA ~10 min). +**Machine a 4 etats** : `pending_payment -> paid -> delivered` (+ `cancelled`). Les etats `preparing` +et `ready` sont abandonnes (decision D4). KPI : `delivered_at - paid_at` (SLA cible ~10 min). -**`service_day` computation** (used in stats queries — NOT a stored column): +**Calcul de `service_day`** (utilise dans les requetes de stats — PAS une colonne stockee) : ```sql CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END ``` -Cutoff: 10:00. The generated-column formula with `INTERVAL 4 HOUR 30 MINUTE` from v0.1 was -incorrect and is dropped (decision D6). +Coupure : 10:00. La formule de colonne generee avec `INTERVAL 4 HOUR 30 MINUTE` de la v0.1 etait +incorrecte et est abandonnee (decision D6). -**VAT calculation**: totals on `customer_order` are the sum of line-level calculations. -Line-level VAT: `unit_price_cents_snapshot * quantity` is the TTC amount per line; -HT = `ROUND(ttc_cents * 100 / (100 + vat_rate_per_cent))` where `vat_rate_per_cent` -is `vat_rate_snapshot / 10`. Computed at application layer at cart validation. +**Calcul de TVA** : les totaux sur `customer_order` sont la somme des calculs au niveau ligne. +TVA au niveau ligne : `unit_price_cents_snapshot * quantity` est le montant TTC par ligne ; +HT = `ROUND(ttc_cents * 100 / (100 + vat_rate_per_cent))` ou `vat_rate_per_cent` +vaut `vat_rate_snapshot / 10`. Calcule au niveau applicatif a la validation du panier. -**`source = 'drive' => service_mode = 'drive'`**: the CHECK enforces this at DB level. +**`source = 'drive' => service_mode = 'drive'`** : le CHECK l'impose au niveau de la BD. --- @@ -565,32 +895,32 @@ order_item (id, #order_id, item_type, [#product_id], [#menu_id], format, OR (item_type = 'menu' AND menu_id IS NOT NULL AND product_id IS NULL) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `order_id` | INT UNSIGNED | NO | FK -> customer_order | -| `item_type` | ENUM('product','menu') | NO | Discriminator | -| `product_id` | INT UNSIGNED | YES | Non-null if `item_type = 'product'`, NULL otherwise | -| `menu_id` | INT UNSIGNED | YES | Non-null if `item_type = 'menu'`, NULL otherwise | -| `format` | ENUM('normal','maxi') NOT NULL DEFAULT 'normal' | NO | Menu format. For standalone products, value is `normal` | -| `label_snapshot` | VARCHAR(120) | NO | Label at time of order | -| `unit_price_cents_snapshot` | INT UNSIGNED | NO | Unit price incl. VAT at time of order | -| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | VAT rate per-mille at time of order | -| `quantity` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Quantity (e.g. 3 drinks = 1 line, quantity=3) | +| `item_type` | ENUM('product','menu') | NO | Discriminateur | +| `product_id` | INT UNSIGNED | YES | Non-null si `item_type = 'product'`, NULL sinon | +| `menu_id` | INT UNSIGNED | YES | Non-null si `item_type = 'menu'`, NULL sinon | +| `format` | ENUM('normal','maxi') NOT NULL DEFAULT 'normal' | NO | Format du menu. Pour les produits autonomes, la valeur est `normal` | +| `label_snapshot` | VARCHAR(120) | NO | Libelle au moment de la commande | +| `unit_price_cents_snapshot` | INT UNSIGNED | NO | Prix unitaire TVA incluse au moment de la commande | +| `vat_rate_snapshot` | SMALLINT UNSIGNED | NO | Taux de TVA pour-mille au moment de la commande | +| `quantity` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Quantite (p. ex. 3 boissons = 1 ligne, quantity=3) | | `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Audit | -**ON DELETE CASCADE** on `order_id`: lines are deleted with the order. -**ON DELETE RESTRICT** on `product_id` and `menu_id`: a product or menu referenced in an -historical order line cannot be deleted. The snapshot makes the FK reference non-critical -for display, but RESTRICT avoids silent orphaning of the relational structure. +**ON DELETE CASCADE** sur `order_id` : les lignes sont supprimees avec la commande. +**ON DELETE RESTRICT** sur `product_id` et `menu_id` : un produit ou menu reference dans une +ligne de commande historique ne peut pas etre supprime. Le snapshot rend la reference FK non critique +pour l'affichage, mais RESTRICT evite l'orphelinage silencieux de la structure relationnelle. -**Polymorphism exclusivity CHECK**: MariaDB 10.2+ enforces this at INSERT/UPDATE time. +**CHECK d'exclusivite du polymorphisme** : MariaDB 10.2+ l'impose au moment de l'INSERT/UPDATE. --- ### 4.17 `order_item_selection` -Customer's choice for one slot of a menu order line. +Choix du client pour un slot d'une ligne de commande de menu. ``` order_item_selection (id, #order_item_id, #menu_slot_id, #product_id, label_snapshot) @@ -602,27 +932,27 @@ order_item_selection (id, #order_item_id, #menu_slot_id, #product_id, label_snap IDX : order_item_id ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | -| `order_item_id` | INT UNSIGNED | NO | FK -> order_item (must be a menu-type line) | -| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot (which slot was filled) | -| `product_id` | INT UNSIGNED | NO | FK -> product (chosen by customer) | -| `label_snapshot` | VARCHAR(120) | NO | Product label at time of order | +| `order_item_id` | INT UNSIGNED | NO | FK -> order_item (doit etre une ligne de type menu) | +| `menu_slot_id` | INT UNSIGNED | NO | FK -> menu_slot (quel slot a ete rempli) | +| `product_id` | INT UNSIGNED | NO | FK -> product (choisi par le client) | +| `label_snapshot` | VARCHAR(120) | NO | Libelle du produit au moment de la commande | -**ON DELETE CASCADE** on `order_item_id`: if the parent order line is deleted, its slot -selections go with it. -**ON DELETE RESTRICT** on `menu_slot_id` and `product_id`: historical slot choice records -must not be silently broken by catalogue changes. +**ON DELETE CASCADE** sur `order_item_id` : si la ligne de commande parente est supprimee, ses +selections de slot disparaissent avec elle. +**ON DELETE RESTRICT** sur `menu_slot_id` et `product_id` : les enregistrements historiques de choix de slot +ne doivent pas etre silencieusement rompus par des changements de catalogue. -Note: the business constraint that `order_item_id` references a line with `item_type='menu'` -is enforced at application layer (not in MariaDB without a trigger or deferred constraint). +Note : la contrainte metier voulant que `order_item_id` reference une ligne avec `item_type='menu'` +est imposee au niveau applicatif (pas dans MariaDB sans trigger ou contrainte differee). --- ### 4.18 `order_item_modifier` -Ingredient-level modification applied by the customer to a product or the fixed burger of a menu. +Modification au niveau ingredient appliquee par le client a un produit ou au burger fixe d'un menu. ``` order_item_modifier (id, #order_item_id, #ingredient_id, action, extra_price_cents) @@ -634,27 +964,27 @@ order_item_modifier (id, #order_item_id, #ingredient_id, action, extra_price_cen CHK : extra_price_cents >= 0 ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `order_item_id` | INT UNSIGNED | NO | FK -> order_item | | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | -| `action` | ENUM('remove','add') | NO | `remove` = free removal; `add` = extra unit | -| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Snapshot of surcharge at time of order (0 for removals) | +| `action` | ENUM('remove','add') | NO | `remove` = retrait gratuit ; `add` = unite supplementaire | +| `extra_price_cents` | INT UNSIGNED NOT NULL DEFAULT 0 | NO | Snapshot du supplement au moment de la commande (0 pour les retraits) | -**ON DELETE CASCADE** on `order_item_id`: if the order line is deleted, its modifiers go with it. -**ON DELETE RESTRICT** on `ingredient_id`: an ingredient referenced in a historical modifier -cannot be deleted. +**ON DELETE CASCADE** sur `order_item_id` : si la ligne de commande est supprimee, ses modificateurs disparaissent avec elle. +**ON DELETE RESTRICT** sur `ingredient_id` : un ingredient reference dans un modificateur historique +ne peut pas etre supprime. -**Modifier attachment for menu lines**: the modifiable product is the fixed burger, resolved -via `order_item.menu_id -> menu.burger_product_id`. No additional FK column is needed on -this table (see dictionary note 10). +**Rattachement du modificateur pour les lignes de menu** : le produit modifiable est le burger fixe, resolu +via `order_item.menu_id -> menu.burger_product_id`. Aucune colonne FK supplementaire n'est necessaire sur +cette table (voir note 10 du dictionnaire). --- ### 4.19 `stock_movement` -Append-only audit log of all stock changes per ingredient. +Journal d'audit append-only de tous les changements de stock par ingredient. ``` stock_movement (id, #ingredient_id, movement_type, delta, @@ -668,239 +998,369 @@ stock_movement (id, #ingredient_id, movement_type, delta, IDX : (movement_type, created_at) ``` -| Column | Type | NULL | Notes | +| Colonne | Type | NULL | Notes | |---|---|---|---| | `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | | `ingredient_id` | INT UNSIGNED | NO | FK -> ingredient | -| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | Nature of movement | -| `delta` | INT | NO | Signed change: negative for consumption, positive for restock/cancellation/correction | -| `order_id` | INT UNSIGNED | YES | FK -> customer_order; non-null for `sale` and `cancellation` | -| `user_id` | INT UNSIGNED | YES | FK -> user; null for automated sale decrements | -| `note` | VARCHAR(255) | YES | Optional human note | -| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Immutable timestamp | +| `movement_type` | ENUM('sale','cancellation','restock','inventory_correction') | NO | Nature du mouvement | +| `delta` | INT | NO | Changement signe : negatif pour consommation, positif pour reapprovisionnement/annulation/correction | +| `order_id` | INT UNSIGNED | YES | FK -> customer_order ; non-null pour `sale` et `cancellation` | +| `user_id` | INT UNSIGNED | YES | FK -> user ; null pour les decrements de vente automatises | +| `note` | VARCHAR(255) | YES | Note humaine optionnelle | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage immuable | -**ON DELETE RESTRICT** on `ingredient_id`: an ingredient with a movement history cannot be -deleted. Admin must archive the ingredient (`is_active = 0`) instead. -**ON DELETE SET NULL** on `order_id`: if an order is purged from the system, its movement -records remain with `order_id = NULL`. The audit log is preserved; only the order link is lost. -**ON DELETE SET NULL** on `user_id`: if a user is deleted, movement records remain with -`user_id = NULL`. Audit is preserved; individual attribution is lost. +**ON DELETE RESTRICT** sur `ingredient_id` : un ingredient avec un historique de mouvements ne peut pas etre +supprime. L'administrateur doit plutot archiver l'ingredient (`is_active = 0`). +**ON DELETE SET NULL** sur `order_id` : si une commande est purgee du systeme, ses enregistrements de +mouvement restent avec `order_id = NULL`. Le journal d'audit est preserve ; seul le lien de commande est perdu. +**ON DELETE SET NULL** sur `user_id` : si un utilisateur est supprime, les enregistrements de mouvement restent avec +`user_id = NULL`. L'audit est preserve ; l'attribution individuelle est perdue. -**Immutability rule**: no UPDATE or DELETE at application layer. Corrections are new rows -with `movement_type = 'inventory_correction'` and a signed `delta`. +**Regle d'immuabilite** : aucun UPDATE ni DELETE au niveau applicatif. Les corrections sont de nouvelles lignes +avec `movement_type = 'inventory_correction'` et un `delta` signe. -No `updated_at`. Immutable append-only table. +Pas de `updated_at`. Table immuable append-only. --- -## 5. Referential integrity summary +### 4.20 `audit_log` -| FK column | References | ON DELETE | Rationale | +Journal append-only des actions back-office sensibles (security-by-design, dict. 3.20). + +``` +audit_log (id, [#actor_user_id], [#actor_role_id], action_code, + [entity_type], [entity_id], [summary], [details], created_at) + + PK : id + FK : actor_user_id -> user(id) ON DELETE SET NULL + FK : actor_role_id -> role(id) ON DELETE SET NULL + IDX : (actor_user_id, created_at) + IDX : (entity_type, entity_id) + IDX : (action_code, created_at) +``` + +| Colonne | Type | NULL | Notes | |---|---|---|---| -| `product.category_id` | `category(id)` | RESTRICT | No orphaned product | -| `menu.category_id` | `category(id)` | RESTRICT | Same | -| `menu.burger_product_id` | `product(id)` | RESTRICT | Menu definition requires its anchor burger | -| `menu_slot.menu_id` | `menu(id)` | CASCADE | Slots have no meaning without their menu | -| `menu_slot_option.menu_slot_id` | `menu_slot(id)` | CASCADE | Eligibility list disappears with the slot | -| `menu_slot_option.product_id` | `product(id)` | RESTRICT | Removing a product must not silently break menus | -| `product_ingredient.product_id` | `product(id)` | CASCADE | Recipe disappears with the product | -| `product_ingredient.ingredient_id` | `ingredient(id)` | RESTRICT | Cannot remove ingredient still in a recipe | -| `ingredient_allergen.ingredient_id` | `ingredient(id)` | CASCADE | Allergen links disappear with the ingredient | -| `ingredient_allergen.allergen_id` | `allergen(id)` | RESTRICT | Regulated allergen catalogue is immutable | -| `user.role_id` | `role(id)` | RESTRICT | A user cannot exist without a role | -| `role_visible_source.role_id` | `role(id)` | CASCADE | Dashboard filters disappear with the role | -| `role_permission.role_id` | `role(id)` | CASCADE | Permission mappings disappear with the role | -| `role_permission.permission_id` | `permission(id)` | CASCADE | Permission mappings disappear with the permission | -| `order_item.order_id` | `customer_order(id)` | CASCADE | Lines disappear with the order | -| `order_item.product_id` | `product(id)` | RESTRICT | Historical reference must not be silently orphaned | -| `order_item.menu_id` | `menu(id)` | RESTRICT | Same | -| `order_item_selection.order_item_id` | `order_item(id)` | CASCADE | Slot choices disappear with the line | -| `order_item_selection.menu_slot_id` | `menu_slot(id)` | RESTRICT | Historical slot record preserved | -| `order_item_selection.product_id` | `product(id)` | RESTRICT | Historical choice record preserved | -| `order_item_modifier.order_item_id` | `order_item(id)` | CASCADE | Modifiers disappear with the line | -| `order_item_modifier.ingredient_id` | `ingredient(id)` | RESTRICT | Historical modifier record preserved | -| `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Ingredient with history cannot be deleted | -| `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserved, order link lost | -| `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserved, user attribution lost | +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `actor_user_id` | INT UNSIGNED | YES | FK -> user ; personnel agissant (capture par PIN) ou NULL si non attribuable | +| `actor_role_id` | INT UNSIGNED | YES | FK -> role ; contexte de role denormalise (survit a l'anonymisation de l'utilisateur) | +| `action_code` | VARCHAR(60) | NO | Code d'operation MCT / permission, p. ex. `product.update`, `order.cancel` | +| `entity_type` | VARCHAR(40) | YES | Nom de la table affectee | +| `entity_id` | INT UNSIGNED | YES | PK de la ligne affectee | +| `summary` | VARCHAR(255) | YES | Description courte non personnelle du changement | +| `details` | JSON | YES | Diff avant/apres optionnel (noms de champs pour les actions ciblant un utilisateur, pas les valeurs PII) | +| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage immuable | -**Key used**: CASCADE = child has no meaning without parent; RESTRICT = parent deletion -blocked while children exist; SET NULL = child is preserved, only the link is severed. +**ON DELETE SET NULL** sur les deux FK : la piste est preservee quand un utilisateur est anonymise/supprime +ou un role supprime ; seul le lien est rompu (le `actor_role_id` denormalise conserve le contexte de +role meme apres l'anonymisation de l'utilisateur). + +**Regle d'immuabilite** : aucun UPDATE ni DELETE au niveau applicatif. **Retention** : une purge cron +planifiee retire les lignes plus anciennes que la fenetre de retention (~12 mois, interet legitime / +tracabilite fiscale), decouplee du cycle de vie des PII de l'utilisateur (note 13 du dict.). + +Pas de `updated_at`. Table immuable append-only. --- -## 6. CHECK constraints summary +### 4.21 `login_throttle` -| Table | CHECK expression | Purpose | +Throttle de force brute par IP source (security-by-design). Complete le compteur par compte +deja present sur `user` (`failed_login_attempts` / `lockout_until`). + +``` +login_throttle (id, ip_address, failed_attempts, window_started_at, + [lockout_until], last_attempt_at) + + PK : id + UK : ip_address + IDX : lockout_until +``` + +| Colonne | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `ip_address` | VARCHAR(45) | NO | IP source, une ligne par IP, upsertee ; 45 caracteres contiennent un litteral IPv6 complet. UNIQUE | +| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Connexions echouees consecutives depuis cette IP dans la fenetre courante | +| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | Fin de la fenetre de backoff degressif ; NULL = pas throttle | +| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage de la derniere tentative echouee | + +Pas de FK : une IP n'est pas une entite modelisee. Append/upsert par IP ; la fenetre se reinitialise a expiration. Un +cron quotidien purge les lignes sans verrouillage actif dont le `last_attempt_at` est plus ancien que 24h. + +Pas de `updated_at` : les lignes sont upsertees par IP, pas editees via une UI. + +--- + +### 4.22 `pin_throttle` + +Throttle du PIN d'action sensible par utilisateur AGISSANT (security-by-design, RG-T22). Separe des +compteurs de connexion (`user.failed_login_attempts` / `lockout_until` / `login_throttle`) : un echec de +PIN n'incremente aucun compteur de login. + +``` +pin_throttle (id, actor_user_id, failed_attempts, window_started_at, + [lockout_until], last_attempt_at) + + PK : id + UK : actor_user_id + IDX : lockout_until + FK : actor_user_id -> user(id) ON DELETE CASCADE +``` + +| Colonne | Type | NULL | Notes | +|---|---|---|---| +| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK | +| `actor_user_id` | INT UNSIGNED | NO | Utilisateur agissant (session), une ligne par acteur, upsertee. UNIQUE. FK -> `user(id)` ON DELETE CASCADE | +| `failed_attempts` | SMALLINT UNSIGNED NOT NULL DEFAULT 0 | NO | Echecs de PIN consecutifs de cet acteur dans la fenetre courante | +| `window_started_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Debut de la fenetre de comptage courante | +| `lockout_until` | DATETIME | YES | Fin de la fenetre de backoff degressif ; NULL = pas throttle | +| `last_attempt_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Horodatage de la derniere tentative echouee | + +FK ON DELETE CASCADE (contrairement a `login_throttle`) : la cle est un user back-office authentifie, donc +supprimer/anonymiser le compte purge sa ligne de throttle. Append/upsert par acteur ; meme purge cron que +`login_throttle`. Pas de `updated_at` (lignes upsertees, pas editees via une UI). + +--- + +## 5. Resume de l'integrite referentielle + +| Colonne FK | References | ON DELETE | Justification | +|---|---|---|---| +| `product.category_id` | `category(id)` | RESTRICT | Pas de produit orphelin | +| `menu.category_id` | `category(id)` | RESTRICT | Idem | +| `menu.burger_product_id` | `product(id)` | RESTRICT | La definition du menu requiert son burger d'ancrage | +| `menu_slot.menu_id` | `menu(id)` | CASCADE | Les slots n'ont pas de sens sans leur menu | +| `menu_slot_option.menu_slot_id` | `menu_slot(id)` | CASCADE | La liste d'eligibilite disparait avec le slot | +| `menu_slot_option.product_id` | `product(id)` | RESTRICT | Retirer un produit ne doit pas rompre silencieusement les menus | +| `product_ingredient.product_id` | `product(id)` | CASCADE | La recette disparait avec le produit | +| `product_ingredient.ingredient_id` | `ingredient(id)` | RESTRICT | Impossible de retirer un ingredient encore dans une recette | +| `ingredient_allergen.ingredient_id` | `ingredient(id)` | CASCADE | Les liens d'allergenes disparaissent avec l'ingredient | +| `ingredient_allergen.allergen_id` | `allergen(id)` | RESTRICT | Le catalogue d'allergenes reglemente est immuable | +| `user.role_id` | `role(id)` | RESTRICT | Un utilisateur ne peut pas exister sans role | +| `role_visible_source.role_id` | `role(id)` | CASCADE | Les filtres du tableau de bord disparaissent avec le role | +| `role_permission.role_id` | `role(id)` | CASCADE | Les associations de permission disparaissent avec le role | +| `role_permission.permission_id` | `permission(id)` | CASCADE | Les associations de permission disparaissent avec la permission | +| `order_item.order_id` | `customer_order(id)` | CASCADE | Les lignes disparaissent avec la commande | +| `order_item.product_id` | `product(id)` | RESTRICT | La reference historique ne doit pas etre silencieusement orphelinee | +| `order_item.menu_id` | `menu(id)` | RESTRICT | Idem | +| `order_item_selection.order_item_id` | `order_item(id)` | CASCADE | Les choix de slot disparaissent avec la ligne | +| `order_item_selection.menu_slot_id` | `menu_slot(id)` | RESTRICT | Enregistrement historique de slot preserve | +| `order_item_selection.product_id` | `product(id)` | RESTRICT | Enregistrement historique de choix preserve | +| `order_item_modifier.order_item_id` | `order_item(id)` | CASCADE | Les modificateurs disparaissent avec la ligne | +| `order_item_modifier.ingredient_id` | `ingredient(id)` | RESTRICT | Enregistrement historique de modificateur preserve | +| `stock_movement.ingredient_id` | `ingredient(id)` | RESTRICT | Un ingredient avec historique ne peut pas etre supprime | +| `stock_movement.order_id` | `customer_order(id)` | SET NULL | Audit preserve, lien de commande perdu | +| `stock_movement.user_id` | `user(id)` | SET NULL | Audit preserve, attribution utilisateur perdue | +| `customer_order.acting_user_id` | `user(id)` | SET NULL | Attribution du personnel preservee comme principal anonymise ; commande conservee | +| `audit_log.actor_user_id` | `user(id)` | SET NULL | Piste d'audit preservee a l'anonymisation de l'utilisateur ; seul le lien est rompu | +| `audit_log.actor_role_id` | `role(id)` | SET NULL | Contexte de role conserve jusqu'a la suppression du role ; denormalise donc il survit a l'anonymisation de l'utilisateur | +| `pin_throttle.actor_user_id` | `user(id)` | CASCADE | Etat de throttle ephemere : il part avec le compte agissant supprime/anonymise (contrairement a l'audit, permanent) | + +**Cle utilisee** : CASCADE = l'enfant n'a pas de sens sans le parent ; RESTRICT = la suppression du parent +est bloquee tant que des enfants existent ; SET NULL = l'enfant est preserve, seul le lien est rompu. + +--- + +## 6. Resume des contraintes CHECK + +| Table | Expression CHECK | Objectif | |---|---|---| -| `product` | `price_cents > 0` | Zero or negative price is a bug | -| `product` | `vat_rate IN (55, 100)` | Only two legal VAT rates for this model | -| `menu` | `price_normal_cents > 0` | Same as product | -| `menu` | `price_maxi_cents > 0` | Same | -| `ingredient` | `stock_quantity >= 0` | Negative stock is an alert, not a valid state | -| `ingredient` | `pack_size > 0` | Pack size of zero makes restock logic incoherent | -| `ingredient` | `low_stock_threshold >= 0` | Threshold cannot be negative | -| `product_ingredient` | `quantity_normal > 0` | Recipe quantity of zero is meaningless | -| `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consumes at least as much as Normal (side/drink more, burger/sauce equal) | -| `product_ingredient` | `extra_price_cents >= 0` | No negative surcharge | -| `customer_order` | `total_ht_cents >= 0` | Zero is allowed (edge case during cart building) | -| `customer_order` | `total_vat_cents >= 0` | Same | -| `customer_order` | `total_ttc_cents > 0` | A validated order must have a positive total | -| `customer_order` | `total_ttc_cents = total_ht_cents + total_vat_cents` | Arithmetic invariant; defence-in-depth vs application bugs | -| `customer_order` | `source != 'drive' OR service_mode = 'drive'` | Cross-dimension constraint (dict. note 5) | -| `order_item` | `unit_price_cents_snapshot > 0` | Non-zero price at transaction time | -| `order_item` | `vat_rate_snapshot IN (55, 100)` | Snapshot must match allowed rates | -| `order_item` | `quantity > 0` | Non-zero quantity | -| `order_item` | `(item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL) OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)` | Polymorphism: exactly one FK populated per discriminator value | -| `order_item_modifier` | `extra_price_cents >= 0` | Snapshot of surcharge; cannot be negative | +| `product` | `price_cents > 0` | Un prix nul ou negatif est un bug | +| `product` | `vat_rate IN (55, 100)` | Seuls deux taux de TVA legaux pour ce modele | +| `menu` | `price_normal_cents > 0` | Comme pour product | +| `menu` | `price_maxi_cents > 0` | Idem | +| `ingredient` | `stock_capacity > 0` | La reference 100% doit etre positive ; protege aussi la division du pourcentage contre la division par zero | +| `ingredient` | `pack_size > 0` | Une taille de lot nulle rend la logique de reapprovisionnement incoherente | +| `ingredient` | `low_stock_pct BETWEEN 0 AND 100` | La bande d’alerte est un pourcentage de la capacite | +| `ingredient` | `critical_stock_pct BETWEEN 0 AND 100` | Le plancher de rupture automatique est un pourcentage de la capacite | +| `ingredient` | `critical_stock_pct < low_stock_pct` | Le plancher critique se situe sous la bande d’alerte | +| `product_ingredient` | `quantity_normal > 0` | Une quantite de recette nulle n'a pas de sens | +| `product_ingredient` | `quantity_maxi >= quantity_normal` | Maxi consomme au moins autant que Normal (side/drink plus, burger/sauce egal) | +| `product_ingredient` | `extra_price_cents >= 0` | Pas de supplement negatif | +| `customer_order` | `total_ht_cents >= 0` | Zero est autorise (cas limite pendant la construction du panier) | +| `customer_order` | `total_vat_cents >= 0` | Idem | +| `customer_order` | `total_ttc_cents > 0` | Une commande validee doit avoir un total positif | +| `customer_order` | `total_ttc_cents = total_ht_cents + total_vat_cents` | Invariant arithmetique ; defense en profondeur contre les bugs applicatifs | +| `customer_order` | `source != 'drive' OR service_mode = 'drive'` | Contrainte inter-dimensions (note 5 du dict.) | +| `order_item` | `unit_price_cents_snapshot > 0` | Prix non nul au moment de la transaction | +| `order_item` | `vat_rate_snapshot IN (55, 100)` | Le snapshot doit correspondre aux taux autorises | +| `order_item` | `quantity > 0` | Quantite non nulle | +| `order_item` | `(item_type='product' AND product_id IS NOT NULL AND menu_id IS NULL) OR (item_type='menu' AND menu_id IS NOT NULL AND product_id IS NULL)` | Polymorphisme : exactement une FK renseignee par valeur de discriminateur | +| `order_item_modifier` | `extra_price_cents >= 0` | Snapshot du supplement ; ne peut pas etre negatif | --- -## 7. Recommended indexes (beyond PK / UK / FK auto-indexes) +## 7. Index recommandes (au-dela des auto-index PK / UK / FK) -MariaDB InnoDB creates an index automatically for each FK declaration (if no usable index -exists). The following additional indexes target frequent query patterns identified in the +MariaDB InnoDB cree automatiquement un index pour chaque declaration de FK (s'il n'existe pas d'index +utilisable). Les index supplementaires suivants ciblent les patterns de requete frequents identifies dans le MCT / MLT. -| Table | Index columns | Query pattern | +| Table | Colonnes d'index | Pattern de requete | |---|---|---| -| `product` | `(category_id, is_available, display_order)` | Kiosk catalogue load: filter by category + availability, sort by order | -| `menu` | `(category_id, is_available, display_order)` | Same pattern for menus | -| `menu_slot` | `(menu_id, display_order)` | Menu builder: load all slots of a menu in order | -| `customer_order` | `(status, created_at)` | Active orders queue: pending/paid orders sorted by time | -| `customer_order` | `(source, created_at)` | Per-channel analytics and order filtering | -| `customer_order` | `created_at` | Time-range aggregations (hourly stats, `service_day`) | -| `order_item` | `order_id` | Retrieve all lines of an order | -| `order_item_selection` | `order_item_id` | Retrieve slot choices for a menu line | -| `order_item_modifier` | `order_item_id` | Retrieve ingredient modifications for a line | -| `stock_movement` | `(ingredient_id, created_at)` | Per-ingredient stock history (dict. section 3.19) | -| `stock_movement` | `(movement_type, created_at)` | Stats: cancellations per week, restocks per month | -| `role_permission` | `permission_id` | Reverse query: "which roles have this permission?" | -| `user` | `(is_active, role_id)` | Login check + permission resolution | +| `product` | `(category_id, is_available, display_order)` | Chargement du catalogue borne : filtre par categorie + disponibilite, tri par ordre | +| `menu` | `(category_id, is_available, display_order)` | Meme pattern pour les menus | +| `menu_slot` | `(menu_id, display_order)` | Constructeur de menu : charger tous les slots d'un menu dans l'ordre | +| `customer_order` | `(status, created_at)` | File des commandes actives : commandes pending/paid triees par temps | +| `customer_order` | `(source, created_at)` | Analytics par canal et filtrage des commandes | +| `customer_order` | `created_at` | Agregations par plage de temps (stats horaires, `service_day`) | +| `order_item` | `order_id` | Recuperer toutes les lignes d'une commande | +| `order_item_selection` | `order_item_id` | Recuperer les choix de slot d'une ligne de menu | +| `order_item_modifier` | `order_item_id` | Recuperer les modifications d'ingredient d'une ligne | +| `stock_movement` | `(ingredient_id, created_at)` | Historique de stock par ingredient (dict. section 3.19) | +| `stock_movement` | `(movement_type, created_at)` | Stats : annulations par semaine, reapprovisionnements par mois | +| `role_permission` | `permission_id` | Requete inverse : "quels roles ont cette permission ?" | +| `user` | `(is_active, role_id)` | Verification de connexion + resolution des permissions | +| `audit_log` | `(actor_user_id, created_at)` | Historique d'audit par acteur | +| `audit_log` | `(entity_type, entity_id)` | "qu'est-il arrive a ce produit/commande/utilisateur ?" | +| `audit_log` | `(action_code, created_at)` | Audit par type d'action sur une plage de temps | +| `login_throttle` | `lockout_until` | Purge cron quotidienne des lignes sans verrouillage actif | +| `pin_throttle` | `lockout_until` | Purge cron quotidienne des lignes sans verrouillage actif (RG-T22) | -**Indexes not added** (intentional): -- `customer_order.order_number`: UK index is sufficient; no range query expected on this column. -- `customer_order.service_mode`: low cardinality (3 values); full scan on the status index - with a `service_mode` filter is acceptable at expected volume. -- `customer_order.paid_at`: NULL for most in-flight rows; sparse index provides limited benefit. +**Index non ajoutes** (intentionnel) : +- `customer_order.order_number` : l'index UK suffit ; aucune requete de plage attendue sur cette colonne. +- `customer_order.service_mode` : faible cardinalite (3 valeurs) ; un scan complet sur l'index de status + avec un filtre `service_mode` est acceptable au volume attendu. +- `customer_order.paid_at` : NULL pour la plupart des lignes en cours ; un index clairseme apporte un benefice limite. --- -## 8. Cross-validation MLD <-> MCD +## 8. Validation croisee MLD <-> MCD -Verification that all 19 MCD entities map to a table, and that all tables trace to the MCD. +Verification que les 22 entites MCD (19 prod-like + 3 security-by-design) correspondent a une table, +et que toutes les tables se rattachent au MCD. -| MCD entity | MLD table | Mapping type | Notes | +| Entite MCD | Table MLD | Type de mapping | Notes | |---|---|---|---| -| `category` (C1) | `category` (4.1) | 1:1 entity | | -| `product` (C2) | `product` (4.2) | 1:1 entity | | -| `menu` (C3) | `menu` (4.3) | 1:1 entity | New: `burger_product_id`, `price_normal_cents`, `price_maxi_cents` | -| `menu_slot` (C4) | `menu_slot` (4.4) | 1:1 entity | New entity (v0.2) | -| `menu_slot_option` (C5) | `menu_slot_option` (4.5) | Join table (composite PK) | New entity (v0.2) | -| `ingredient` (C6) | `ingredient` (4.6) | 1:1 entity | New entity (v0.2) | -| `product_ingredient` (C7) | `product_ingredient` (4.7) | Join table with attributes | New entity (v0.2) | -| `allergen` (C8) | `allergen` (4.8) | 1:1 entity | New entity (v0.2) | -| `ingredient_allergen` (C9) | `ingredient_allergen` (4.9) | Join table (composite PK) | New entity (v0.2) | -| `role` (C10) | `role` (4.10) | 1:1 entity | New: `default_route`, `order_source` | -| `user` (C11) | `user` (4.11) | 1:1 entity | Columns renamed to English | -| `role_visible_source` (C12) | `role_visible_source` (4.12) | Join table (composite PK) | New entity (v0.2) | -| `permission` (C13) | `permission` (4.13) | 1:1 entity | | -| `role_permission` (C14) | `role_permission` (4.14) | Join table (composite PK) | | -| `customer_order` (C15) | `customer_order` (4.15) | 1:1 entity | Renamed from `commande`; 4-state machine; phase timestamps | -| `order_item` (C16) | `order_item` (4.16) | 1:1 entity | New: `format`, `vat_rate_snapshot`; polymorphism CHECK | -| `order_item_selection` (C17) | `order_item_selection` (4.17) | 1:1 entity | New entity (v0.2) | -| `order_item_modifier` (C18) | `order_item_modifier` (4.18) | 1:1 entity | New entity (v0.2) | -| `stock_movement` (C19) | `stock_movement` (4.19) | 1:1 entity | New entity (v0.2) | +| `category` (C1) | `category` (4.1) | entite 1:1 | | +| `product` (C2) | `product` (4.2) | entite 1:1 | | +| `menu` (C3) | `menu` (4.3) | entite 1:1 | Nouveau : `burger_product_id`, `price_normal_cents`, `price_maxi_cents` | +| `menu_slot` (C4) | `menu_slot` (4.4) | entite 1:1 | Nouvelle entite (v0.2) | +| `menu_slot_option` (C5) | `menu_slot_option` (4.5) | Table de jointure (PK composite) | Nouvelle entite (v0.2) | +| `ingredient` (C6) | `ingredient` (4.6) | entite 1:1 | Nouvelle entite (v0.2) | +| `product_ingredient` (C7) | `product_ingredient` (4.7) | Table de jointure avec attributs | Nouvelle entite (v0.2) | +| `allergen` (C8) | `allergen` (4.8) | entite 1:1 | Nouvelle entite (v0.2) | +| `ingredient_allergen` (C9) | `ingredient_allergen` (4.9) | Table de jointure (PK composite) | Nouvelle entite (v0.2) | +| `role` (C10) | `role` (4.10) | entite 1:1 | Nouveau : `default_route`, `order_source` | +| `user` (C11) | `user` (4.11) | entite 1:1 | Colonnes renommees en anglais | +| `role_visible_source` (C12) | `role_visible_source` (4.12) | Table de jointure (PK composite) | Nouvelle entite (v0.2) | +| `permission` (C13) | `permission` (4.13) | entite 1:1 | | +| `role_permission` (C14) | `role_permission` (4.14) | Table de jointure (PK composite) | | +| `customer_order` (C15) | `customer_order` (4.15) | entite 1:1 | Renommee depuis `commande` ; machine a 4 etats ; horodatages de phase | +| `order_item` (C16) | `order_item` (4.16) | entite 1:1 | Nouveau : `format`, `vat_rate_snapshot` ; CHECK de polymorphisme | +| `order_item_selection` (C17) | `order_item_selection` (4.17) | entite 1:1 | Nouvelle entite (v0.2) | +| `order_item_modifier` (C18) | `order_item_modifier` (4.18) | entite 1:1 | Nouvelle entite (v0.2) | +| `stock_movement` (C19) | `stock_movement` (4.19) | entite 1:1 | Nouvelle entite (v0.2) | +| `audit_log` (R5/R6) | `audit_log` (4.20) | entite 1:1 | Nouvelle entite (security-by-design) | +| `login_throttle` (R7) | `login_throttle` (4.21) | entite 1:1 | Nouvelle entite (security-by-design) | +| `pin_throttle` (R9) | `pin_throttle` (4.22) | entite 1:1 | Nouvelle entite (security-by-design, RG-T22) | -**Result**: 19/19 entities mapped. No entity without a table; no table outside the MCD. +**Resultat** : 22/22 entites mappees (19 prod-like + `audit_log` + `login_throttle` + `pin_throttle`). Aucune entite +sans table ; aucune table hors du MCD. Nouvelles colonnes sur les tables existantes : `user` +(cycle de vie auth + `pin_hash` + `anonymized_at`), `customer_order` (`idempotency_key`, +`acting_user_id`), `ingredient` (`stock_capacity`, `low_stock_pct`, `critical_stock_pct` ; +`low_stock_threshold` reaffecte). -**Dropped from v0.1**: `commande_event` (replaced by `paid_at`, `delivered_at`, `cancelled_at` -phase timestamps on `customer_order` — decision 2.A); `menu_produit` fixed-composition model -(replaced by `menu_slot` + `menu_slot_option` — decision D1). +**Abandonne depuis v0.1** : `commande_event` (remplace par les horodatages de phase `paid_at`, `delivered_at`, `cancelled_at` +sur `customer_order` — decision 2.A) ; le modele de composition fixe `menu_produit` +(remplace par `menu_slot` + `menu_slot_option` — decision D1). --- -## 9. Volume estimation (6 months) +## 9. Estimation de volume (6 mois) -| Table | Rows at 6 months | Avg row size | Est. size | +| Table | Lignes a 6 mois | Taille moyenne de ligne | Taille est. | |---|---|---|---| -| `category` | ~10 | 200 bytes | < 1 KB | -| `product` | ~55 | 400 bytes | ~22 KB | -| `menu` | ~13 | 450 bytes | ~6 KB | -| `menu_slot` | ~40 | 150 bytes | ~6 KB | -| `menu_slot_option` | ~150 | 30 bytes | ~5 KB | -| `ingredient` | ~100 | 300 bytes | ~30 KB | -| `product_ingredient` | ~400 | 40 bytes | ~16 KB | -| `allergen` | 14 | 200 bytes | ~3 KB | -| `ingredient_allergen` | ~200 | 20 bytes | ~4 KB | -| `role` | ~5 | 200 bytes | ~1 KB | -| `user` | ~20 | 500 bytes | ~10 KB | -| `role_visible_source` | ~7 | 15 bytes | < 1 KB | -| `permission` | 23 | 250 bytes | ~6 KB | -| `role_permission` | ~80 | 15 bytes | ~2 KB | -| `customer_order` | ~30k | 300 bytes | ~9 MB | -| `order_item` | ~150k | 250 bytes | ~37 MB | -| `order_item_selection` | ~300k | 150 bytes | ~45 MB | -| `order_item_modifier` | ~150k | 80 bytes | ~12 MB | -| `stock_movement` | ~500k | 180 bytes | ~90 MB | +| `category` | ~10 | 200 octets | < 1 KB | +| `product` | ~55 | 400 octets | ~22 KB | +| `menu` | ~13 | 450 octets | ~6 KB | +| `menu_slot` | ~40 | 150 octets | ~6 KB | +| `menu_slot_option` | ~150 | 30 octets | ~5 KB | +| `ingredient` | ~100 | 300 octets | ~30 KB | +| `product_ingredient` | ~400 | 40 octets | ~16 KB | +| `allergen` | 14 | 200 octets | ~3 KB | +| `ingredient_allergen` | ~200 | 20 octets | ~4 KB | +| `role` | ~5 | 200 octets | ~1 KB | +| `user` | ~20 | 500 octets | ~10 KB | +| `role_visible_source` | ~7 | 15 octets | < 1 KB | +| `permission` | 23 | 250 octets | ~6 KB | +| `role_permission` | ~80 | 15 octets | ~2 KB | +| `customer_order` | ~30k | 300 octets | ~9 MB | +| `order_item` | ~150k | 250 octets | ~37 MB | +| `order_item_selection` | ~300k | 150 octets | ~45 MB | +| `order_item_modifier` | ~150k | 80 octets | ~12 MB | +| `stock_movement` | ~500k | 180 octets | ~90 MB | +| `audit_log` | ~5k-10k | 200 octets | ~2 MB | +| `login_throttle` | ~100-1k | 80 octets | < 1 MB | +| `pin_throttle` | ~10-100 | 80 octets | < 1 MB (1 ligne par user back-office) | -**Estimated total**: ~190 MB data + ~60-80 MB for indexes = ~250-270 MB over 6 months. -Manageable on the MariaDB container (`wakdo_db_data` named volume in `docker-compose.yml`). +**Total estime** : ~190 MB de donnees + ~60-80 MB pour les index = ~250-270 MB sur 6 mois +(`audit_log` est negligeable : les actions sensibles sont d'un ordre de grandeur plus rares que les commandes). +Gerable sur le conteneur MariaDB (volume nomme `wakdo_db_data` dans `docker-compose.yml`). -`stock_movement` is the highest-volume table (~5-15 rows per order across all ingredients). -The `(ingredient_id, created_at)` index is the primary query path for per-ingredient -history; it will carry meaningful write amplification at scale. +`stock_movement` est la table au plus fort volume (~5-15 lignes par commande tous ingredients confondus). +L'index `(ingredient_id, created_at)` est le chemin de requete principal pour l'historique par +ingredient ; il portera une amplification d'ecriture significative a l'echelle. --- -## 10. Decisions deferred to DDL and P2 +## 10. Decisions reportees au DDL et a P2 -1. **MariaDB generated column** for `service_day`: a `VIRTUAL GENERATED` column is technically - possible in MariaDB 5.7+ syntax. If stats queries prove burdensome without a materialised - column, a `STORED GENERATED` column could be added as a migration. For this model, the - applicative CASE expression is retained (simpler, avoids generated-column edge cases). -2. **Partitioning**: `stock_movement` could be partitioned by month if volume exceeds - estimates. Not in scope for the initial DDL. -3. **Triggers**: stock decrement on `paid` transition and re-credit on `cancelled` (from `paid`) - could be implemented as MariaDB triggers or as application-layer logic. To be decided at P2. -4. **Collation**: `utf8mb4_unicode_ci` retained (Unicode-compliant, case-insensitive). - If strict French alphabetical sort is needed, `utf8mb4_fr_0900_ai_ci` is available in - MySQL 8 but not MariaDB; `unicode_ci` is the portable choice. -5. **Migration tooling**: Phinx, Doctrine Migrations, or a plain PHP script. Decision at P2. -6. **`order_item_id` constraint for selections**: the business rule that - `order_item_selection.order_item_id` must reference a line with `item_type='menu'` - is enforced at application layer. A MariaDB trigger could reinforce this at DB level if - needed. +1. **Colonne generee MariaDB** pour `service_day` : une colonne `VIRTUAL GENERATED` est techniquement + possible avec la syntaxe MariaDB 5.7+. Si les requetes de stats s'averent lourdes sans colonne + materialisee, une colonne `STORED GENERATED` pourrait etre ajoutee en migration. Pour ce modele, + l'expression CASE applicative est retenue (plus simple, evite les cas limites des colonnes generees). +2. **Partitionnement** : `stock_movement` pourrait etre partitionnee par mois si le volume depasse les + estimations. Hors perimetre pour le DDL initial. +3. **Triggers** : decrement de stock a la transition `paid` et re-credit a `cancelled` (depuis `paid`) + pourraient etre implementes en triggers MariaDB ou en logique applicative. A decider en P2. +4. **Collation** : `utf8mb4_unicode_ci` retenue (conforme Unicode, insensible a la casse). + Si un tri alphabetique francais strict est necessaire, `utf8mb4_fr_0900_ai_ci` est disponible dans + MySQL 8 mais pas MariaDB ; `unicode_ci` est le choix portable. +5. **Outillage de migration** : Phinx, Doctrine Migrations, ou un simple script PHP. Decision en P2. +6. **Contrainte `order_item_id` pour les selections** : la regle metier voulant que + `order_item_selection.order_item_id` reference une ligne avec `item_type='menu'` + est imposee au niveau applicatif. Un trigger MariaDB pourrait renforcer cela au niveau de la BD si + necessaire. --- -## 11. Next steps (DDL + Seed) +## 11. Prochaines etapes (DDL + Seed) -1. **DDL** (`db/migrations/0001_init_schema.sql`): transcribe this MLD into executable - `CREATE TABLE` statements, in dependency order: +1. **DDL** (`db/migrations/0001_init_schema.sql`) : transcrire ce MLD en instructions + `CREATE TABLE` executables, dans l'ordre de dependance : - `category` -> `product`, `ingredient`, `allergen`, `role` - - `menu` (depends on `category`, `product`) - - `menu_slot` (depends on `menu`), `menu_slot_option` (depends on `menu_slot`, `product`) - - `product_ingredient` (depends on `product`, `ingredient`) - - `ingredient_allergen` (depends on `ingredient`, `allergen`) - - `user` (depends on `role`), `role_visible_source` (depends on `role`) - - `permission`, `role_permission` (depends on `role`, `permission`) + - `menu` (depend de `category`, `product`) + - `menu_slot` (depend de `menu`), `menu_slot_option` (depend de `menu_slot`, `product`) + - `product_ingredient` (depend de `product`, `ingredient`) + - `ingredient_allergen` (depend de `ingredient`, `allergen`) + - `user` (depend de `role`), `role_visible_source` (depend de `role`) + - `permission`, `role_permission` (depend de `role`, `permission`) - `customer_order` - - `order_item` (depends on `customer_order`, `product`, `menu`) - - `order_item_selection` (depends on `order_item`, `menu_slot`, `product`) - - `order_item_modifier` (depends on `order_item`, `ingredient`) - - `stock_movement` (depends on `ingredient`, `customer_order`, `user`) + - `order_item` (depend de `customer_order`, `product`, `menu`) + - `order_item_selection` (depend de `order_item`, `menu_slot`, `product`) + - `order_item_modifier` (depend de `order_item`, `ingredient`) + - `stock_movement` (depend de `ingredient`, `customer_order`, `user`) + - `audit_log` (depend de `user`, `role`) + - `login_throttle` (pas de FK, peut etre cree a n'importe quel moment) + - `pin_throttle` (FK `actor_user_id -> user`, donc apres le bloc `user`) -2. **Seed** (`db/seeds/0001_demo_data.sql`): - - 9 categories + 53 products + 13 menus from JSON sources (`docs/merise/_sources/`) - - 13 menus with slots and slot options - - 14 allergens (INCO EU 1169/2011) - - Sample ingredient catalogue with recipes - - 5 roles with `role_permission` matrix and `role_visible_source` data - - 1 admin user - - Sample orders for demo + Note : `customer_order` porte desormais `acting_user_id -> user`, donc `user` doit etre cree + avant `customer_order` (deja le cas : le bloc RBAC precede `customer_order`). -3. **Fallback JSON export** (`scripts/export-fallback.{sh|php}`): extract seed data to - `src/public/borne/data/*.json` for isolated kiosk mode (Bloc 1 without DB). +2. **Seed** (`db/seeds/0001_demo_data.sql`) : + - 9 categories + 53 produits + 13 menus depuis les sources JSON (`docs/merise/_sources/`) + - 13 menus avec slots et options de slot + - 14 allergenes (INCO UE 1169/2011) + - Catalogue d'ingredients exemple avec recettes + - 5 roles avec matrice `role_permission` et donnees `role_visible_source` + - 1 utilisateur admin + - Commandes exemple pour la demo -4. **DDL validation tests**: confirm CHECK constraints trigger as expected; confirm - ON DELETE CASCADE / RESTRICT / SET NULL behaviours match specification. +3. **Export JSON de fallback** (`scripts/export-fallback.{sh|php}`) : extraire les donnees de seed vers + `src/public/borne/data/*.json` pour le mode borne isole (Bloc 1 sans BD). + +4. **Tests de validation DDL** : confirmer que les contraintes CHECK se declenchent comme attendu ; confirmer + que les comportements ON DELETE CASCADE / RESTRICT / SET NULL correspondent a la specification. diff --git a/docs/merise/mlt.md b/docs/merise/mlt.md index abcff87..37b626f 100644 --- a/docs/merise/mlt.md +++ b/docs/merise/mlt.md @@ -1,637 +1,720 @@ -# Model of Logical Treatments (MLT) — Wakdo +# Modele Logique des Traitements (MLT) — Wakdo -**Merise phase** : P1 - Conception, step 4 (derived from MCT) -**Version** : v0.2 — prod-like, 4-state machine -**Date** : 2026-06-04 -**Branch** : `feat/p1-conception` -**Status** : prod-like — all D1-D8 + stock decisions applied (see `docs/notes/revue-alignement-p1.md` §7) -**Author** : BYAN (methodology layer) +**Phase Merise** : P1 - Conception, etape 4 (derivee du MCT) +**Version** : v0.2 — prod-like, machine a 4 etats (+ couche security-by-design 2026-06-11) +**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11) +**Branche** : `feat/p1-conception` +**Statut** : prod-like — toutes les decisions D1-D8 + stock appliquees (voir `docs/notes/revue-alignement-p1.md` §7) ; regles security-by-design ajoutees (RG-T13-T22 : PIN, audit, escaping, allowlists, idempotence, decrement atomique, disponibilite produit calculee (RG-T21), throttling du PIN d'action sensible par utilisateur agissant (RG-T22) ; ops RESET_PASSWORD, ERASE_USER_PII, throttling d'authentification ; tables de throttle `login_throttle` (par IP) et `pin_throttle` (par acteur)) +**Auteur** : BYAN (couche methodologie) --- -## 1. Purpose +## 1. Objectif -The MLT (Model of Logical Treatments) refines each MCT operation by specifying: -- **preconditions** — what must be true before execution -- **business rules** — validation, computation, business logic -- **postconditions** — the state guaranteed after success -- **outputs** — produced data or emitted events -- **error cases** — alternative outputs when a condition fails +Le MLT (Modele Logique des Traitements) affine chaque operation du MCT en specifiant : +- **preconditions** — ce qui doit etre vrai avant l'execution +- **regles de gestion** — validation, calcul, logique metier +- **postconditions** — l'etat garanti apres succes +- **sorties** — donnees produites ou evenements emis +- **cas d'erreur** — sorties alternatives lorsqu'une condition echoue -It bridges the MCT (conceptual level) and the PHP/SQL implementation (physical level). -All entity/attribute references use the names from `docs/merise/dictionary.md` (English, -snake_case). All monetary amounts are in integer cents. +Il fait le lien entre le MCT (niveau conceptuel) et l'implementation PHP/SQL (niveau physique). +Toutes les references aux entites/attributs utilisent les noms de `docs/merise/dictionary.md` (anglais, +snake_case). Tous les montants monetaires sont en centimes entiers. -**Tag conventions**: -- `[PRE]` — precondition; must be satisfied for the operation to execute -- `[RG]` — business rule (regle de gestion); logic applied during execution -- `[POST]` — postcondition; database state guaranteed after success -- `[OUT]` — output; data or event produced -- `[ERR]` — error case; alternative output when a condition fails +**Conventions de tags** : +- `[PRE]` — precondition ; doit etre satisfaite pour que l'operation s'execute +- `[RG]` — regle de gestion ; logique appliquee pendant l'execution +- `[POST]` — postcondition ; etat de la base garanti apres succes +- `[OUT]` — sortie ; donnee ou evenement produit +- `[ERR]` — cas d'erreur ; sortie alternative lorsqu'une condition echoue --- -## 2. Transverse business rules +## 2. Regles de gestion transverses -These rules apply to multiple operations and are centralised here to avoid repetition. +Ces regles s'appliquent a plusieurs operations et sont centralisees ici pour eviter la repetition. -| Rule code | Label | Operations concerned | +| Code de regle | Libelle | Operations concernees | |-----------|-------|----------------------| -| **RG-T01** | CSRF token verified on every back-office POST/PUT/DELETE form | AUTH, all admin ops | -| **RG-T02** | Session active + `user.is_active = 1` verified on each authenticated request | All domains 3-10 | -| **RG-T03** | Permission verified via `role_permission` before executing operation | All domains 3-10 | -| **RG-T04** | All monetary amounts are manipulated in integer cents; EUR conversion at output only | 3.3, 4.1, 8.1, 8.4 | -| **RG-T05** | Snapshots (`label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`) on `order_item` are not modified after INSERT (historical integrity of placed orders — design guarantee) | 3.3, 4.1, 8.2, 8.5 | -| **RG-T06** | All SQL queries use PDO with prepared statements; no user data concatenated into SQL | All operations | -| **RG-T07** | Status transition UPDATE statements include `AND status = ` in the WHERE clause (optimistic concurrency protection against double transition) | 6.1, 7.1 | -| **RG-T08** | Operations touching multiple tables execute in an atomic database transaction; partial failure triggers full rollback | 3.3, 4.1, 7.1, 8.4, 9.1, 9.2 | -| **RG-T09** | Cross-constraint on `customer_order`: `source = 'drive'` implies `service_mode = 'drive'`; verified at order creation. Materialisable as a MariaDB CHECK: `CHECK (source != 'drive' OR service_mode = 'drive')`. | 3.3, 4.1 | -| **RG-T10** | VAT computation is line-by-line: each `order_item` carries its own `vat_rate_snapshot` (per-mille integer snapshotted from `product.vat_rate`). Order totals (`total_ht_cents`, `total_vat_cents`, `total_ttc_cents`) are the sum of line-level amounts. | 3.3, 4.1 | -| **RG-T11** | Stock decrements at the `pending_payment -> paid` transition and re-credits at `paid -> cancelled` are within the same database transaction as the status update (no orphan decrement). | 3.3, 4.1, 7.1 | -| **RG-T12** | Dashboard filter by source: each role's visible sources are read from `role_visible_source`; the query uses `WHERE customer_order.source IN (role_visible_sources)`. | 6.1 | +| **RG-T01** | Token CSRF verifie sur chaque formulaire POST/PUT/DELETE du back-office | AUTH, toutes ops admin | +| **RG-T02** | Session active + `user.is_active = 1` verifies a chaque requete authentifiee | Tous domaines 3-10 | +| **RG-T03** | Permission verifiee via `role_permission` avant l'execution de l'operation | Tous domaines 3-10 | +| **RG-T04** | Tous les montants monetaires sont manipules en centimes entiers ; conversion EUR uniquement en sortie | 3.3, 4.1, 8.1, 8.4 | +| **RG-T05** | Les snapshots (`label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`) sur `order_item` ne sont pas modifies apres l'INSERT (integrite historique des commandes passees — garantie de conception) | 3.3, 4.1, 8.2, 8.5 | +| **RG-T06** | Toutes les requetes SQL utilisent PDO avec des requetes preparees ; aucune donnee utilisateur concatenee dans le SQL | Toutes operations | +| **RG-T07** | Les instructions UPDATE de transition d'etat incluent `AND status = ` dans la clause WHERE (protection de concurrence optimiste contre la double transition) | 6.1, 7.1 | +| **RG-T08** | Les operations touchant plusieurs tables s'executent dans une transaction de base de donnees atomique ; un echec partiel declenche un rollback complet | 3.3, 4.1, 7.1, 8.4, 9.1, 9.2 | +| **RG-T09** | Contrainte croisee sur `customer_order` : `source = 'drive'` implique `service_mode = 'drive'` ; verifiee a la creation de la commande. Materialisable en CHECK MariaDB : `CHECK (source != 'drive' OR service_mode = 'drive')`. | 3.3, 4.1 | +| **RG-T10** | Le calcul de TVA se fait ligne par ligne : chaque `order_item` porte son propre `vat_rate_snapshot` (entier pour-mille snapshote depuis `product.vat_rate`). Les totaux de commande (`total_ht_cents`, `total_vat_cents`, `total_ttc_cents`) sont la somme des montants au niveau des lignes. | 3.3, 4.1 | +| **RG-T11** | Le decrement de stock a la transition `pending_payment -> paid` et le re-credit a `paid -> cancelled` sont dans la meme transaction de base de donnees que la mise a jour du statut (pas de decrement orphelin). | 3.3, 4.1, 7.1 | +| **RG-T12** | Filtre du tableau de bord par source : les sources visibles de chaque role sont lues depuis `role_visible_source` ; la requete utilise `WHERE customer_order.source IN (role_visible_sources)`. | 6.1 | +| **RG-T13** | **PIN d'action sensible** (security-by-design) : l'ensemble des operations sensibles requiert une re-autorisation par PIN propre a chaque membre du personnel avant l'execution : verifier le PIN soumis contre `user.pin_hash` (`password_verify`, argon2id). En cas de succes, le `user_id` agissant est capture pour le journal d'audit ; en cas d'echec, l'operation est rejetee. Ensemble sensible : 7.1 (annulation), 8.2/8.3 (mise a jour/suppression produit), 8.6 (suppression menu), 9.2 (correction d'inventaire), 10.1/10.2/10.3 (gestion utilisateur), 10.4 (RBAC), 10.5 (effacement PII). Les sessions restent partagees par poste de travail pour les 95% de routine. | 7.1, 8.2, 8.3, 8.6, 9.2, 10.1-10.5 | +| **RG-T14** | **Ecriture du journal d'audit** : les operations sensibles hors stock ajoutent une ligne `audit_log` immuable dans la meme transaction que leur effet : `actor_user_id` (issu du PIN RG-T13), `actor_role_id`, `action_code` (code de permission/operation), `entity_type` + `entity_id` de la ligne affectee, `summary` (description de changement non personnelle), `details` JSON (**noms** des champs modifies pour les actions ciblant un utilisateur, pas les valeurs PII). Aucun UPDATE/DELETE sur `audit_log`. Les actions de stock (9.1 restock, 9.2 inventaire) enregistrent leur attribution via `stock_movement.user_id` (capture par PIN), qui fournit deja la trace de stock append-only — elles ne sont pas doublement journalisees. | 7.1, 8.2, 8.3, 8.6, 10.1-10.5, 12.1 | +| **RG-T15** | **Echappement en sortie** (anti-XSS) : les champs de texte libre (`product.name`/`description`, `ingredient.name`, `user.first_name`/`last_name`, notes) sont echappes selon le contexte au rendu. Les vues admin rendues cote serveur utilisent `htmlspecialchars($v, ENT_QUOTES, 'UTF-8')` ; le kiosk en vanilla-JS injecte le texte via `textContent` (ou un echappeur explicite), pas `innerHTML`. | Toutes les vues rendant du texte stocke | +| **RG-T16** | **Allowlist d'affectation de masse** : les instructions INSERT/UPDATE ne lient qu'une allowlist de colonnes explicite par operation issue de la requete ; les champs supplementaires/inconnus sont ecartes. Empeche l'alteration de `price_cents`, `vat_rate`, `role_id`, `is_active`, `status` via des champs de formulaire injectes. | 8.1, 8.2, 8.4, 8.5, 10.1, 10.2 | +| **RG-T17** | **Allowlist d'identifiants dynamiques** : les tokens de colonne/direction utilises dans un `ORDER BY` / `GROUP BY` dynamique sont resolus contre une allowlist fixe de noms de colonnes avant la construction de la requete (RG-T06 couvre les valeurs via les parametres lies ; les identifiants SQL ne peuvent pas etre lies, ils sont donc en allowlist). | 5.1, 9.3, 11.1 | +| **RG-T18** | **Validation cote serveur et bornes de longueur** : chaque entree est re-validee cote serveur independamment des verifications cote client — type, plage, longueur max (correspondant aux tailles VARCHAR du dictionnaire), appartenance a l'enum, existence de FK. La validation cote client est une aide UX, pas une frontiere de confiance. | Toutes operations d'ecriture | +| **RG-T19** | **Idempotence** : `POST /api/orders` porte un `idempotency_key` (UUID) genere par le client. Avant de creer, le rechercher sur `customer_order.idempotency_key` (UNIQUE) ; si une ligne existe, retourner cette commande au lieu de creer un doublon (retry reseau rejoue). | 3.3, 4.1 | +| **RG-T20** | **Decrement de stock atomique** : pendant la transition `paid`, chaque `ingredient` affecte est decremente par une unique instruction auto-verrouillante `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` — pas de lecture-gate prealable, pas de `SELECT ... FOR UPDATE`. Les commandes concurrentes sur le meme ingredient appliquent leurs deltas sans perte de mise a jour et sans souci d'ordonnancement de deadlock. `stock_quantity` est signe et peut devenir negatif quand les ventes depassent le stock compte (l'ampleur de la survente est remontee aux managers) ; le decrement ne bloque pas sur un plancher. | 3.3, 4.1 | +| **RG-T21** | **Disponibilite produit calculee** : la commandabilite effective d'un produit est calculee, pas stockee. Il est commandable lorsque `product.is_available = 1` ET que chaque ingredient non retirable (`is_removable = 0`) de son `product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct / 100`. A la bande critique, un ingredient requis met le produit en rupture sans ecriture et sans cascade ; un reapprovisionnement au-dessus de la bande critique le rend commandable a nouveau de lui-meme ; un retrait manuel (`product.is_available = 0`) est une surcharge forte ; un ingredient retirable/optionnel a la bande critique ne bloque pas le produit (seul son supplement devient indisponible). | 3.1, 3.3, 4.1, 5.1 | +| **RG-T22** | **Throttling du PIN d'action sensible** (complement de RG-T13). Les tentatives de PIN echouees sont comptabilisees PAR UTILISATEUR AGISSANT (l'identite de session authentifiee qui soumet email+PIN, RG-T02), dans une table dediee `pin_throttle` (entite 22) STRICTEMENT SEPAREE des compteurs de connexion (`user.failed_login_attempts` / `user.lockout_until` / `login_throttle`) : un echec de PIN n'incremente aucun compteur de login, sinon spammer le PIN d'une victime verrouillerait sa CONNEXION (escalade de DoS sur une surface plus sensible). La dimension est l'AGISSANT et non l'email cible (un compteur par email cible serait contourne par rotation d'emails, RG-T13 verifiant un email arbitraire) ni l'IP (un verrou par IP priverait de re-autorisation tous les equipiers honnetes d'un poste a session partagee). A chaque echec hors verrou : upsert atomique de la ligne cle sur `actor_user_id` (insert sinon increment ; fenetre glissante reinitialisee via `window_started_at` quand elle expire), `last_attempt_at = NOW()`, et au-dela d'un seuil (suggestion 5) pose `lockout_until` avec le meme backoff degressif que RG-8 mais des bornes propres (PIN_THROTTLE_*, plus permissives : base 30s, plafond 300s — ne pas bloquer un manager en plein rush). Backoff degressif, pas verrou definitif. Le verrou est evalue AVANT la verification argon2id ; un acteur verrouille recoit le MEME message generique 'Email ou PIN invalide' (ne revele ni l'existence d'un compte ni l'etat de verrou, RG-2) et l'on paie un leurre de timing pour egaliser la latence avec le chemin mauvais-PIN. Sous verrou actif, aucune nouvelle ligne `audit_log` `pin.failed` n'est ecrite (les echecs ayant arme le verrou sont deja audites), ce qui borne l'amplification de l'audit append-only (RG-T14). En cas d'erreur de lecture du throttle, la requete echoue (fail-closed, pas de contournement silencieux du verrou). Le hook est pose sur la branche de changement sensible dans `update` (prix/TVA) et inconditionnellement dans `delete`. Purge cron des lignes sans verrou actif au-dela de THROTTLE_PURGE_AFTER_HOURS, comme `login_throttle`. Detection : un pic de `pin.failed` reste le controle detectif (alerte de volume) ; un PIN de plus de 4 chiffres pour les roles sensibles est recommande. | 8.2, 8.3, 8.6, 9.2, 10.1-10.5 | --- -## 3. Domain 1 — Order lifecycle (kiosk) +## 3. Domaine 1 — Cycle de vie de la commande (kiosk) ### 3.1 LOAD_CATALOGUE -**Corresponds to MCT section 3.1** +**Correspond a la section 3.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Request originates from the kiosk endpoint (public, no authentication required) | -| **[PRE-2]** | Current time is within the service window (10:00-01:00); outside the window the kiosk displays a closed message | -| **[RG-1]** | Read all `category` rows with `is_active = 1`, ordered by `category.display_order ASC` | -| **[RG-2]** | For each category, read `product` rows with `is_available = 1` and matching `category_id`, ordered by `product.display_order ASC` | -| **[RG-3]** | Read all `menu` rows with `is_available = 1`; for each menu, load `menu_slot` rows ordered by `menu_slot.display_order ASC`; for each slot, load eligible products via `menu_slot_option JOIN product` (where `product.is_available = 1`) | -| **[RG-4]** | For each product, compute allergens by joining `product_ingredient -> ingredient_allergen -> allergen` (no manual re-entry per product) | -| **[RG-5]** | For each product with `product_ingredient` rows, load `ingredient` composition (for the configurator) | -| **[RG-6]** | Prices are returned in integer cents; EUR conversion is performed client-side | -| **[POST-1]** | No database write; database state unchanged | -| **[OUT-1]** | JSON response: `{data: {categories: [...], products: {...}, menus: [{..., slots: [{..., options: [...]}]}]}}` | -| **[ERR-1]** | DB unreachable: response `{data: null, error: {code: "DB_ERROR"}}` and front-end falls back to static JSON | +| **[PRE-1]** | La requete provient de l'endpoint kiosk (public, aucune authentification requise) | +| **[PRE-2]** | L'heure courante est dans la fenetre de service (10:00-01:00) ; en dehors de la fenetre le kiosk affiche un message de fermeture | +| **[RG-1]** | Lire toutes les lignes `category` avec `is_active = 1`, triees par `category.display_order ASC` | +| **[RG-2]** | Pour chaque categorie, lire les lignes `product` avec `is_available = 1` et `category_id` correspondant, triees par `product.display_order ASC` | +| **[RG-3]** | Lire toutes les lignes `menu` avec `is_available = 1` ; pour chaque menu, charger les lignes `menu_slot` triees par `menu_slot.display_order ASC` ; pour chaque slot, charger les produits eligibles via `menu_slot_option JOIN product` (ou `product.is_available = 1`) | +| **[RG-4]** | Pour chaque produit, calculer les allergenes en joignant `product_ingredient -> ingredient_allergen -> allergen` (pas de ressaisie manuelle par produit) | +| **[RG-5]** | Pour chaque produit avec des lignes `product_ingredient`, charger la composition `ingredient` (pour le configurateur) | +| **[RG-6]** | Les prix sont retournes en centimes entiers ; la conversion EUR est effectuee cote client | +| **[POST-1]** | Aucune ecriture en base ; etat de la base inchange | +| **[OUT-1]** | Reponse JSON : `{data: {categories: [...], products: {...}, menus: [{..., slots: [{..., options: [...]}]}]}}` | +| **[ERR-1]** | Base inaccessible : reponse `{data: null, error: {code: "DB_ERROR"}}` et le front-end bascule sur un JSON statique | --- ### 3.2 COMPOSE_CART -**Corresponds to MCT section 3.2** +**Correspond a la section 3.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Catalogue loaded into front-end memory (LOAD_CATALOGUE completed) | -| **[PRE-2]** | Selected item (product or menu) is present in the loaded catalogue with `is_available = 1` | -| **[RG-1]** | Cart is a JavaScript in-memory structure (array of items); no database persistence at this stage | -| **[RG-2]** | Each item contains: `type` (`product` or `menu`), `item_id`, `label`, `unit_price_cents` (snapshot from catalogue), `quantity`, `format` (`normal` or `maxi`, for menus), `slot_selections` (array of `{menu_slot_id, product_id, label}` for menu items), `modifiers` (array of `{ingredient_id, action, extra_price_cents}`) | -| **[RG-3]** | Format Normal/Maxi (menu items only): `normal` uses `menu.price_normal_cents`; `maxi` uses `menu.price_maxi_cents`. No individual component price change is stored; the price differential is at menu level. | -| **[RG-4]** | Ingredient modifier rules: `action = 'remove'` requires `is_removable = 1` on `product_ingredient` (free); `action = 'add'` requires `is_addable = 1` (may carry `extra_price_cents`). These constraints are verified at cart composition time against the loaded catalogue. | -| **[RG-5]** | If an item with the same `(type, item_id, format, slot_selections, modifiers)` already exists in the cart, its quantity is incremented rather than adding a new item | -| **[RG-6]** | Cart total recomputed after each change: `SUM(unit_price_cents * quantity + modifier_extras)` across all items | -| **[POST-1]** | No database write; cart in-memory state updated | -| **[OUT-1]** | Cart summary displayed with TTC total | -| **[ERR-1]** | If a product becomes `is_available = 0` between catalogue load and order submission, the server-side validation in CREATE_ORDER catches it | +| **[PRE-1]** | Catalogue charge en memoire front-end (LOAD_CATALOGUE termine) | +| **[PRE-2]** | L'article selectionne (produit ou menu) est present dans le catalogue charge avec `is_available = 1` | +| **[RG-1]** | Le panier est une structure JavaScript en memoire (tableau d'articles) ; aucune persistance en base a ce stade | +| **[RG-2]** | Chaque article contient : `type` (`product` ou `menu`), `item_id`, `label`, `unit_price_cents` (snapshot depuis le catalogue), `quantity`, `format` (`normal` ou `maxi`, pour les menus), `slot_selections` (tableau de `{menu_slot_id, product_id, label}` pour les articles menu), `modifiers` (tableau de `{ingredient_id, action, extra_price_cents}`) | +| **[RG-3]** | Format Normal/Maxi (articles menu uniquement) : `normal` utilise `menu.price_normal_cents` ; `maxi` utilise `menu.price_maxi_cents`. Aucun changement de prix de composant individuel n'est stocke ; le differentiel de prix est au niveau du menu. | +| **[RG-4]** | Regles de modificateur d'ingredient : `action = 'remove'` requiert `is_removable = 1` sur `product_ingredient` (gratuit) ; `action = 'add'` requiert `is_addable = 1` (peut porter un `extra_price_cents`). Ces contraintes sont verifiees au moment de la composition du panier contre le catalogue charge. | +| **[RG-5]** | Si un article avec les memes `(type, item_id, format, slot_selections, modifiers)` existe deja dans le panier, sa quantite est incrementee plutot que d'ajouter un nouvel article | +| **[RG-6]** | Total du panier recalcule apres chaque changement : `SUM(unit_price_cents * quantity + modifier_extras)` sur tous les articles | +| **[POST-1]** | Aucune ecriture en base ; etat en memoire du panier mis a jour | +| **[OUT-1]** | Recapitulatif du panier affiche avec total TTC | +| **[ERR-1]** | Si un produit passe a `is_available = 0` entre le chargement du catalogue et la soumission de la commande, la validation cote serveur dans CREATE_ORDER le detecte | --- ### 3.3 CREATE_ORDER -**Corresponds to MCT section 3.3** +**Correspond a la section 3.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Cart contains at least 1 item (`items.length >= 1`) | -| **[PRE-2]** | Order number entered by customer is non-empty (front-end validation) | -| **[PRE-3]** | POST JSON body is valid (schema validation at API layer) | -| **[RG-1]** | Server-side availability check: for each item, verify `product.is_available = 1` or `menu.is_available = 1`. If any item is unavailable, reject with list of unavailable articles. | -| **[RG-2 — service_day]** | `service_day` for a given order is computed at query time as: `CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END`. Cutoff is 10:00. This is NOT stored as a column — computed at query time only. The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` was incorrect and is dropped. | -| **[RG-3 — order number]** | Order number format: `K-YYYY-MM-DD-NNN` where NNN is the sequential counter for the current service_day for the `kiosk` source (SELECT COUNT + 1 with a table-level lock or serialised insert to avoid duplicate generation under concurrency). Source is `kiosk` (set by the kiosk endpoint, derived from the public entry point). | -| **[RG-4 — VAT by line]** | For each `order_item`: `vat_rate_snapshot` is copied from `product.vat_rate`. Line amounts: `unit_ttc = unit_price_cents_snapshot`; `unit_ht = ROUND(unit_ttc * 1000 / (1000 + vat_rate_snapshot))`; `unit_vat = unit_ttc - unit_ht`. Order totals: `total_ttc_cents = SUM(unit_ttc * quantity)` across all lines; `total_ht_cents = SUM(unit_ht * quantity)`; `total_vat_cents = total_ttc_cents - total_ht_cents`. Invariant: `total_ttc_cents = total_ht_cents + total_vat_cents` (verified before INSERT). | -| **[RG-5 — atomic transaction]** | All writes within one database transaction: (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode from cart, computed totals); (2) INSERT `order_item` rows (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id or menu_id); (3) INSERT `order_item_selection` rows for each slot filled in a menu item (order_item_id, menu_slot_id, product_id, label_snapshot); (4) INSERT `order_item_modifier` rows for each ingredient modification (order_item_id, ingredient_id, action, extra_price_cents snapshot); (5) for each ingredient consumed: compute units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by modifiers (remove => no decrement for that ingredient; add => extra decrement); UPDATE `ingredient.stock_quantity -= units`; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL for kiosk); (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. All six steps commit together or roll back entirely. | -| **[RG-6 — cross-constraint]** | Source `kiosk` implies no particular service_mode constraint; the customer selects `dine_in` or `takeaway`. The drive cross-constraint (RG-T09) does not apply to kiosk-originated orders. | -| **[RG-7 — immutability]** | After INSERT, `label_snapshot`, `unit_price_cents_snapshot`, and `vat_rate_snapshot` are not modified even if the source product is later renamed or repriced (see RG-T05). | -| **[POST-1]** | One `customer_order` row exists with `status = 'paid'`, `source = 'kiosk'`, all totals computed, `paid_at` set. The `pending_payment` phase is not observable outside the transaction. | -| **[POST-2]** | N `order_item` rows exist, each referencing either a `product_id` (item_type='product') or a `menu_id` (item_type='menu') — exclusivity constraint verified. | -| **[POST-3]** | `customer_order.order_number` is unique in the database (UNIQUE constraint). | -| **[POST-4]** | `ingredient.stock_quantity` decremented for each consumed ingredient unit; one `stock_movement` row of type `sale` per affected ingredient. | -| **[OUT-1]** | HTTP 201: `{data: {id: int, order_number: string, status: 'paid'}}` | -| **[OUT-2]** | Logical event ORDER_CREATED available for preparation domain (preparation display refreshes via polling or server push depending on implementation) | -| **[ERR-1]** | Empty cart: HTTP 422, `{error: {code: "EMPTY_CART"}}` | -| **[ERR-2]** | Unavailable item: HTTP 422, `{error: {code: "ITEM_UNAVAILABLE", items: [...]}}` | -| **[ERR-3]** | DB error / timeout: HTTP 500 with rollback, `{error: {code: "DB_ERROR"}}` | +| **[PRE-1]** | Le panier contient au moins 1 article (`items.length >= 1`) | +| **[PRE-2]** | Le numero de commande saisi par le client est non vide (validation front-end) | +| **[PRE-3]** | Le corps JSON du POST est valide (validation de schema a la couche API) | +| **[RG-1]** | Verification de disponibilite cote serveur : pour chaque article, verifier `product.is_available = 1` ou `menu.is_available = 1`. Si un article est indisponible, rejeter avec la liste des articles indisponibles. | +| **[RG-2 — service_day]** | Le `service_day` d'une commande donnee est calcule a l'execution de la requete comme : `CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END`. La coupure est a 10:00. Ce n'est PAS stocke comme colonne — calcule uniquement a l'execution de la requete. La formule v0.1 avec `INTERVAL 4 HOUR 30 MINUTE` etait incorrecte et est abandonnee. | +| **[RG-3 — order number]** | Format du numero de commande : `K-YYYY-MM-DD-NNN` ou NNN est le compteur sequentiel pour le service_day courant pour la source `kiosk` (SELECT COUNT + 1 avec un verrou au niveau table ou un insert serialise pour eviter une generation en double sous concurrence). La source est `kiosk` (definie par l'endpoint kiosk, derivee du point d'entree public). | +| **[RG-4 — VAT by line]** | Pour chaque `order_item` : `vat_rate_snapshot` est copie depuis `product.vat_rate`. Montants de ligne : `unit_ttc = unit_price_cents_snapshot` ; `unit_ht = ROUND(unit_ttc * 1000 / (1000 + vat_rate_snapshot))` ; `unit_vat = unit_ttc - unit_ht`. Totaux de commande : `total_ttc_cents = SUM(unit_ttc * quantity)` sur toutes les lignes ; `total_ht_cents = SUM(unit_ht * quantity)` ; `total_vat_cents = total_ttc_cents - total_ht_cents`. Invariant : `total_ttc_cents = total_ht_cents + total_vat_cents` (verifie avant l'INSERT). | +| **[RG-5 — atomic transaction]** | Toutes les ecritures dans une seule transaction de base de donnees : (1) INSERT `customer_order` (status `pending_payment`, source `kiosk`, service_mode depuis le panier, totaux calcules) ; (2) INSERT des lignes `order_item` (label_snapshot, unit_price_cents_snapshot, vat_rate_snapshot, quantity, format, item_type, product_id ou menu_id) ; (3) INSERT des lignes `order_item_selection` pour chaque slot rempli dans un article menu (order_item_id, menu_slot_id, product_id, label_snapshot) ; (4) INSERT des lignes `order_item_modifier` pour chaque modification d'ingredient (order_item_id, ingredient_id, action, extra_price_cents snapshot) ; (5) pour chaque ingredient consomme : calculer units = `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, ajuste par les modificateurs (remove => pas de decrement pour cet ingredient ; add => decrement supplementaire) ; appliquer le decrement atomique `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (instruction unique auto-verrouillante, sans lecture-gate prealable, RG-T20) ; `stock_quantity` est signe et peut devenir negatif (ampleur de survente, remontee aux managers) — le decrement ne se conditionne pas a un plancher ; INSERT `stock_movement` (type `sale`, delta = -units, order_id, user_id = NULL pour le kiosk) ; (6) UPDATE `customer_order` SET status = `paid`, `paid_at = NOW()`. Les six etapes committent ensemble ou sont entierement annulees. | +| **[RG-6 — cross-constraint]** | La source `kiosk` n'implique aucune contrainte particuliere de service_mode ; le client selectionne `dine_in` ou `takeaway`. La contrainte croisee drive (RG-T09) ne s'applique pas aux commandes provenant du kiosk. | +| **[RG-7 — immutability]** | Apres l'INSERT, `label_snapshot`, `unit_price_cents_snapshot` et `vat_rate_snapshot` ne sont pas modifies meme si le produit source est renomme ou voit son prix change plus tard (voir RG-T05). | +| **[RG-8 — idempotency]** | Le corps porte un `idempotency_key` client (UUID). Avant toute ecriture, `SELECT id, order_number, status FROM customer_order WHERE idempotency_key = :key`. Si trouve, sauter la creation et retourner cette commande (deduplique un retry rejoue — RG-T19). La cle est stockee sur la nouvelle ligne `customer_order`. | +| **[RG-9 — server-side modificateur re-validation]** | Les modificateurs d'ingredient dans le corps sont re-valides cote serveur contre `product_ingredient` : un `action='remove'` requiert `is_removable=1` ; un `action='add'` requiert `is_addable=1` et snapshote le `extra_price_cents` courant. Les verifications cote client (3.2 RG-4) ne sont pas dignes de confiance ; un POST forge ajoutant un ingredient non addable est rejete (HTTP 422). | +| **[RG-10 — atomic stock decrement]** | Aucune operation ne se conditionne a une lecture de stock, donc le decrement est une instruction atomique unique `UPDATE ingredient SET stock_quantity = stock_quantity - :units WHERE id = :id` (RG-T20). La ligne s'auto-verrouille pour la duree de la mise a jour, donc les commandes kiosk concurrentes sur le meme ingredient appliquent leurs deltas sans perte de mise a jour et sans souci d'ordonnancement de deadlock ; `stock_quantity` est signe et peut devenir negatif (ampleur de survente remontee aux managers). | +| **[POST-1]** | Une ligne `customer_order` existe avec `status = 'paid'`, `source = 'kiosk'`, tous les totaux calcules, `paid_at` defini, `idempotency_key` stocke. La phase `pending_payment` n'est pas observable hors de la transaction. | +| **[POST-2]** | N lignes `order_item` existent, chacune referencant soit un `product_id` (item_type='product') soit un `menu_id` (item_type='menu') — contrainte d'exclusivite verifiee. | +| **[POST-3]** | `customer_order.order_number` est unique dans la base (contrainte UNIQUE). | +| **[POST-4]** | `ingredient.stock_quantity` decremente pour chaque unite d'ingredient consommee ; une ligne `stock_movement` de type `sale` par ingredient affecte. | +| **[OUT-1]** | HTTP 201 : `{data: {id: int, order_number: string, status: 'paid'}}` | +| **[OUT-2]** | Evenement logique ORDER_CREATED disponible pour le domaine de preparation (l'affichage de preparation se rafraichit via polling ou push serveur selon l'implementation) | +| **[ERR-1]** | Panier vide : HTTP 422, `{error: {code: "EMPTY_CART"}}` | +| **[ERR-2]** | Article indisponible : HTTP 422, `{error: {code: "ITEM_UNAVAILABLE", items: [...]}}` | +| **[ERR-3]** | Erreur DB / timeout : HTTP 500 avec rollback, `{error: {code: "DB_ERROR"}}` | --- ### 3.4 DISPLAY_CONFIRMATION -**Corresponds to MCT section 3.4** +**Correspond a la section 3.4 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | CREATE_ORDER returned HTTP 201 with `{id, order_number, status: 'paid'}` | -| **[RG-1]** | Order number displayed prominently on the confirmation screen | -| **[RG-2]** | After a configurable delay (suggestion: 15 seconds), the kiosk auto-resets for the next customer | -| **[POST-1]** | No database write | -| **[OUT-1]** | Confirmation screen displayed with order number | -| **[ERR-1]** | If API response is an error: generic error message displayed with option to retry | +| **[PRE-1]** | CREATE_ORDER a retourne HTTP 201 avec `{id, order_number, status: 'paid'}` | +| **[RG-1]** | Numero de commande affiche de maniere proeminente sur l'ecran de confirmation | +| **[RG-2]** | Apres un delai configurable (suggestion : 15 secondes), le kiosk se reinitialise automatiquement pour le client suivant | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Ecran de confirmation affiche avec le numero de commande | +| **[ERR-1]** | Si la reponse de l'API est une erreur : message d'erreur generique affiche avec une option de reessai | --- -## 4. Domain 2 — Order lifecycle (counter and drive) +## 4. Domaine 2 — Cycle de vie de la commande (comptoir et drive) ### 4.1 CREATE_COUNTER_ORDER -**Corresponds to MCT section 4.1** +**Correspond a la section 4.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor is authenticated (valid session, `user.is_active = 1`) | -| **[PRE-2]** | Actor holds permission `order.create` (verified via `role_permission`) | -| **[PRE-3]** | Cart contains at least 1 item | -| **[RG-1]** | Creation logic identical to CREATE_ORDER (RG-1 through RG-7 apply), with the following differences: `source` is auto-tagged from `role.order_source` (counter role -> `counter`, drive role -> `drive`); `service_mode` is selected by the staff member (`dine_in` / `takeaway` / `drive`); `user_id` is set to the authenticated user's id in `stock_movement` rows (instead of NULL for kiosk). | -| **[RG-2 — cross-constraint]** | If `source = 'drive'` then `service_mode` must be `'drive'` (RG-T09); verified before INSERT. HTTP 422 if violated. | -| **[RG-3 — order number]** | Format: `C-YYYY-MM-DD-NNN` for counter source; `D-YYYY-MM-DD-NNN` for drive source. Sequential NNN counter is per source per service_day. | -| **[RG-4 — stock]** | Same stock decrement logic as CREATE_ORDER RG-5; `stock_movement.user_id` is set to the authenticated staff member's id. | -| **[POST-1]** | One `customer_order` row with `status = 'paid'`, `source = 'counter'` or `'drive'`, `paid_at` set. | -| **[POST-2]** | N `order_item` rows with snapshots. Slot selections and modifiers written identically to kiosk flow. | -| **[POST-3]** | Stock decremented; movements logged with actor `user_id`. | -| **[OUT-1]** | HTTP 201: `{data: {id: int, order_number: string, status: 'paid'}}`. Order number communicated to customer. | -| **[ERR-1]** | Same error cases as CREATE_ORDER (ERR-1, ERR-2, ERR-3) | -| **[ERR-2]** | Cross-constraint violation (`source = drive` but `service_mode != drive`): HTTP 422, `{error: {code: "INVALID_SERVICE_MODE"}}` | +| **[PRE-1]** | L'acteur est authentifie (session valide, `user.is_active = 1`) | +| **[PRE-2]** | L'acteur detient la permission `order.create` (verifiee via `role_permission`) | +| **[PRE-3]** | Le panier contient au moins 1 article | +| **[RG-1]** | Logique de creation identique a CREATE_ORDER (RG-1 a RG-7 s'appliquent), avec les differences suivantes : `source` est auto-tagguee depuis `role.order_source` (role comptoir -> `counter`, role drive -> `drive`) ; `service_mode` est selectionne par le membre du personnel (`dine_in` / `takeaway` / `drive`) ; `user_id` est defini a l'id de l'utilisateur authentifie dans les lignes `stock_movement` (au lieu de NULL pour le kiosk). | +| **[RG-2 — cross-constraint]** | Si `source = 'drive'` alors `service_mode` doit etre `'drive'` (RG-T09) ; verifie avant l'INSERT. HTTP 422 si viole. | +| **[RG-3 — order number]** | Format : `C-YYYY-MM-DD-NNN` pour la source comptoir ; `D-YYYY-MM-DD-NNN` pour la source drive. Le compteur sequentiel NNN est par source par service_day. | +| **[RG-4 — stock]** | Meme logique de decrement de stock que CREATE_ORDER RG-5 ; `stock_movement.user_id` est defini a l'id du membre du personnel authentifie. | +| **[RG-5 — staff attribution + decrement]** | `customer_order.acting_user_id` est defini a l'id du membre du personnel authentifie (imputabilite ciblee sur les commandes comptoir/drive ; les commandes kiosk restent NULL). La re-validation des modificateurs cote serveur (3.3 RG-9), l'idempotence (RG-T19) et le decrement de stock atomique (RG-T20) s'appliquent a l'identique. Aucun PIN n'est requis pour creer une commande (la permission `order.create` suffit) ; la creation de commande n'est pas dans l'ensemble des actions sensibles. | +| **[POST-1]** | Une ligne `customer_order` avec `status = 'paid'`, `source = 'counter'` ou `'drive'`, `paid_at` defini, `acting_user_id` defini. | +| **[POST-2]** | N lignes `order_item` avec snapshots. Selections de slot et modificateurs ecrits a l'identique du flux kiosk. | +| **[POST-3]** | Stock decremente ; mouvements journalises avec l'acteur `user_id`. | +| **[OUT-1]** | HTTP 201 : `{data: {id: int, order_number: string, status: 'paid'}}`. Numero de commande communique au client. | +| **[ERR-1]** | Memes cas d'erreur que CREATE_ORDER (ERR-1, ERR-2, ERR-3) | +| **[ERR-2]** | Violation de contrainte croisee (`source = drive` mais `service_mode != drive`) : HTTP 422, `{error: {code: "INVALID_SERVICE_MODE"}}` | --- -## 5. Domain 3 — Preparation display (kitchen) +## 5. Domaine 3 — Affichage de preparation (cuisine) ### 5.1 LIST_ORDERS_DISPLAY -**Corresponds to MCT section 5.1** +**Correspond a la section 5.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor is authenticated, `is_active = 1` | -| **[PRE-2]** | Actor holds permission `order.read` | -| **[RG-1 — source filter]** | Retrieve visible sources for the actor's role: `SELECT source FROM role_visible_source WHERE role_id = :role_id`. Kitchen sees all three; counter sees `kiosk` and `counter`; drive sees `drive`. | +| **[PRE-1]** | L'acteur est authentifie, `is_active = 1` | +| **[PRE-2]** | L'acteur detient la permission `order.read` | +| **[RG-1 — source filter]** | Recuperer les sources visibles pour le role de l'acteur : `SELECT source FROM role_visible_source WHERE role_id = :role_id`. La cuisine voit les trois ; le comptoir voit `kiosk` et `counter` ; le drive voit `drive`. | | **[RG-2 — query]** | `SELECT customer_order.*, order_item.* FROM customer_order JOIN order_item ON order_item.order_id = customer_order.id WHERE customer_order.status = 'paid' AND customer_order.source IN (:visible_sources) ORDER BY customer_order.paid_at ASC` | -| **[RG-3 — item detail]** | For each order line of type `menu`, also load `order_item_selection` rows (slot choices). For all lines, load `order_item_modifier` rows (ingredient modifications). Display uses snapshots (`label_snapshot`, `quantity`, `format`); no re-join on `product` or `menu` tables needed. | -| **[RG-4 — KDS colour]** | Colour indicator computed at render time: `elapsed = NOW() - customer_order.paid_at`; green if elapsed < SLA threshold (configurable, approx. 10 min); amber if approaching; red if exceeded. Not stored; computed client-side or in PHP before response. | -| **[RG-5 — read only]** | Kitchen staff perform no status transition from this view. No UPDATE is issued by this operation. | -| **[POST-1]** | No database write | -| **[OUT-1]** | List of orders with status `paid`, filtered by role, sorted by `paid_at` ascending, with full item detail (selections, modifiers, KDS colour) | +| **[RG-3 — item detail]** | Pour chaque ligne de commande de type `menu`, charger aussi les lignes `order_item_selection` (choix de slot). Pour toutes les lignes, charger les lignes `order_item_modifier` (modifications d'ingredient). L'affichage utilise les snapshots (`label_snapshot`, `quantity`, `format`) ; aucune re-jointure sur les tables `product` ou `menu` necessaire. | +| **[RG-4 — KDS colour]** | Indicateur de couleur calcule au rendu : `elapsed = NOW() - customer_order.paid_at` ; vert si elapsed < seuil SLA (configurable, approx. 10 min) ; ambre si en approche ; rouge si depasse. Non stocke ; calcule cote client ou en PHP avant la reponse. | +| **[RG-5 — read only]** | Le personnel de cuisine n'effectue aucune transition de statut depuis cette vue. Aucun UPDATE n'est emis par cette operation. | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Liste des commandes au statut `paid`, filtree par role, triee par `paid_at` croissant, avec le detail complet des articles (selections, modificateurs, couleur KDS) | --- -## 6. Domain 4 — Delivery to customer +## 6. Domaine 4 — Remise au client ### 6.1 DELIVER_ORDER -**Corresponds to MCT section 6.1** +**Correspond a la section 6.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor is authenticated, holds permission `order.deliver` | -| **[PRE-2]** | Targeted order exists and `status = 'paid'` | -| **[PRE-3]** | Order source is in the actor's visible sources (verified via `role_visible_source`) | +| **[PRE-1]** | L'acteur est authentifie, detient la permission `order.deliver` | +| **[PRE-2]** | La commande ciblee existe et `status = 'paid'` | +| **[PRE-3]** | La source de la commande est dans les sources visibles de l'acteur (verifiee via `role_visible_source`) | | **[RG-1]** | `UPDATE customer_order SET status = 'delivered', delivered_at = NOW(), updated_at = NOW() WHERE id = :id AND status = 'paid'` | -| **[RG-2 — concurrency]** | The `AND status = 'paid'` clause in the UPDATE protects against concurrent double-delivery: if two staff members click simultaneously, only the first succeeds (second receives 0 rows affected). | -| **[RG-3]** | `delivered` is a terminal status: no further transition is defined from this status (application constraint, not enforced as a DB trigger). | -| **[POST-1]** | `customer_order.status = 'delivered'`, `delivered_at` set, lifecycle complete. Order passes to history. | -| **[OUT-1]** | HTTP 200 with confirmation. Order disappears from the `paid` queue. | -| **[ERR-1]** | Invalid transition (status was not `paid` when UPDATE executed — concurrency): HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` | -| **[ERR-2]** | Order source not in actor's visible sources: HTTP 403, `{error: {code: "FORBIDDEN"}}` | +| **[RG-2 — concurrency]** | La clause `AND status = 'paid'` dans l'UPDATE protege contre une double remise concurrente : si deux membres du personnel cliquent simultanement, seul le premier reussit (le second recoit 0 ligne affectee). | +| **[RG-3]** | `delivered` est un statut terminal : aucune transition ulterieure n'est definie depuis ce statut (contrainte applicative, pas appliquee comme trigger DB). | +| **[POST-1]** | `customer_order.status = 'delivered'`, `delivered_at` defini, cycle de vie complet. La commande passe a l'historique. | +| **[OUT-1]** | HTTP 200 avec confirmation. La commande disparait de la file `paid`. | +| **[ERR-1]** | Transition invalide (le statut n'etait pas `paid` au moment de l'execution de l'UPDATE — concurrence) : HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` | +| **[ERR-2]** | Source de commande hors des sources visibles de l'acteur : HTTP 403, `{error: {code: "FORBIDDEN"}}` | --- -## 7. Domain 5 — Cancellation +## 7. Domaine 5 — Annulation ### 7.1 CANCEL_ORDER -**Corresponds to MCT section 7.1** +**Correspond a la section 7.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor is authenticated, holds permission `order.cancel` | -| **[PRE-2]** | Targeted order exists | -| **[PRE-3]** | `customer_order.status` is in `['pending_payment', 'paid']`. Terminal statuses `delivered` and `cancelled` cannot transition to `cancelled`. | +| **[PRE-1]** | L'acteur est authentifie, detient la permission `order.cancel` | +| **[PRE-2]** | La commande ciblee existe | +| **[PRE-3]** | `customer_order.status` est dans `['pending_payment', 'paid']`. Les statuts terminaux `delivered` et `cancelled` ne peuvent pas transiter vers `cancelled`. | | **[RG-1 — status update]** | `UPDATE customer_order SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW() WHERE id = :id AND status IN ('pending_payment', 'paid')` | -| **[RG-2 — concurrency]** | The `AND status IN (...)` clause protects against concurrent cancellation (see RG-T07). | -| **[RG-3 — stock re-credit — conditional]** | Re-credit applies only if the order was at status `paid` before cancellation. Orders at `pending_payment` had not yet decremented stock (the decrement occurs at the `paid` transition). For each `order_item` line of a `paid` order, recompute ingredient units consumed: `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, adjusted by `order_item_modifier` rows (remove modifier -> ingredient was not decremented, so no re-credit; add modifier -> ingredient had extra decrement, so extra re-credit). UPDATE `ingredient.stock_quantity += units`. INSERT `stock_movement` (type `cancellation`, delta = +units, order_id, user_id of actor). | -| **[RG-4 — transaction]** | Status update and stock re-credit (when applicable) execute in the same database transaction (RG-T11). | -| **[RG-5 — history]** | Order is not physically deleted; retained for history and stats. Cancelled orders are excluded from revenue totals but included in volume counts in READ_STATS. `order_item` rows are not deleted (ON DELETE CASCADE is not triggered); they allow reconstruction of what was ordered. | -| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` set, terminal state. | -| **[POST-2]** | If prior status was `paid`: `ingredient.stock_quantity` re-credited; one `stock_movement` row of type `cancellation` per affected ingredient. | -| **[OUT-1]** | HTTP 200 with cancellation confirmation | -| **[ERR-1]** | Attempt to cancel a delivered or already cancelled order: HTTP 422, `{error: {code: "CANNOT_CANCEL_IN_STATE", current_status: "..."}}` | -| **[ERR-2]** | Concurrent cancellation (0 rows affected by UPDATE): HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` | +| **[RG-2 — concurrency]** | La clause `AND status IN (...)` protege contre une annulation concurrente (voir RG-T07). | +| **[RG-3 — stock re-credit — conditional]** | Le re-credit ne s'applique que si la commande etait au statut `paid` avant l'annulation. Les commandes a `pending_payment` n'avaient pas encore decremente le stock (le decrement a lieu a la transition `paid`). Pour chaque ligne `order_item` d'une commande `paid`, recalculer les unites d'ingredient consommees : `(order_item.format = 'maxi' ? product_ingredient.quantity_maxi : product_ingredient.quantity_normal) * order_item.quantity`, ajuste par les lignes `order_item_modifier` (modificateur remove -> l'ingredient n'a pas ete decremente, donc pas de re-credit ; modificateur add -> l'ingredient avait un decrement supplementaire, donc re-credit supplementaire). UPDATE `ingredient.stock_quantity += units`. INSERT `stock_movement` (type `cancellation`, delta = +units, order_id, user_id de l'acteur). | +| **[RG-4 — transaction]** | La mise a jour du statut et le re-credit de stock (quand applicable) s'executent dans la meme transaction de base de donnees (RG-T11). | +| **[RG-5 — history]** | La commande n'est pas physiquement supprimee ; conservee pour l'historique et les stats. Les commandes annulees sont exclues des totaux de chiffre d'affaires mais incluses dans les comptes de volume dans READ_STATS. Les lignes `order_item` ne sont pas supprimees (ON DELETE CASCADE n'est pas declenche) ; elles permettent de reconstruire ce qui a ete commande. | +| **[RG-6 — PIN + audit]** | L'annulation est une action sensible de manipulation d'argent : elle requiert le PIN propre a chaque membre du personnel (RG-T13) et ecrit une ligne `audit_log` dans la meme transaction (RG-T14) : `action_code='order.cancel'`, `entity_type='customer_order'`, `entity_id=:id`, `summary` avec le statut anterieur et le montant re-credite. | +| **[POST-1]** | `customer_order.status = 'cancelled'`, `cancelled_at` defini, etat terminal. Une ligne `audit_log` enregistree avec le personnel agissant. | +| **[POST-2]** | Si le statut anterieur etait `paid` : `ingredient.stock_quantity` re-credite ; une ligne `stock_movement` de type `cancellation` par ingredient affecte. | +| **[OUT-1]** | HTTP 200 avec confirmation d'annulation | +| **[ERR-1]** | Tentative d'annulation d'une commande livree ou deja annulee : HTTP 422, `{error: {code: "CANNOT_CANCEL_IN_STATE", current_status: "..."}}` | +| **[ERR-2]** | Annulation concurrente (0 ligne affectee par l'UPDATE) : HTTP 409, `{error: {code: "INVALID_TRANSITION"}}` | --- -## 8. Domain 6 — Catalogue management +## 8. Domaine 6 — Gestion du catalogue ### 8.1 CREATE_PRODUCT -**Corresponds to MCT section 8.1** +**Correspond a la section 8.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `product.create` | -| **[PRE-2]** | `category_id` references an existing category with `is_active = 1` | -| **[RG-1]** | Form validation: `name` non-empty, `price_cents > 0`, `category_id` valid, `vat_rate` in `(55, 100)` | -| **[RG-2]** | Image upload (optional): validate MIME type (JPEG, PNG, WEBP), max size configurable (suggestion: 2 MB), store under `UPLOAD_DIR/products/`, record relative path in `image_path` | -| **[RG-3]** | `is_available = 1` by default at INSERT | -| **[RG-4]** | `display_order` set to `MAX(display_order) + 1` for the target category, or 0 if first product | -| **[POST-1]** | One `product` row in the database with all valid fields | -| **[OUT-1]** | Redirect to category product list with success message | -| **[ERR-1]** | Validation failure: inline field errors displayed | -| **[ERR-2]** | Invalid image (type or size): specific error message | +| **[PRE-1]** | Acteur authentifie, detient la permission `product.create` | +| **[PRE-2]** | `category_id` reference une categorie existante avec `is_active = 1` | +| **[RG-1]** | Validation du formulaire : `name` non vide, `price_cents > 0`, `category_id` valide, `vat_rate` dans `(55, 100)` | +| **[RG-2]** | Upload d'image (optionnel) : valider le type MIME (JPEG, PNG, WEBP), taille max configurable (suggestion : 2 MB), stocker sous `UPLOAD_DIR/products/`, enregistrer le chemin relatif dans `image_path` | +| **[RG-3]** | `is_available = 1` par defaut a l'INSERT | +| **[RG-4]** | `display_order` defini a `MAX(display_order) + 1` pour la categorie cible, ou 0 si premier produit | +| **[POST-1]** | Une ligne `product` dans la base avec tous les champs valides | +| **[OUT-1]** | Redirection vers la liste des produits de la categorie avec message de succes | +| **[ERR-1]** | Echec de validation : erreurs de champ affichees en ligne | +| **[ERR-2]** | Image invalide (type ou taille) : message d'erreur specifique | --- ### 8.2 UPDATE_PRODUCT -**Corresponds to MCT section 8.2** +**Correspond a la section 8.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `product.update` | -| **[PRE-2]** | Target `product.id` exists | -| **[RG-1]** | Same validations as CREATE_PRODUCT on modified fields | -| **[RG-2]** | If a new image is uploaded, the old image file is deleted from the filesystem (volume cleanup) | -| **[RG-3]** | `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot` in historical `order_item` rows are not modified (see RG-T05) | -| **[POST-1]** | `product` updated, `updated_at` refreshed | -| **[OUT-1]** | Redirect to product list with success message | +| **[PRE-1]** | Acteur authentifie, detient la permission `product.update` | +| **[PRE-2]** | Le `product.id` cible existe | +| **[RG-1]** | Memes validations que CREATE_PRODUCT sur les champs modifies | +| **[RG-2]** | Si une nouvelle image est uploadee, l'ancien fichier image est supprime du systeme de fichiers (nettoyage du volume) | +| **[RG-3]** | `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot` dans les lignes `order_item` historiques ne sont pas modifies (voir RG-T05) | +| **[RG-4 — PIN + audit + allowlist]** | Un changement de prix/TVA est une action sensible : il requiert le PIN propre a chaque membre du personnel (RG-T13) et ecrit une ligne `audit_log` (RG-T14) avec `action_code='product.update'`, `entity_type='product'`, `entity_id=:id`, et un `summary` enregistrant les valeurs modifiees (ex. `price_cents 880 -> 920`). Seules les colonnes en allowlist (`name`, `description`, `price_cents`, `vat_rate`, `image_path`, `is_available`, `display_order`, `category_id`) sont liees depuis la requete (RG-T16). | +| **[POST-1]** | `product` mis a jour, `updated_at` rafraichi ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection vers la liste des produits avec message de succes | --- ### 8.3 DELETE_PRODUCT -**Corresponds to MCT section 8.3** +**Correspond a la section 8.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `product.delete` | -| **[PRE-2]** | Target `product.id` exists | -| **[RG-1]** | Pre-check (PHP): is the product referenced in `menu_slot_option.product_id`? If yes, display blocking message listing the menus. | -| **[RG-2]** | Pre-check (PHP): is the product the `burger_product_id` of any `menu`? If yes, block with message to delete or reassign the menu first. | -| **[RG-3]** | Pre-check (PHP): is the product referenced in `order_item.product_id` (historical orders)? FK `ON DELETE RESTRICT` blocks at DB level. Recommended response: propose deactivation (`is_available=0`) rather than deletion. | -| **[RG-4]** | FK constraints (`menu_slot_option.product_id ON DELETE RESTRICT`, `order_item.product_id ON DELETE RESTRICT`) enforce the constraint even if the PHP check is bypassed. | -| **[POST-1]** | Product deleted if no FK constraint was blocking | -| **[OUT-1]** | Redirect to product list with success message | -| **[ERR-1]** | Product in menu slot: HTTP 422 or inline message with blocking menu list | -| **[ERR-2]** | Product in historical orders: message proposing deactivation instead | +| **[PRE-1]** | Acteur authentifie, detient la permission `product.delete` | +| **[PRE-2]** | Le `product.id` cible existe | +| **[RG-1]** | Pre-verification (PHP) : le produit est-il reference dans `menu_slot_option.product_id` ? Si oui, afficher un message bloquant listant les menus. | +| **[RG-2]** | Pre-verification (PHP) : le produit est-il le `burger_product_id` d'un `menu` ? Si oui, bloquer avec un message invitant a supprimer ou reaffecter le menu d'abord. | +| **[RG-3]** | Pre-verification (PHP) : le produit est-il reference dans `order_item.product_id` (commandes historiques) ? La FK `ON DELETE RESTRICT` bloque au niveau DB. Reponse recommandee : proposer la desactivation (`is_available=0`) plutot que la suppression. | +| **[RG-4]** | Les contraintes FK (`menu_slot_option.product_id ON DELETE RESTRICT`, `order_item.product_id ON DELETE RESTRICT`) appliquent la contrainte meme si la verification PHP est contournee. | +| **[RG-5 — PIN + audit]** | La suppression est une action sensible : elle requiert le PIN propre a chaque membre du personnel (RG-T13) et ecrit une ligne `audit_log` (RG-T14) avec `action_code='product.delete'`, `entity_type='product'`, `entity_id=:id`, `summary` capturant le nom du produit avant suppression (enregistre avant que la ligne ne soit retiree). | +| **[POST-1]** | Produit supprime si aucune contrainte FK ne bloquait ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection vers la liste des produits avec message de succes | +| **[ERR-1]** | Produit dans un slot de menu : HTTP 422 ou message en ligne avec la liste des menus bloquants | +| **[ERR-2]** | Produit dans des commandes historiques : message proposant la desactivation a la place | --- ### 8.4 CREATE_MENU -**Corresponds to MCT section 8.4** +**Correspond a la section 8.4 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `menu.create` | -| **[PRE-2]** | `burger_product_id` references an existing, available product | -| **[PRE-3]** | At least one `menu_slot` is defined with at least one `menu_slot_option` | -| **[RG-1]** | Validation: `name` non-empty, `price_normal_cents > 0`, `price_maxi_cents > 0`, `burger_product_id` valid, all `product_id` values in slot options exist | -| **[RG-2]** | Transaction: INSERT `menu`, then INSERT `menu_slot` rows (name, slot_type, is_required, display_order), then INSERT `menu_slot_option` rows (menu_slot_id, product_id) | -| **[RG-3]** | Valid `slot_type` values (from dictionary ENUM): `drink`, `side`, `sauce`, `dessert`, `extra` | -| **[POST-1]** | One `menu` row, N `menu_slot` rows, M `menu_slot_option` rows in the database | -| **[OUT-1]** | Redirect to menu list with success message | -| **[ERR-1]** | Invalid configuration (no slot, no option): business error message | -| **[ERR-2]** | Slot option product unavailable: warning (menu can be created; product availability is checked at order time) | +| **[PRE-1]** | Acteur authentifie, detient la permission `menu.create` | +| **[PRE-2]** | `burger_product_id` reference un produit existant et disponible | +| **[PRE-3]** | Au moins un `menu_slot` est defini avec au moins une `menu_slot_option` | +| **[RG-1]** | Validation : `name` non vide, `price_normal_cents > 0`, `price_maxi_cents > 0`, `burger_product_id` valide, toutes les valeurs `product_id` des options de slot existent | +| **[RG-2]** | Transaction : INSERT `menu`, puis INSERT des lignes `menu_slot` (name, slot_type, is_required, display_order), puis INSERT des lignes `menu_slot_option` (menu_slot_id, product_id) | +| **[RG-3]** | Valeurs `slot_type` valides (depuis l'ENUM du dictionnaire) : `drink`, `side`, `sauce`, `dessert`, `extra` | +| **[POST-1]** | Une ligne `menu`, N lignes `menu_slot`, M lignes `menu_slot_option` dans la base | +| **[OUT-1]** | Redirection vers la liste des menus avec message de succes | +| **[ERR-1]** | Configuration invalide (pas de slot, pas d'option) : message d'erreur metier | +| **[ERR-2]** | Produit d'option de slot indisponible : avertissement (le menu peut etre cree ; la disponibilite du produit est verifiee au moment de la commande) | --- ### 8.5 UPDATE_MENU -**Corresponds to MCT section 8.5** +**Correspond a la section 8.5 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `menu.update` | -| **[PRE-2]** | Target `menu.id` exists | -| **[RG-1]** | Same validations as CREATE_MENU on modified fields | -| **[RG-2]** | If slot configuration is modified: `DELETE FROM menu_slot_option WHERE menu_slot_id IN (SELECT id FROM menu_slot WHERE menu_id = :id)`, then `DELETE FROM menu_slot WHERE menu_id = :id`, then re-INSERT (delete-and-reinsert pattern, atomic in transaction) | -| **[RG-3]** | `label_snapshot` values in historical `order_item_selection` rows are not affected (see RG-T05) | -| **[POST-1]** | `menu` updated; `menu_slot` and `menu_slot_option` rebuilt | -| **[OUT-1]** | Redirect with success message | +| **[PRE-1]** | Acteur authentifie, detient la permission `menu.update` | +| **[PRE-2]** | Le `menu.id` cible existe | +| **[RG-1]** | Memes validations que CREATE_MENU sur les champs modifies | +| **[RG-2]** | Si la configuration de slot est modifiee : `DELETE FROM menu_slot_option WHERE menu_slot_id IN (SELECT id FROM menu_slot WHERE menu_id = :id)`, puis `DELETE FROM menu_slot WHERE menu_id = :id`, puis re-INSERT (pattern delete-and-reinsert, atomique en transaction) | +| **[RG-3]** | Les valeurs `label_snapshot` dans les lignes `order_item_selection` historiques ne sont pas affectees (voir RG-T05) | +| **[POST-1]** | `menu` mis a jour ; `menu_slot` et `menu_slot_option` reconstruits | +| **[OUT-1]** | Redirection avec message de succes | --- ### 8.6 DELETE_MENU -**Corresponds to MCT section 8.6** +**Correspond a la section 8.6 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `menu.delete` | -| **[PRE-2]** | Target `menu.id` exists | -| **[RG-1]** | Pre-check (PHP): is the menu referenced in `order_item.menu_id`? FK `ON DELETE RESTRICT`. If yes, propose deactivation (`is_available=0`) instead of deletion. | -| **[RG-2]** | If no historical reference: DELETE `menu` triggers CASCADE to `menu_slot` (which cascades to `menu_slot_option`) | -| **[POST-1]** | `menu`, its `menu_slot` rows, and its `menu_slot_option` rows deleted | -| **[OUT-1]** | Redirect with success message | -| **[ERR-1]** | Menu in historical orders: message proposing deactivation instead | +| **[PRE-1]** | Acteur authentifie, detient la permission `menu.delete` | +| **[PRE-2]** | Le `menu.id` cible existe | +| **[RG-1]** | Pre-verification (PHP) : le menu est-il reference dans `order_item.menu_id` ? FK `ON DELETE RESTRICT`. Si oui, proposer la desactivation (`is_available=0`) au lieu de la suppression. | +| **[RG-2]** | Si aucune reference historique : DELETE `menu` declenche un CASCADE vers `menu_slot` (qui cascade vers `menu_slot_option`) | +| **[RG-3 — PIN + audit]** | La suppression est une action sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='menu.delete'`, `entity_type='menu'`, `entity_id=:id`, `summary` capturant le nom du menu avant suppression. | +| **[POST-1]** | `menu`, ses lignes `menu_slot` et ses lignes `menu_slot_option` supprimes ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection avec message de succes | +| **[ERR-1]** | Menu dans des commandes historiques : message proposant la desactivation a la place | --- ### 8.7 MANAGE_CATEGORY -**Corresponds to MCT section 8.7** +**Correspond a la section 8.7 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `category.manage` | -| **[RG-CREATE]** | `name` and `slug` non-empty and unique in the database; `display_order` set to MAX + 1 | +| **[PRE-1]** | Acteur authentifie, detient la permission `category.manage` | +| **[RG-CREATE]** | `name` et `slug` non vides et uniques dans la base ; `display_order` defini a MAX + 1 | | **[RG-UPDATE]** | UPDATE `name`, `slug`, `image_path`, `display_order`, `is_active` | -| **[RG-DEACTIVATE]** | Deactivation (`is_active=0`) does not auto-deactivate child products/menus in the DB (no CASCADE on `is_active`). PHP layer proposes to the admin to also deactivate child products/menus, or the kiosk filter on `category.is_active = 1` implicitly hides them. | -| **[RG-DELETE]** | Physical deletion blocked if `product.category_id` or `menu.category_id` references this category (FK `ON DELETE RESTRICT`). Propose deactivation. | -| **[POST-CREATE]** | New `category` row in database | -| **[POST-UPDATE]** | `category` updated, `updated_at` refreshed | -| **[OUT-1]** | Confirmation, redirect to category list | +| **[RG-DEACTIVATE]** | La desactivation (`is_active=0`) ne desactive pas automatiquement les produits/menus enfants dans la DB (pas de CASCADE sur `is_active`). La couche PHP propose a l'admin de desactiver aussi les produits/menus enfants, ou le filtre kiosk sur `category.is_active = 1` les masque implicitement. | +| **[RG-DELETE]** | Suppression physique bloquee si `product.category_id` ou `menu.category_id` reference cette categorie (FK `ON DELETE RESTRICT`). Proposer la desactivation. | +| **[POST-CREATE]** | Nouvelle ligne `category` dans la base | +| **[POST-UPDATE]** | `category` mise a jour, `updated_at` rafraichi | +| **[OUT-1]** | Confirmation, redirection vers la liste des categories | --- ### 8.8 MANAGE_INGREDIENT -**Corresponds to MCT section 8.8** +**Correspond a la section 8.8 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `ingredient.manage` | -| **[RG-CREATE-ING]** | `name` non-empty and UNIQUE; `unit` non-empty; `pack_size >= 1`; `low_stock_threshold >= 0`; `stock_quantity` defaults to 0 at creation | -| **[RG-UPDATE-ING]** | UPDATE `name`, `unit`, `pack_size`, `pack_label`, `low_stock_threshold`, `is_active` | -| **[RG-DEACTIVATE-ING]** | `is_active=0` hides ingredient from configurator. Physical deletion blocked if referenced in `product_ingredient` (FK `ON DELETE RESTRICT`) or `stock_movement` (FK `ON DELETE RESTRICT`). | -| **[RG-COMPOSITION]** | UPDATE `product_ingredient`: for each ingredient in a product's recipe, set `quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`. Delete-and-reinsert pattern within transaction. | -| **[RG-ALLERGEN]** | Manage `ingredient_allergen`: INSERT or DELETE `(ingredient_id, allergen_id)` pairs. Allergen list is read-only (14 rows fixed by EU regulation 1169/2011). | -| **[POST-1]** | `ingredient` / `product_ingredient` / `ingredient_allergen` rows updated | -| **[OUT-1]** | Confirmation, redirect to ingredient list or product composition form | +| **[PRE-1]** | Acteur authentifie, detient la permission `ingredient.manage` | +| **[RG-CREATE-ING]** | `name` non vide et UNIQUE ; `unit` non vide ; `pack_size >= 1` ; `stock_capacity >= 1` (la reference 100%) ; `low_stock_pct` et `critical_stock_pct` dans 0-100 avec `critical_stock_pct < low_stock_pct` (defauts 10 / 5) ; `stock_quantity` par defaut a 0 a la creation | +| **[RG-UPDATE-ING]** | UPDATE `name`, `unit`, `pack_size`, `pack_label`, `stock_capacity`, `low_stock_pct`, `critical_stock_pct`, `is_active` | +| **[RG-DEACTIVATE-ING]** | `is_active=0` masque l'ingredient du configurateur. Suppression physique bloquee si reference dans `product_ingredient` (FK `ON DELETE RESTRICT`) ou `stock_movement` (FK `ON DELETE RESTRICT`). | +| **[RG-COMPOSITION]** | UPDATE `product_ingredient` : pour chaque ingredient de la recette d'un produit, definir `quantity_normal`, `quantity_maxi`, `is_removable`, `is_addable`, `extra_price_cents`. Pattern delete-and-reinsert en transaction. | +| **[RG-ALLERGEN]** | Gerer `ingredient_allergen` : INSERT ou DELETE des paires `(ingredient_id, allergen_id)`. La liste des allergenes est en lecture seule (14 lignes fixees par le reglement UE 1169/2011). | +| **[POST-1]** | Lignes `ingredient` / `product_ingredient` / `ingredient_allergen` mises a jour | +| **[OUT-1]** | Confirmation, redirection vers la liste des ingredients ou le formulaire de composition de produit | --- -## 9. Domain 7 — Stock management +## 9. Domaine 7 — Gestion du stock ### 9.1 RESTOCK -**Corresponds to MCT section 9.1** +**Correspond a la section 9.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `stock.manage` | -| **[PRE-2]** | Target ingredient exists and `is_active = 1` | -| **[PRE-3]** | Number of packs `N >= 1` | +| **[PRE-1]** | Acteur authentifie, detient la permission `stock.manage` | +| **[PRE-2]** | L'ingredient cible existe et `is_active = 1` | +| **[PRE-3]** | Nombre de packs `N >= 1` | | **[RG-1]** | `delta = N * ingredient.pack_size` | -| **[RG-2]** | Transaction: `UPDATE ingredient SET stock_quantity = stock_quantity + :delta WHERE id = :id`; INSERT `stock_movement` (ingredient_id, movement_type=`restock`, delta=+delta, order_id=NULL, user_id=actor, note=optional) | -| **[RG-3]** | `stock_movement` is append-only: no UPDATE or DELETE on this table (corrections are new rows) | -| **[POST-1]** | `ingredient.stock_quantity` incremented by `delta`. One `stock_movement` row of type `restock` inserted. | -| **[OUT-1]** | Confirmation with new stock level displayed | +| **[RG-2]** | Transaction : `UPDATE ingredient SET stock_quantity = stock_quantity + :delta WHERE id = :id` ; INSERT `stock_movement` (ingredient_id, movement_type=`restock`, delta=+delta, order_id=NULL, user_id=acteur, note=optionnelle) | +| **[RG-3]** | `stock_movement` est append-only : aucun UPDATE ou DELETE sur cette table (les corrections sont de nouvelles lignes) | +| **[POST-1]** | `ingredient.stock_quantity` incremente de `delta`. Une ligne `stock_movement` de type `restock` inseree. | +| **[OUT-1]** | Confirmation avec le nouveau niveau de stock affiche | --- ### 9.2 INVENTORY_COUNT -**Corresponds to MCT section 9.2** +**Correspond a la section 9.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `stock.count` | -| **[PRE-2]** | Target ingredient exists | -| **[PRE-3]** | `actual_quantity >= 0` (physical count is non-negative) | -| **[RG-1]** | `delta = actual_quantity - ingredient.stock_quantity` (may be negative if actual < theoretical) | -| **[RG-2]** | Transaction: `UPDATE ingredient SET stock_quantity = :actual_quantity WHERE id = :id`; INSERT `stock_movement` (ingredient_id, movement_type=`inventory_correction`, delta=computed, order_id=NULL, user_id=actor, note=optional) | -| **[RG-3]** | `delta = 0` is a valid correction (physical count matches theoretical); a movement row is still inserted for audit completeness | -| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. One `stock_movement` row of type `inventory_correction` inserted. | -| **[OUT-1]** | Confirmation with reconciled stock level and discrepancy displayed | +| **[PRE-1]** | Acteur authentifie, detient la permission `stock.count` | +| **[PRE-2]** | L'ingredient cible existe | +| **[PRE-3]** | `actual_quantity >= 0` (le comptage physique est non negatif) | +| **[RG-1]** | `delta = actual_quantity - ingredient.stock_quantity` (peut etre negatif si actual < theorique) | +| **[RG-2]** | Transaction : `UPDATE ingredient SET stock_quantity = :actual_quantity WHERE id = :id` ; INSERT `stock_movement` (ingredient_id, movement_type=`inventory_correction`, delta=calcule, order_id=NULL, user_id=acteur, note=optionnelle) | +| **[RG-3]** | `delta = 0` est une correction valide (le comptage physique correspond au theorique) ; une ligne de mouvement est tout de meme inseree pour la completude de l'audit | +| **[RG-4 — PIN attribution]** | Une correction d'inventaire peut masquer de la demarque, elle requiert donc le PIN propre a chaque membre du personnel (RG-T13). Le `user_id` capture par PIN est ecrit dans `stock_movement.user_id`, rendant la correction imputable a une personne meme sur un poste de travail partage. Pas de ligne `audit_log` separee (la trace `stock_movement` l'enregistre deja). | +| **[POST-1]** | `ingredient.stock_quantity = actual_quantity`. Une ligne `stock_movement` de type `inventory_correction` inseree avec le `user_id` agissant. | +| **[OUT-1]** | Confirmation avec le niveau de stock reconcilie et l'ecart affiches | --- ### 9.3 READ_STOCK -**Corresponds to MCT section 9.3** +**Correspond a la section 9.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `stock.read` | +| **[PRE-1]** | Acteur authentifie, detient la permission `stock.read` | | **[RG-1]** | `SELECT * FROM ingredient WHERE is_active = 1 ORDER BY name ASC` | -| **[RG-2]** | Low-stock alert computed at render time: `stock_quantity <= low_stock_threshold` -> flag `low_stock: true` in response. Not stored as a column. | -| **[RG-3]** | Optional movement history for a given ingredient: `SELECT * FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC LIMIT :n` | -| **[POST-1]** | No database write | -| **[OUT-1]** | Ingredient list with `stock_quantity`, `low_stock_threshold`, `pack_size`, `pack_label`, `low_stock` flag | +| **[RG-2]** | Bandes de stock calculees au rendu depuis les seuils en pourcentage : `low_stock: true` quand `stock_quantity <= stock_capacity * low_stock_pct / 100`, `critical_stock: true` quand `stock_quantity <= stock_capacity * critical_stock_pct / 100` ; `stock_pct = ROUND(stock_quantity / stock_capacity * 100)` est aussi retourne. Non stockees comme colonnes. | +| **[RG-3]** | Historique optionnel des mouvements pour un ingredient donne : `SELECT * FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC LIMIT :n` | +| **[RG-4 — attribution visibility]** | Le `stock_movement.user_id` (qui a reapprovisionne / qui a corrige) est inclus pour `manager`/`admin` uniquement ; le personnel de ligne (`kitchen`/`counter`/`drive`) voit les deltas de mouvement sans l'identite de l'acteur. Cela limite l'exposition intra-equipe tout en preservant l'imputabilite pour ceux qui gerent. L'allowlist `details` est appliquee a la couche de requete/serialisation. | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Liste des ingredients avec `stock_quantity`, `stock_capacity`, `stock_pct` calcule, `low_stock_pct`, `critical_stock_pct`, `pack_size`, `pack_label`, drapeaux `low_stock` / `critical_stock` ; historique des mouvements avec l'acteur visible pour manager/admin uniquement | --- -## 10. Domain 8 — User and role management +## 10. Domaine 8 — Gestion des utilisateurs et des roles ### 10.1 CREATE_USER -**Corresponds to MCT section 10.1** +**Correspond a la section 10.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `user.create` | -| **[PRE-2]** | Email does not already exist in `user.email` (UNIQUE constraint) | -| **[PRE-3]** | `role_id` references an existing, active role | -| **[RG-1]** | Validation: `email` conforms to RFC 5321 (PHP `FILTER_VALIDATE_EMAIL`), `first_name` and `last_name` non-empty, `role_id` valid | -| **[RG-2]** | Password hash: `password_hash($password, PASSWORD_ARGON2ID)`. Minimum password length: 8 characters. | -| **[RG-3]** | `is_active = 1` by default; `last_login_at = NULL` at creation | -| **[POST-1]** | One `user` row with argon2id `password_hash`, valid `role_id` | -| **[OUT-1]** | Redirect to user list with success message | -| **[ERR-1]** | Duplicate email: message "This email is already in use" | -| **[ERR-2]** | Password too short: inline validation message | +| **[PRE-1]** | Acteur authentifie, detient la permission `user.create` | +| **[PRE-2]** | L'email n'existe pas deja dans `user.email` (contrainte UNIQUE) | +| **[PRE-3]** | `role_id` reference un role existant et actif | +| **[RG-1]** | Validation : `email` conforme a la RFC 5321 (PHP `FILTER_VALIDATE_EMAIL`), `first_name` et `last_name` non vides, `role_id` valide | +| **[RG-2]** | Hachage du mot de passe : `password_hash($password, PASSWORD_ARGON2ID)`. Longueur minimale du mot de passe : 8 caracteres. | +| **[RG-3]** | `is_active = 1` par defaut ; `last_login_at = NULL` a la creation | +| **[RG-4 — PIN + audit + allowlist]** | Creer un compte back-office est une action sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='user.create'`, `entity_type='user'`, `entity_id=:new_id`, `details` enregistrant le `role_id` assigne (noms de champs/role, pas le mot de passe). Seules les colonnes en allowlist sont liees (RG-T16) : `email`, `first_name`, `last_name`, `role_id` (+ le mot de passe hache) ; `is_active` et tout autre champ sont definis cote serveur, pas lies a la requete. | +| **[POST-1]** | Une ligne `user` avec `password_hash` argon2id, `role_id` valide ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection vers la liste des utilisateurs avec message de succes | +| **[ERR-1]** | Email en doublon : message "Cet email est deja utilise" | +| **[ERR-2]** | Mot de passe trop court : message de validation en ligne | --- ### 10.2 UPDATE_USER -**Corresponds to MCT section 10.2** +**Correspond a la section 10.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `user.update` | -| **[PRE-2]** | Target `user.id` exists | -| **[RG-1]** | If a new password is supplied (non-empty field): rehash via `PASSWORD_ARGON2ID` and replace existing hash | -| **[RG-2]** | If password field is empty: existing hash is preserved unchanged | -| **[RG-3]** | Email update subject to UNIQUE constraint (pre-check before UPDATE) | -| **[POST-1]** | `user` updated, `updated_at` refreshed | -| **[OUT-1]** | Redirect with success message | +| **[PRE-1]** | Acteur authentifie, detient la permission `user.update` | +| **[PRE-2]** | Le `user.id` cible existe | +| **[RG-1]** | Si un nouveau mot de passe est fourni (champ non vide) : re-hacher via `PASSWORD_ARGON2ID` et remplacer le hash existant | +| **[RG-2]** | Si le champ mot de passe est vide : le hash existant est preserve inchange | +| **[RG-3]** | Mise a jour d'email soumise a la contrainte UNIQUE (pre-verification avant l'UPDATE) | +| **[RG-4 — PIN + audit + allowlist]** | Editer un compte (incl. `role_id`, le vecteur d'escalade de privileges) est sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='user.update'`, `entity_type='user'`, `entity_id=:id`, `details` listant les noms des champs modifies (pas les valeurs, pas de PII). Seules les colonnes en allowlist sont liees (RG-T16) : `first_name`, `last_name`, `email`, `role_id`, `is_active` (+ re-hachage optionnel du mot de passe). | +| **[POST-1]** | `user` mis a jour, `updated_at` rafraichi ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection avec message de succes | --- ### 10.3 DEACTIVATE_USER -**Corresponds to MCT section 10.3** +**Correspond a la section 10.3 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `user.deactivate` | -| **[PRE-2]** | Actor is not targeting their own account (`$targetUserId !== $currentUserId`) | +| **[PRE-1]** | Acteur authentifie, detient la permission `user.deactivate` | +| **[PRE-2]** | L'acteur ne cible pas son propre compte (`$targetUserId !== $currentUserId`) | | **[RG-1]** | `UPDATE user SET is_active = 0, updated_at = NOW() WHERE id = :id` | -| **[RG-2]** | The user's potentially active session is invalidated on next request: middleware checks `user.is_active = 1` on each authenticated request | -| **[POST-1]** | `user.is_active = 0`; user cannot log in; history remains intact | -| **[OUT-1]** | Redirect with success message | -| **[ERR-1]** | Self-deactivation attempt: HTTP 403, `{error: {code: "SELF_DEACTIVATION_FORBIDDEN"}}` | +| **[RG-2]** | La session potentiellement active de l'utilisateur est invalidee a la requete suivante : le middleware verifie `user.is_active = 1` a chaque requete authentifiee | +| **[RG-3 — PIN + audit]** | Action sensible : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14), `action_code='user.deactivate'`, `entity_type='user'`, `entity_id=:id`. | +| **[POST-1]** | `user.is_active = 0` ; l'utilisateur ne peut plus se connecter ; l'historique reste intact ; une ligne `audit_log` enregistree | +| **[OUT-1]** | Redirection avec message de succes | +| **[ERR-1]** | Tentative d'auto-desactivation : HTTP 403, `{error: {code: "SELF_DEACTIVATION_FORBIDDEN"}}` | --- ### 10.4 MANAGE_RBAC -**Corresponds to MCT section 10.4** +**Correspond a la section 10.4 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `role.manage` | -| **[PRE-2]** | Target `role.id` exists (for permission update) or role fields are valid (for role creation) | -| **[PRE-3]** | All submitted `permission_id` values exist in the `permission` catalogue | -| **[RG-1 — permissions]** | Transaction: `DELETE FROM role_permission WHERE role_id = :id`; INSERT new `(role_id, permission_id)` pairs for each selected permission | -| **[RG-2]** | Permissions are not modifiable via this operation: they are read-only to populate the selection form. Permission catalogue is frozen at seed. | -| **[RG-3]** | Effect is immediate for new requests; sessions of users bearing this role see the change on the next permission check (sessions store `role_id`; permissions are reloaded from DB on each check). | -| **[RG-4 — custom role]** | Creating a custom role: INSERT `role` (code UNIQUE, label, description, default_route nullable, order_source nullable); INSERT `role_visible_source` rows as needed. | -| **[RG-5 — order_source]** | `role.order_source` controls the auto-tagging of `customer_order.source` when this role creates an order. NULL for admin and manager (they can create on behalf of any channel). | -| **[POST-1]** | `role_permission` reflects exactly the selected permissions for this role | -| **[OUT-1]** | Redirect with success message | +| **[PRE-1]** | Acteur authentifie, detient la permission `role.manage` | +| **[PRE-2]** | Le `role.id` cible existe (pour la mise a jour des permissions) ou les champs du role sont valides (pour la creation de role) | +| **[PRE-3]** | Toutes les valeurs `permission_id` soumises existent dans le catalogue `permission` | +| **[RG-1 — permissions]** | Transaction : `DELETE FROM role_permission WHERE role_id = :id` ; INSERT des nouvelles paires `(role_id, permission_id)` pour chaque permission selectionnee | +| **[RG-2]** | Les permissions ne sont pas modifiables via cette operation : elles sont en lecture seule pour peupler le formulaire de selection. Le catalogue de permissions est fige au seed. | +| **[RG-3]** | L'effet est immediat pour les nouvelles requetes ; les sessions des utilisateurs portant ce role voient le changement a la prochaine verification de permission (les sessions stockent `role_id` ; les permissions sont rechargees depuis la DB a chaque verification). | +| **[RG-4 — custom role]** | Creer un role personnalise : INSERT `role` (code UNIQUE, label, description, default_route nullable, order_source nullable) ; INSERT des lignes `role_visible_source` selon le besoin. | +| **[RG-5 — order_source]** | `role.order_source` controle l'auto-tagging de `customer_order.source` lorsque ce role cree une commande. NULL pour admin et manager (ils peuvent creer au nom de n'importe quel canal). | +| **[RG-6 — PIN + audit change-log]** | Les changements RBAC sont a fort impact (escalade de privileges) : PIN propre a chaque membre du personnel (RG-T13) + une ligne `audit_log` (RG-T14) par changement, `action_code='role.manage'`, `entity_type='role'`, `entity_id=:role_id`. Comme les permissions sont reecrites en delete-and-reinsert (RG-1), le `details` JSON enregistre le **diff** — codes de permission ajoutes et retires — calcule avant la reecriture, de sorte que la trace montre exactement quelles capacites un role a gagnees ou perdues et qui les a accordees. | +| **[POST-1]** | `role_permission` reflete exactement les permissions selectionnees pour ce role ; une ligne `audit_log` enregistree avec le diff de permissions | +| **[OUT-1]** | Redirection avec message de succes | --- -## 11. Domain 9 — Stats and KPI +### 10.5 ERASE_USER_PII (anonymisation RGPD) + +**Operation security-by-design (pas de predecesseur MCT v0.1 / v0.2). Honore le droit a +l'effacement RGPD (Cr 3.d) sans casser l'integrite referentielle ni la trace d'audit (note 13 du dict.).** + +| Marqueur | Contenu | +|-----|---------| +| **[PRE-1]** | Acteur authentifie, detient la permission `user.update` (l'effacement est une operation admin) | +| **[PRE-2]** | PIN propre a chaque membre du personnel verifie (RG-T13) — action sensible | +| **[PRE-3]** | Le `user.id` cible existe et `anonymized_at IS NULL` (pas deja anonymise) | +| **[RG-1 — anonymise, not delete]** | En une transaction : `UPDATE user SET email = CONCAT('anon-', id, '@wakdo.invalid'), first_name = '', last_name = '', password_hash = '', pin_hash = NULL, password_reset_token_hash = NULL, is_active = 0, anonymized_at = NOW() WHERE id = :id`. Le domaine placeholder est reserve par la RFC 2606 (`.invalid`), garde `email` UNIQUE et non identifiant. | +| **[RG-2 — preserve links]** | La ligne persiste, donc les FK pointant vers elle (`stock_movement.user_id`, `customer_order.acting_user_id`, `audit_log.actor_user_id`) restent valides et resolvent desormais vers un principal anonymise. L'imputabilite des actions passees est preservee dans sa forme (qui-en-tant-qu'id) sans conserver de PII. | +| **[RG-3 — audit]** | Une ligne `audit_log` (RG-T14) : `action_code='user.erase_pii'`, `entity_type='user'`, `entity_id=:id`. Le `summary`/`details` enregistrent l'evenement d'effacement et sa base legale, pas les valeurs effacees. | +| **[POST-1]** | Ligne `user` anonymisee : champs PII vides/placeholders, identifiants invalides, `anonymized_at` defini, `is_active = 0`. Liens referentiels intacts. | +| **[OUT-1]** | Confirmation ; l'utilisateur disparait des listes actives, demeure comme tombstone anonymise dans l'historique. | +| **[ERR-1]** | Deja anonymise : HTTP 409, `{error: {code: "ALREADY_ANONYMISED"}}` | + +--- + +## 11. Domaine 9 — Stats et KPI ### 11.1 READ_STATS -**Corresponds to MCT section 11.1** +**Correspond a la section 11.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Actor authenticated, holds permission `stats.read` | -| **[RG-1 — service_day]** | `service_day` expression used in all stats aggregations: `CASE WHEN HOUR(customer_order.created_at) < 10 THEN DATE(customer_order.created_at) - INTERVAL 1 DAY ELSE DATE(customer_order.created_at) END`. Cutoff at 10:00. No stored column. The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` is dropped. | -| **[RG-2 — revenue]** | Revenue queries filter `status != 'cancelled'`; they sum `total_ttc_cents` from `customer_order`. Cancelled orders are excluded from revenue but appear in volume counts with `status = 'cancelled'` filter. | +| **[PRE-1]** | Acteur authentifie, detient la permission `stats.read` | +| **[RG-1 — service_day]** | Expression `service_day` utilisee dans toutes les agregations de stats : `CASE WHEN HOUR(customer_order.created_at) < 10 THEN DATE(customer_order.created_at) - INTERVAL 1 DAY ELSE DATE(customer_order.created_at) END`. Coupure a 10:00. Pas de colonne stockee. La formule v0.1 avec `INTERVAL 4 HOUR 30 MINUTE` est abandonnee. | +| **[RG-2 — revenue]** | Les requetes de chiffre d'affaires filtrent `status != 'cancelled'` ; elles somment `total_ttc_cents` depuis `customer_order`. Les commandes annulees sont exclues du chiffre d'affaires mais apparaissent dans les comptes de volume avec le filtre `status = 'cancelled'`. | | **[RG-3 — top products]** | `SELECT label_snapshot, SUM(quantity) AS total_sold FROM order_item JOIN customer_order ON ... WHERE customer_order.status != 'cancelled' GROUP BY label_snapshot ORDER BY total_sold DESC LIMIT 10` | -| **[RG-4 — delivery time KPI]** | Average delivery time: `AVG(TIMESTAMPDIFF(SECOND, paid_at, delivered_at))` on orders with `status = 'delivered'`. SLA reference approx. 10 min (configurable). | -| **[RG-5 — breakdown]** | Breakdowns available by `source` (kiosk/counter/drive) and `service_mode` (dine_in/takeaway/drive) for capacity planning. `service_mode` carries no fiscal role (see dictionary note 9). | -| **[POST-1]** | No database write | -| **[OUT-1]** | Stats dashboard data: revenue by service_day, order counts, top products, cancellation rate, average delivery time, breakdown by source/service_mode | +| **[RG-4 — delivery time KPI]** | Temps de livraison moyen : `AVG(TIMESTAMPDIFF(SECOND, paid_at, delivered_at))` sur les commandes avec `status = 'delivered'`. Reference SLA approx. 10 min (configurable). | +| **[RG-5 — breakdown]** | Ventilations disponibles par `source` (kiosk/counter/drive) et `service_mode` (dine_in/takeaway/drive) pour la planification de capacite. `service_mode` ne porte aucun role fiscal (voir note 9 du dictionnaire). | +| **[POST-1]** | Aucune ecriture en base | +| **[OUT-1]** | Donnees du tableau de bord de stats : chiffre d'affaires par service_day, comptes de commandes, top produits, taux d'annulation, temps de livraison moyen, ventilation par source/service_mode | --- -## 12. Domain 10 — Back-office authentication +## 12. Domaine 10 — Authentification back-office ### 12.1 AUTHENTICATE_USER -**Corresponds to MCT section 12.1** +**Correspond a la section 12.1 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Login form submitted with email and password | -| **[PRE-2]** | CSRF token of the form is valid (anti-CSRF protection) | -| **[RG-1]** | Lookup: `SELECT * FROM user WHERE email = :email AND is_active = 1 LIMIT 1` | -| **[RG-2]** | Password verification: `password_verify($password, $user->password_hash)`. On failure: same generic error whether the email does not exist or the password is wrong (protection against email enumeration). | -| **[RG-3]** | On success: `session_regenerate(true)` (session ID regeneration, protection against session fixation) | -| **[RG-4]** | Session storage: `$_SESSION['user_id']`, `$_SESSION['role_id']`, `$_SESSION['logged_in_at']` | -| **[RG-5]** | UPDATE: `UPDATE user SET last_login_at = NOW() WHERE id = :id` | -| **[RG-6]** | Session timeouts: idle timeout 4h (detection via last-activity timestamp in session); absolute timeout 10h (detection via `logged_in_at`) | -| **[RG-7]** | Redirect target is `role.default_route` (dynamic; no hardcoded role name in routing logic) | -| **[POST-1]** | PHP session open with `user_id` and `role_id`; `user.last_login_at` updated | -| **[OUT-1]** | Redirect to `role.default_route` | -| **[ERR-1]** | Incorrect credentials or inactive account: generic message "Email or password incorrect" (no distinction to prevent enumeration) | -| **[ERR-2]** | Invalid CSRF token: HTTP 403 | +| **[PRE-1]** | Formulaire de connexion soumis avec email et mot de passe | +| **[PRE-2]** | Le token CSRF du formulaire est valide (protection anti-CSRF) | +| **[PRE-3 — throttle gate]** | Si le compte est dans une fenetre de throttling (`user.lockout_until IS NOT NULL AND lockout_until > NOW()`), rejeter avec l'erreur generique avant toute verification de mot de passe. Le throttling est aussi cle par IP source via la table `login_throttle` : si une ligne existe pour l'IP source avec `lockout_until IS NOT NULL AND lockout_until > NOW()`, rejeter avec la meme erreur generique, de sorte que les tentatives distribuees sur de nombreux comptes sont ralenties elles aussi. | +| **[RG-1]** | Recherche : `SELECT * FROM user WHERE email = :email AND is_active = 1 LIMIT 1` | +| **[RG-2]** | Verification du mot de passe : `password_verify($password, $user->password_hash)`. En cas d'echec : meme erreur generique que l'email n'existe pas ou que le mot de passe soit faux (protection contre l'enumeration d'emails). Pour garder un timing comparable lorsque l'email est inconnu, un `password_verify` factice contre un hash leurre fixe est execute. | +| **[RG-3]** | En cas de succes : `session_regenerate(true)` (regeneration de l'ID de session, protection contre la fixation de session) | +| **[RG-4]** | Stockage de session : `$_SESSION['user_id']`, `$_SESSION['role_id']`, `$_SESSION['logged_in_at']` | +| **[RG-5]** | UPDATE : `UPDATE user SET last_login_at = NOW() WHERE id = :id` | +| **[RG-6]** | Timeouts de session : timeout d'inactivite 4h (detection via timestamp de derniere activite en session) ; timeout absolu 10h (detection via `logged_in_at`) | +| **[RG-7]** | La cible de redirection est `role.default_route` (dynamique ; aucun nom de role en dur dans la logique de routage) | +| **[RG-8 — failure handling, degressive backoff]** | A une verification echouee, le compteur par compte sur `user` : `UPDATE user SET failed_login_attempts = failed_login_attempts + 1, last_failed_login_at = NOW()`, et une fois un seuil atteint (suggestion : 5) definir `lockout_until = NOW() + INTERVAL (base * 2^(attempts - threshold)) SECOND`, plafonne (suggestion : plafond de quelques minutes). Dans la meme etape, la dimension par IP est enregistree dans la table `login_throttle` : upsert de la ligne cle sur `ip_address` (insert si absente, sinon incrementer `failed_attempts` ; reinitialiser la fenetre quand elle a expire via `window_started_at`), mettre a jour `last_attempt_at = NOW()`, et une fois le seuil IP atteint definir `lockout_until` avec le meme backoff degressif. C'est un backoff degressif, pas un verrouillage indefini — il ralentit la force brute sans laisser une serie de fautes de frappe priver de service une cuisine en plein rush. Ecrire une ligne `audit_log` (`action_code='auth.login_failed'`, `actor_user_id` si l'email a ete resolu, sinon NULL). | +| **[RG-9 — success reset]** | En cas de succes, reinitialiser le compteur par compte `failed_login_attempts = 0`, effacer `lockout_until = NULL`, et effacer aussi la ligne `login_throttle` par IP pour l'IP source (reinitialiser `failed_attempts = 0`, `lockout_until = NULL`, redemarrer `window_started_at`), puis ecrire une ligne `audit_log` (`action_code='auth.login_success'`, `actor_user_id`, `actor_role_id`). | +| **[POST-1]** | Session PHP ouverte avec `user_id` et `role_id` ; `user.last_login_at` mis a jour ; `failed_login_attempts` reinitialise | +| **[OUT-1]** | Redirection vers `role.default_route` | +| **[ERR-1]** | Identifiants incorrects ou compte inactif : message generique "Email ou mot de passe incorrect" (aucune distinction pour eviter l'enumeration) ; compteur d'echec incremente (RG-8) | +| **[ERR-2]** | Token CSRF invalide : HTTP 403 | +| **[ERR-3]** | Compte dans une fenetre de throttling (PRE-3) : meme message generique ; la tentative ne revele pas que le compte existe ou est verrouille | --- ### 12.2 LOGOUT_USER -**Corresponds to MCT section 12.2** +**Correspond a la section 12.2 du MCT** -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[PRE-1]** | Valid session open (`session_id()` non-empty, `$_SESSION['user_id']` present) | -| **[RG-1]** | `$_SESSION = []` (clear session data) | -| **[RG-2]** | If session cookie exists, expire it: `setcookie(session_name(), '', time() - 3600, '/', '', true, true)` | +| **[PRE-1]** | Session valide ouverte (`session_id()` non vide, `$_SESSION['user_id']` present) | +| **[RG-1]** | `$_SESSION = []` (effacer les donnees de session) | +| **[RG-2]** | Si un cookie de session existe, l'expirer : `setcookie(session_name(), '', time() - 3600, '/', '', true, true)` | | **[RG-3]** | `session_destroy()` | -| **[POST-1]** | PHP session destroyed; no authenticated access possible with the old cookie | -| **[OUT-1]** | Redirect to login page | +| **[POST-1]** | Session PHP detruite ; aucun acces authentifie possible avec l'ancien cookie | +| **[OUT-1]** | Redirection vers la page de connexion | --- -## 13. Automated treatments — Crons (outside user interactions) +### 12.3 RESET_PASSWORD -These treatments are executed by the `wakdo-cron` service container in the maintenance -window 01:30-09:30 (outside active service). They are outside the MCT scope (technical -treatments, no user trigger) but are documented here for consistency with PROJECT_CONTEXT. +**Operation security-by-design (pas de predecesseur v0.1). Deux phases : demande, puis confirmation.** -### 13.1 Stats aggregation (cron 04:30) - -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[TRIGGER]** | Cron: `30 4 * * *` | -| **[RG-1]** | `service_day` to aggregate: computed per order (see RG-1 of READ_STATS). At 04:30 the service_day in progress is the previous calendar day. | -| **[RG-2]** | Aggregations by `service_day`: order count, TTC revenue (sum `total_ttc_cents` where `status != 'cancelled'`), top products (by `label_snapshot`, COUNT in `order_item`) | -| **[POST-1]** | Stats available for admin dashboard (direct queries on `customer_order` filtered by `service_day`, or an aggregation table if implemented) | +| **[PRE-1]** | Phase de demande : un `user` soumet le formulaire "mot de passe oublie" avec un email ; token CSRF valide | +| **[RG-1 — request, enumeration-safe]** | Rechercher l'email. La meme reponse neutre ("si le compte existe, un email a ete envoye") est retournee que l'email existe ou non, pour eviter l'enumeration de compte. | +| **[RG-2 — token generation]** | Si l'email resout vers un utilisateur actif : generer un token aleatoire cryptographique (ex. 32 octets depuis un CSPRNG) ; stocker son **hash** dans `password_reset_token_hash` et `password_reset_expires_at = NOW() + INTERVAL 1 HOUR`. Le token **brut** est envoye une seule fois dans le lien de reinitialisation (pas stocke en clair). | +| **[PRE-2]** | Phase de confirmation : l'utilisateur ouvre le lien de reinitialisation avec le token brut et soumet un nouveau mot de passe ; token CSRF valide | +| **[RG-3 — confirm]** | Hacher le token soumis et le comparer a `password_reset_token_hash` ou `password_reset_expires_at > NOW()`. En cas de correspondance : `password_hash = password_hash($new, PASSWORD_ARGON2ID)` (longueur min 8), puis effacer `password_reset_token_hash = NULL` et `password_reset_expires_at = NULL`, et reinitialiser `failed_login_attempts = 0`, `lockout_until = NULL`. Usage unique. | +| **[RG-4 — audit]** | Ecrire une ligne `audit_log` (RG-T14), `action_code='auth.password_reset'`, `actor_user_id = :id`. | +| **[POST-1]** | Mot de passe remplace par un nouveau hash argon2id ; token de reinitialisation consomme et efface | +| **[OUT-1]** | Confirmation ; redirection vers la connexion | +| **[ERR-1]** | Token invalide ou expire : message generique invitant a une nouvelle demande de reinitialisation (aucun detail sur la condition qui a echoue) | -### 13.2 Expired sessions purge (cron every 15 min) +Ces traitements sont executes par le conteneur de service `wakdo-cron` dans la fenetre de +maintenance 01:30-09:30 (hors service actif). Ils sont hors du perimetre du MCT (traitements +techniques, pas de declencheur utilisateur) mais sont documentes ici par coherence avec PROJECT_CONTEXT. -| Tag | Content | +### 13.1 Agregation des stats (cron 04:30) + +| Marqueur | Contenu | |-----|---------| -| **[TRIGGER]** | Cron: `*/15 * * * *` | -| **[RG-1]** | File-based sessions (default): `find /tmp/sessions -mmin +240 -delete` | -| **[RG-2]** | DB-based sessions (option): `DELETE FROM php_sessions WHERE updated_at < NOW() - INTERVAL 4 HOUR` | -| **[POST-1]** | Expired sessions deleted; users inactive for more than 4h are forced to re-login | +| **[TRIGGER]** | Cron : `30 4 * * *` | +| **[RG-1]** | `service_day` a agreger : calcule par commande (voir RG-1 de READ_STATS). A 04:30 le service_day en cours est le jour calendaire precedent. | +| **[RG-2]** | Agregations par `service_day` : nombre de commandes, chiffre d'affaires TTC (somme `total_ttc_cents` ou `status != 'cancelled'`), top produits (par `label_snapshot`, COUNT dans `order_item`) | +| **[POST-1]** | Stats disponibles pour le tableau de bord admin (requetes directes sur `customer_order` filtrees par `service_day`, ou une table d'agregation si implementee) | -### 13.3 DB backup (cron 03:00) +### 13.2 Purge des sessions expirees (cron toutes les 15 min) -| Tag | Content | +| Marqueur | Contenu | |-----|---------| -| **[TRIGGER]** | Cron: `0 3 * * *` | -| **[RG-1]** | `mysqldump` of the `wakdo` database to a dated file in the backup volume | -| **[RG-2]** | Retention: keep the last 7 dumps; delete older ones | -| **[POST-1]** | SQL dump available for restoration | +| **[TRIGGER]** | Cron : `*/15 * * * *` | +| **[RG-1]** | Sessions basees fichier (par defaut) : `find /tmp/sessions -mmin +240 -delete` | +| **[RG-2]** | Sessions basees DB (option) : `DELETE FROM php_sessions WHERE updated_at < NOW() - INTERVAL 4 HOUR` | +| **[POST-1]** | Sessions expirees supprimees ; les utilisateurs inactifs depuis plus de 4h sont forces de se reconnecter | + +### 13.3 Sauvegarde DB (cron 03:00) + +| Marqueur | Contenu | +|-----|---------| +| **[TRIGGER]** | Cron : `0 3 * * *` | +| **[RG-1]** | `mysqldump` de la base `wakdo` vers un fichier date dans le volume de sauvegarde | +| **[RG-2]** | Retention : garder les 7 derniers dumps ; supprimer les plus anciens | +| **[POST-1]** | Dump SQL disponible pour restauration | + +### 13.4 Purge de retention du journal d'audit (cron quotidien) + +| Marqueur | Contenu | +|-----|---------| +| **[TRIGGER]** | Cron : `15 4 * * *` (fenetre de maintenance) | +| **[RG-1]** | `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL :retention_months MONTH` (suggestion : 12 mois, interet legitime / tracabilite fiscale — configurable dans `.env`). | +| **[RG-2]** | La fenetre est decouplee du cycle de vie des PII utilisateur : l'anonymisation (10.5) retire les PII immediatement sur demande, tandis que la trace d'audit vieillit selon son propre calendrier (note 13 du dict.). | +| **[POST-1]** | Lignes `audit_log` plus anciennes que la fenetre de retention retirees ; imputabilite recente preservee. | + +### 13.5 Purge de login_throttle (cron quotidien) + +| Marqueur | Contenu | +|-----|---------| +| **[TRIGGER]** | Cron : `45 4 * * *` (fenetre de maintenance) | +| **[RG-1]** | `DELETE FROM login_throttle WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL 24 HOUR` — purger les lignes sans verrouillage actif dont la derniere tentative echouee est plus ancienne que 24h. | +| **[RG-2]** | Les lignes servant encore un verrouillage actif sont conservees ; le compteur par IP (S1) est borne par cette purge de sorte que la table ne croit pas de maniere illimitee a cause de tentatives ponctuelles. | +| **[POST-1]** | Lignes `login_throttle` obsoletes retirees ; throttles actifs et activite recente preserves. | + +La meme purge s'applique a `pin_throttle` (RG-T22), avec le meme predicat et le meme +seuil `THROTTLE_PURGE_AFTER_HOURS` : +`DELETE FROM pin_throttle WHERE (lockout_until IS NULL OR lockout_until < NOW()) AND last_attempt_at < NOW() - INTERVAL 24 HOUR`. --- -## 14. State machine — consistency recap (MLT) +## 14. Machine a etats — recapitulatif de coherence (MLT) -Summary of `customer_order.status` transitions covered in the MLT, with corresponding -operations, SQL condition, concurrency protection, and phase timestamp set. +Recapitulatif des transitions de `customer_order.status` couvertes dans le MLT, avec les operations +correspondantes, la condition SQL, la protection de concurrence et le timestamp de phase defini. -| Transition | MLT operation | SQL condition | Concurrency protection | Phase timestamp set | +| Transition | Operation MLT | Condition SQL | Protection concurrence | Timestamp de phase pose | |------------|--------------|---------------|------------------------|---------------------| -| `-> pending_payment` (creation) | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | INSERT with status `pending_payment` | Atomic transaction | `created_at` | -| `pending_payment -> paid` | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | UPDATE in same transaction | Atomic transaction | `paid_at` | -| `paid -> delivered` | DELIVER_ORDER (6.1) | `WHERE status = 'paid'` | AND status in WHERE | `delivered_at` | -| `pending_payment/paid -> cancelled` | CANCEL_ORDER (7.1) | `WHERE status IN ('pending_payment', 'paid')` | AND status IN WHERE | `cancelled_at` | +| `-> pending_payment` (creation) | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | INSERT avec statut `pending_payment` | Transaction atomique | `created_at` | +| `pending_payment -> paid` | CREATE_ORDER (3.3), CREATE_COUNTER_ORDER (4.1) | UPDATE dans la meme transaction | Transaction atomique | `paid_at` | +| `paid -> delivered` | DELIVER_ORDER (6.1) | `WHERE status = 'paid'` | status dans le WHERE (clause AND) | `delivered_at` | +| `pending_payment/paid -> cancelled` | CANCEL_ORDER (7.1) | `WHERE status IN ('pending_payment', 'paid')` | status dans le WHERE (clause AND) | `cancelled_at` | -Terminal statuses (no further transition defined from these states): `delivered`, `cancelled`. +Statuts terminaux (aucune transition ulterieure definie depuis ces etats) : `delivered`, `cancelled`. -**Dropped from v0.1**: -- `paid -> preparing` and `preparing -> ready` transitions — intermediate states removed. -- MARQUER_EN_PREPARATION (v0.1 MLT section 4.2) — dropped. -- MARQUER_PRETE (v0.1 MLT section 4.3) — dropped. -- `preparing` and `ready` in the cancellable state set — the cancellable set is now - `['pending_payment', 'paid']` only. -- `commande_event` table and v0.1 RG-T10 — replaced by phase timestamps on `customer_order`. +**Abandonnes depuis v0.1** : +- Transitions `paid -> preparing` et `preparing -> ready` — etats intermediaires retires. +- MARQUER_EN_PREPARATION (section 4.2 du MLT v0.1) — abandonnee. +- MARQUER_PRETE (section 4.3 du MLT v0.1) — abandonnee. +- `preparing` et `ready` dans l'ensemble des etats annulables — l'ensemble annulable est desormais + `['pending_payment', 'paid']` uniquement. +- Table `commande_event` et RG-T10 v0.1 — remplacees par les timestamps de phase sur `customer_order`. --- -## 15. Residual notes and open points +## 15. Notes residuelles et points ouverts -### 15.1 `service_day` — not materialised as a column +### 15.1 `service_day` — non materialise comme colonne -The `service_day` computation is documented (RG-2 of CREATE_ORDER, RG-1 of READ_STATS): +Le calcul de `service_day` est documente (RG-2 de CREATE_ORDER, RG-1 de READ_STATS) : `CASE WHEN HOUR(created_at) < 10 THEN DATE(created_at) - INTERVAL 1 DAY ELSE DATE(created_at) END` -(cutoff 10:00). It is computed at query time, not stored. For high-frequency stats queries, -a MariaDB generated column `VIRTUAL` or `STORED` could be added at DDL time to avoid -per-row recomputation, but this is not a blocker for the RNCP scope. -The v0.1 formula with `INTERVAL 4 HOUR 30 MINUTE` was incorrect and is dropped. +(coupure 10:00). Il est calcule a l'execution de la requete, pas stocke. Pour les requetes de stats +a haute frequence, une colonne generee MariaDB `VIRTUAL` ou `STORED` pourrait etre ajoutee au moment du DDL pour eviter +un recalcul par ligne, mais ce n'est pas un bloquant pour le perimetre RNCP. +La formule v0.1 avec `INTERVAL 4 HOUR 30 MINUTE` etait incorrecte et est abandonnee. -### 15.2 `order_item_modifier` for menu items +### 15.2 `order_item_modifier` pour les articles menu -For a menu line (`item_type='menu'`), modifiers target the fixed burger identified via -`order_item.menu_id -> menu.burger_product_id`. The constraint that modifiers reference -only ingredients belonging to the burger's `product_ingredient` is enforced at the -application layer, not at the DB FK layer (see dictionary note 10). This is a known -trade-off: a multi-column FK or a DB trigger would be needed to enforce it at DB level. -Documenting it as an application invariant is the retained approach for this project scope. +Pour une ligne de menu (`item_type='menu'`), les modificateurs ciblent le burger fixe identifie via +`order_item.menu_id -> menu.burger_product_id`. La contrainte que les modificateurs ne referencent +que des ingredients appartenant au `product_ingredient` du burger est appliquee a la +couche applicative, pas a la couche FK de la DB (voir note 10 du dictionnaire). C'est un +compromis connu : une FK multi-colonnes ou un trigger DB serait necessaire pour l'appliquer au niveau DB. +Le documenter comme un invariant applicatif est l'approche retenue pour le perimetre de ce projet. -### 15.3 Order number NNN counter — concurrency +### 15.3 Compteur NNN de numero de commande — concurrence -The sequential NNN counter per `(source, service_day)` could produce duplicates under -high concurrency if implemented naively as `SELECT COUNT + 1`. The recommended -implementation at DDL/code time is either: (a) a table-level advisory lock around the -count-and-insert sequence; or (b) a dedicated sequence table with an atomic increment. -The UNIQUE constraint on `order_number` provides the last-resort guard (INSERT would fail -and the application retries). This is not a blocker for the RNCP demo volume. +Le compteur sequentiel NNN par `(source, service_day)` pourrait produire des doublons sous +forte concurrence s'il est implemente naivement comme `SELECT COUNT + 1`. L'implementation +recommandee au moment du DDL/code est soit : (a) un verrou consultatif au niveau table autour de la +sequence count-and-insert ; soit (b) une table de sequence dediee avec un increment atomique. +La contrainte UNIQUE sur `order_number` fournit le garde-fou de dernier recours (l'INSERT echouerait +et l'application reessaie). Ce n'est pas un bloquant pour le volume de la demo RNCP. diff --git a/docs/uml/security-sequence.md b/docs/uml/security-sequence.md new file mode 100644 index 0000000..1d39c69 --- /dev/null +++ b/docs/uml/security-sequence.md @@ -0,0 +1,232 @@ +# Diagramme de sequence securite - Annulation de commande avec PIN (CANCEL_ORDER) + +**Phase UML** : P1 - Conception, complement UML (passe security-by-design) +**Statut** : v0.2 - flux sensible PIN-gate + audit_log (controle anti-fraude interne) +**Date** : 2026-06-12 +**Branche** : `feat/p1-conception` +**Auteur methodologie** : BYAN + +--- + +## 1. Objet du document + +Ce document decrit le **flux temporel securise** de l'annulation d'une commande +en back-office (`CANCEL_ORDER`). L'annulation est une action de **manipulation +d'argent** : annuler une commande deja `paid` peut servir a masquer un +detournement d'especes (l'equipier encaisse, annule, garde le cash). Le flux +ci-dessous materialise le controle qui adresse ce risque : une **re-authentification +par PIN par equipier** (`RG-T13`) avant l'execution, et l'ecriture d'une ligne +**`audit_log` immuable** dans la meme transaction que l'effet (`RG-T14`), de sorte +que chaque annulation est rattachee a une personne meme sur un poste partage. + +Le diagramme reste au niveau conceptuel / logique. Il nomme les echanges entre +participants sans detailler l'implementation PHP ni le SQL exact. Il complete +l'operation `CANCEL_ORDER` du `docs/merise/mlt.md` (7.1), la transition T5 de +`docs/uml/state-commande.md` (`paid -> cancelled`, re-credit du stock) et le cas +d'utilisation "Annuler une commande" de `docs/uml/use-cases.md` (UC13). + +**Sources** : +- `docs/merise/mlt.md` 7.1 `CANCEL_ORDER` (PRE-1..PRE-3, RG-1..RG-6, POST, ERR) +- `docs/merise/mlt.md` section 2 : `RG-T13` (PIN sensible), `RG-T14` (audit_log), `RG-T11` (re-credit dans la meme transaction), `RG-T07` (garde de concurrence), `RG-T08` (transaction atomique) +- `docs/merise/dictionary.md` 3.14 (`user.pin_hash`, argon2id) et 3.20 (`audit_log`) +- `docs/uml/state-commande.md` T5 (`paid -> cancelled`, `stock_movement` type `cancellation`) + +--- + +## 2. Participants + +| Participant | Role | Couche | +|---|---|---| +| **Equipier** | Counter / Drive / Admin titulaire de `order.cancel`, sur poste partage | Acteur | +| **Admin UI** | Interface back-office (Bloc 3, formulaire d'annulation + saisie PIN) | Presentation | +| **Controleur** | Back-end REST sous `/api/*` (Bloc 2), orchestre la transaction | Application | +| **Verif PIN** | Service de verification du PIN (`password_verify` argon2id) | Application | +| **BDD** | Base de donnees MariaDB | Persistance | + +La session est **partagee par poste de travail** pour le routine 95% ; le PIN +re-introduit une attribution individuelle sur le sous-ensemble sensible +(`mlt.md` `RG-T13`). Le PIN n'est pas une session : il est verifie a chaque action +sensible et sert a capturer le `user_id` qui sera ecrit dans `audit_log`. + +--- + +## 3. Diagramme de sequence + +```mermaid +sequenceDiagram + actor Equipier + participant AdminUI as Admin UI + participant Ctrl as Controleur + participant PIN as Verif PIN + participant BDD + + Note over Equipier,BDD: Phase 1 - Demande et controle de permission + + Equipier->>AdminUI: demander l'annulation d'une commande + AdminUI->>Ctrl: POST /api/orders/{id}/cancel + Ctrl->>Ctrl: verifier session active + is_active = 1 (RG-T02) + Ctrl->>BDD: lire role_permission (permission order.cancel ?) (RG-T03, PRE-1) + BDD-->>Ctrl: permission presente + Ctrl->>BDD: lire customer_order (existe ? statut ?) (PRE-2, PRE-3) + BDD-->>Ctrl: status courant + + alt status hors ['pending_payment','paid'] (delivered ou cancelled) + Ctrl-->>AdminUI: 422 CANNOT_CANCEL_IN_STATE {current_status} + AdminUI-->>Equipier: refus, aucun changement d'etat (ERR-1) + else status annulable + Note over Equipier,BDD: Phase 2 - Re-authentification PIN (RG-T13) + + Ctrl-->>AdminUI: demander la saisie du PIN + Equipier->>AdminUI: saisir le PIN + AdminUI->>Ctrl: soumettre le PIN (re-auth action sensible) + Ctrl->>PIN: verifier le PIN soumis + PIN->>BDD: lire user.pin_hash de l'equipier + BDD-->>PIN: pin_hash (argon2id) + PIN->>PIN: password_verify(pin, pin_hash) + + alt PIN incorrect + PIN-->>Ctrl: echec + Ctrl-->>AdminUI: refus du PIN, action rejetee + AdminUI-->>Equipier: PIN incorrect, aucun changement d'etat + else PIN correct + PIN-->>Ctrl: succes, acting user_id capture (RG-T13) + + Note over Equipier,BDD: Phase 3 - Transaction atomique (RG-T08, RG-T11) + + Ctrl->>BDD: BEGIN transaction + Ctrl->>BDD: UPDATE customer_order SET status = 'cancelled', cancelled_at = NOW() WHERE id = :id AND status IN ('pending_payment','paid') (RG-1, RG-T07) + BDD-->>Ctrl: lignes affectees + + alt 0 ligne affectee (annulation concurrente) + Ctrl->>BDD: ROLLBACK + Ctrl-->>AdminUI: 409 INVALID_TRANSITION (ERR-2) + AdminUI-->>Equipier: deja annulee par un autre poste + else 1 ligne affectee + opt statut anterieur = paid (re-credit du stock) + Ctrl->>BDD: UPDATE ingredient SET stock_quantity = stock_quantity + :units (par ingredient consomme) (RG-3) + Ctrl->>BDD: INSERT stock_movement (type cancellation, delta +units, order_id, user_id de l'equipier) (RG-3) + end + Ctrl->>BDD: INSERT audit_log (action_code order.cancel, actor_user_id, actor_role_id, entity_type customer_order, entity_id, summary [statut anterieur + montant re-credite]) (RG-6, RG-T14) + Ctrl->>BDD: COMMIT + BDD-->>Ctrl: transaction validee + Ctrl-->>AdminUI: 200 annulation confirmee (OUT-1) + AdminUI-->>Equipier: commande annulee, trace enregistree + end + end + end +``` + +--- + +## 4. Notes de modelisation : chaque pas et sa regle + +Le tableau ci-dessous mappe chaque interaction du diagramme a la regle +`mlt.md` 7.1 ou a la regle transverse correspondante, et a l'entite ecrite. + +| # | Interaction | Regle (mlt.md) | Entite ecrite / lue | +|---|---|---|---| +| 1 | Verifier session active + `is_active = 1` | `RG-T02` | `user` (lecture) | +| 2 | Verifier `order.cancel` via `role_permission` | `RG-T03`, 7.1 PRE-1 | `role_permission` (lecture) | +| 3 | Charger la commande et lire son `status` | 7.1 PRE-2, PRE-3 | `customer_order` (lecture) | +| 4 | Bloquer si `status` est `delivered` ou `cancelled` | 7.1 ERR-1 | aucune ecriture (HTTP 422) | +| 5 | Demander + verifier le PIN (`password_verify` argon2id) | `RG-T13`, 7.1 RG-6 | `user.pin_hash` (lecture) | +| 6 | Rejeter si PIN incorrect, sans changement d'etat | `RG-T13` | aucune ecriture | +| 7 | Capturer l'`acting user_id` pour l'audit | `RG-T13` | (en memoire, sert aux pas 11-12) | +| 8 | `BEGIN` transaction | `RG-T08` | transaction | +| 9 | `UPDATE customer_order SET status='cancelled'` avec garde `AND status IN (...)` | 7.1 RG-1, `RG-T07` | `customer_order` (ecriture) | +| 10 | `ROLLBACK` + 409 si 0 ligne affectee (concurrence) | 7.1 ERR-2, `RG-T07` | aucune ecriture nette | +| 11 | Re-credit conditionnel du stock si statut anterieur `paid` | 7.1 RG-3, `RG-T11` | `ingredient`, `stock_movement` (type `cancellation`) | +| 12 | `INSERT audit_log` dans la meme transaction | 7.1 RG-6, `RG-T14` | `audit_log` (ecriture) | +| 13 | `COMMIT` (tout ou rien) | `RG-T08`, `RG-T11` | transaction | +| 14 | Reponse 200 de confirmation | 7.1 OUT-1 | aucune ecriture | + +### 4.1 Re-credit conditionnel du stock (`RG-T11`) + +Le re-credit ne s'applique que si la commande etait au statut `paid` avant +l'annulation (7.1 RG-3). Une commande `pending_payment` n'avait pas encore +decremente le stock (le decrement a lieu a la transition `paid`), donc il n'y a +rien a re-crediter. Pour chaque `order_item` d'une commande `paid`, les unites +consommees sont recalculees (format `normal`/`maxi`, ajustees par les +`order_item_modifier`), `ingredient.stock_quantity` est re-incremente et un +`stock_movement` de type `cancellation` est insere. `RG-T11` garantit que ce +re-credit et l'`UPDATE` du statut sont dans la **meme transaction** : il n'y a pas +de decrement orphelin si une etape echoue. + +### 4.2 Garde de concurrence (`RG-T07`) + +L'`UPDATE` porte la clause `AND status IN ('pending_payment','paid')`. Si deux +postes tentent d'annuler la meme commande au meme instant, seul le premier +obtient une ligne affectee ; le second recoit 0 ligne et le controleur repond +409 `INVALID_TRANSITION` apres `ROLLBACK` (7.1 ERR-2). Cette garde optimiste +reduit le risque d'une double annulation et d'un double re-credit du stock. + +### 4.3 PIN distinct de la session (`RG-T13`) + +La session reste **partagee par poste** pour le flux routine. Le PIN est verifie +a chaque action du sous-ensemble sensible (annulation, prix/VAT, RBAC, gestion +utilisateur, correction d'inventaire), et c'est lui qui fournit l'`actor_user_id` +ecrit dans `audit_log`. Le `pin_hash` est un hash argon2id (`dictionary.md` 3.14), +compare via `password_verify` ; il fait partie des champs RESTRICTED tenus hors +des logs et des reponses API. + +--- + +## 5. Menace adressee : repudiation et detournement d'especes + +L'annulation d'une commande `paid` est le geste qui permet le schema de fraude +"encaisser puis annuler pour garder le cash" (insider cash-skim). Sans controle, +sur un poste a session partagee, une annulation ne serait rattachee a personne : +l'auteur pourrait nier l'avoir faite (repudiation). Le flux ci-dessus reduit le +risque de ce schema en combinant deux mecanismes concrets : + +- **PIN par equipier (`RG-T13`)** : l'annulation exige une re-authentification + individuelle. Sur un poste partage, cela rattache l'acte a une personne et non + au seul poste. Le PIN tend a dissuader l'usage opportuniste d'une session + laissee ouverte par un collegue. +- **`audit_log` immuable (`RG-T14`)** : chaque annulation ecrit une ligne + `audit_log` (`action_code = order.cancel`, `actor_user_id`, `actor_role_id`, + `entity_type`, `entity_id`, `summary` avec le statut anterieur et le montant + re-credite) dans la **meme transaction** que l'`UPDATE` du statut. La table + n'accepte ni `UPDATE` ni `DELETE` au niveau applicatif (`dictionary.md` 3.20). + Une annulation ne peut donc pas exister sans sa trace, et la trace ne peut pas + etre effacee par l'auteur. + +L'effet combine : un pic d'annulations rattachees a un meme `actor_user_id` +devient visible et opposable lors d'une revue. Ceci ne supprime pas le risque, +mais le **reduit** en transformant un acte anonyme et niable en un acte attribue +et trace. La residualite (collusion, partage de PIN) releve de controles +organisationnels hors du modele de donnees. + +> Note : `audit_log` enregistre des **noms de champs** et un `summary` +> non-personnel (`details` stocke les noms de champs modifies, pas de PII), +> conformement a `RG-T14` et a la classification de `PROJECT_CONTEXT.md` section 19. +> L'attribution `stock_movement.user_id` du re-credit complete la trace cote stock +> sans double journalisation. + +--- + +## 6. Coherence avec les autres livrables + +| Verification | Resultat | +|---|---| +| Statuts annulables coherents avec `state-commande.md` | Oui : `pending_payment` et `paid` (T3, T5) ; `delivered` non annulable (7.1 ERR-1) | +| Transition `paid -> cancelled` avec re-credit | Couverte par T5 et 7.1 RG-3 (`stock_movement` type `cancellation`) | +| Entites ecrites presentes au dictionnaire | `customer_order` (3.10), `ingredient`, `stock_movement`, `audit_log` (3.20) | +| Regle PIN appliquee | `RG-T13` (sous-ensemble sensible inclut 7.1) ; `user.pin_hash` (3.14) | +| Regle audit appliquee | `RG-T14` ; colonnes conformes a `audit_log` (3.20) | +| Atomicite re-credit + statut + audit | `RG-T08` + `RG-T11` (une transaction, `COMMIT` / `ROLLBACK`) | +| Codes d'erreur | 422 `CANNOT_CANCEL_IN_STATE` (ERR-1), 409 `INVALID_TRANSITION` (ERR-2) | + +--- + +## 7. Arbitrage tranche + +Le flux retient la re-authentification **par PIN** plutot qu'une re-saisie du mot +de passe complet : le PIN couvre le sous-ensemble sensible sans casser le routine +95% a session partagee (`RG-T13`), tout en fournissant l'attribution +individuelle. L'`audit_log` est ecrit dans la **meme transaction** que l'effet +(`RG-T14` + `RG-T08`) : une annulation sans trace ne peut pas etre committee. Le +re-credit du stock est conditionnel au statut anterieur `paid` (`RG-T11`), ce qui +ecarte un re-credit indu sur une commande `pending_payment` qui n'a pas ete +decrementee. Les codes d'erreur (`CANNOT_CANCEL_IN_STATE`, `INVALID_TRANSITION`) +reprennent ceux de `mlt.md` 7.1, sans en inventer de nouveaux. diff --git a/docs/uml/sequence-passer-commande.md b/docs/uml/sequence-passer-commande.md index 3b00cfe..9ffd0a8 100644 --- a/docs/uml/sequence-passer-commande.md +++ b/docs/uml/sequence-passer-commande.md @@ -1,8 +1,8 @@ # Diagramme de sequence - Passer une commande (borne client) **Phase UML** : P1 - Conception, complement UML (apres MCD) -**Statut** : v0.1 -**Date** : 2026-05-21 +**Statut** : v0.2 - prod-like, creation atomique (create + pay) +**Date** : 2026-06-11 **Branche** : `feat/p1-conception` **Auteur methodologie** : BYAN @@ -12,18 +12,24 @@ Ce document decrit le **flux temporel** du parcours "passer une commande" cote **Client sur la borne kiosk** : navigation dans les categories, selection d'un -produit ou composition d'un menu, gestion du panier, validation avec saisie du -numero de retrait, paiement, puis confirmation. +produit ou composition d'un menu (slots + format Normal/Maxi + modifiers +d'ingredients), gestion du panier, validation avec saisie du numero de retrait, +et confirmation. Dans le modele v0.2, la **creation et le paiement sont +atomiques** : un seul appel `POST /api/orders` cree la commande, la fait passer a +`paid`, decremente le stock et journalise les mouvements, dans une meme +transaction. -Le diagramme reste au niveau **conceptuel / logique**. Il nomme les echanges -entre participants sans detailler l'implementation PHP (controllers, models) -ni le SQL exact. Il complete le cas d'utilisation "Passer une commande" de -`docs/uml/use-cases.md` et la machine a etats de `docs/uml/state-commande.md`. +Le diagramme reste au niveau conceptuel / logique. Il nomme les echanges entre +participants sans detailler l'implementation PHP ni le SQL exact. Il complete le +cas d'utilisation "Passer une commande" de `docs/uml/use-cases.md` (4.1), la +machine a etats de `docs/uml/state-commande.md` (T1/T2) et l'operation +`CREATE_ORDER` du `docs/merise/mct.md` (3.3). **Sources** : - `docs/PROJECT_CONTEXT.md` section 2 (processus metier), section 7 (endpoints API) -- `docs/merise/dictionary.md` (`commande`, `ligne_commande`, `menu`, `produit`) -- `docs/uml/state-commande.md` (transitions `pending_payment -> paid`) +- `docs/merise/dictionary.md` 3.10-3.13 (`customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`) +- `docs/merise/mct.md` 3.3 CREATE_ORDER (transaction, snapshots, decrement stock) +- `docs/uml/state-commande.md` (transitions T1/T2, atomicite `pending_payment -> paid`) --- @@ -71,16 +77,18 @@ sequenceDiagram alt Produit a la carte Client->>Borne: selectionner un produit - Client->>Borne: regler taille / options + opt Personnaliser les ingredients + Client->>Borne: retirer / ajouter un ingredient + end Borne->>Borne: ajouter la ligne au panier local else Composition d'un menu Client->>Borne: selectionner un menu - Borne->>API: GET /api/menus (composition du menu) - API->>BDD: lire menu et composition - BDD-->>API: menu + produits par role + Borne->>API: GET /api/menus (slots + options eligibles) + API->>BDD: lire menu, menu_slot, menu_slot_option + BDD-->>API: menu + slots + options API-->>Borne: composition (JSON) - Borne-->>Client: afficher les choix par slot (burger, accompagnement, boisson, sauce) - Client->>Borne: choisir chaque composant + tailles + Borne-->>Client: afficher les slots (boisson, accompagnement, sauce) + format Normal/Maxi + Client->>Borne: choisir chaque slot + format + modifiers du burger Borne->>Borne: ajouter la ligne menu au panier local end @@ -94,36 +102,35 @@ sequenceDiagram Borne-->>Client: panier mis a jour end - Note over Client,BDD: Phase 4 - Validation du panier et saisie du numero + Note over Client,BDD: Phase 4 - Validation, saisie du numero, creation atomique (create + pay) Client->>Borne: valider la commande Client->>Borne: saisir le numero de retrait - Borne->>Borne: valider le panier (au moins 1 ligne) - Borne->>API: POST /api/orders (lignes + mode_consommation + numero) + Borne->>Borne: valider le panier (au moins 1 ligne, numero non vide) + Borne->>API: POST /api/orders (lignes + selections + modifiers + service_mode + numero) - API->>API: recalculer les totaux cote serveur - API->>BDD: creer la commande (statut pending_payment) - API->>BDD: creer les lignes (snapshot libelle + prix) - BDD-->>API: commande persistee {id, numero, statut: pending_payment} - API-->>Borne: 201 Created {id, numero, statut: pending_payment, total} - Borne-->>Client: afficher le total a regler + API->>API: recalculer les totaux cote serveur (HT / TVA / TTC, taux par produit) + API->>BDD: BEGIN transaction + API->>BDD: INSERT customer_order (status pending_payment, source kiosk) + API->>BDD: INSERT order_item (snapshot libelle + prix + vat_rate) + API->>BDD: INSERT order_item_selection (par slot de menu rempli) + API->>BDD: INSERT order_item_modifier (par modification d'ingredient) + API->>BDD: UPDATE ingredient.stock_quantity (decrement, ajuste par modifiers) + API->>BDD: INSERT stock_movement (type sale, par unite consommee) + API->>BDD: UPDATE customer_order status -> paid, paid_at = NOW() + API->>BDD: COMMIT + BDD-->>API: commande persistee {id, order_number, status: paid} - Note over Client,BDD: Phase 5 - Paiement (pending_payment -> paid) + Note over Client,BDD: Phase 5 - Confirmation - Client->>Borne: payer la commande - Borne->>API: POST /api/orders/{id}/pay - API->>BDD: enregistrer le paiement, passer la commande a paid (paye_a) - BDD-->>API: commande mise a jour {id, numero, statut: paid} - - Note over Client,BDD: Phase 6 - Confirmation - - API-->>Borne: 200 OK {id, numero, statut: paid} - Borne-->>Client: ecran de confirmation avec le numero + API-->>Borne: 201 Created {id, order_number, status: paid, total_ttc} + Borne-->>Client: ecran de confirmation avec le numero de retrait Note over Client,BDD: Cas d'erreur - alt Panier vide ou donnees invalides - API-->>Borne: 4xx {error: code, message} + alt Panier vide, produit indisponible ou donnees invalides + API->>BDD: ROLLBACK (si transaction entamee) + API-->>Borne: 4xx {error: {code, message}} Borne-->>Client: message d'erreur, retour au panier end ``` @@ -132,27 +139,31 @@ sequenceDiagram ## 4. Notes de modelisation -### 4.1 Recalcul des totaux cote serveur +### 4.1 Recalcul des totaux cote serveur (controle de securite) La Borne affiche un total **provisoire** calcule localement pour l'experience utilisateur. L'API recalcule les totaux a la reception du `POST /api/orders` a -partir des prix en base, puis fige les snapshots -(`prix_unitaire_ttc_cents_snapshot`, `libelle_snapshot` dans `ligne_commande`, -voir `dictionary.md` 3.6). Le total affiche par le client n'est pas considere -comme la source de verite : ceci limite la falsification du prix cote client. +partir des prix en base (HT, TVA ligne par ligne via `vat_rate`, TTC), puis fige +les snapshots (`unit_price_cents_snapshot`, `vat_rate_snapshot`, +`label_snapshot` sur `order_item`, voir `dictionary.md` 3.11). Le total affiche +par le client n'est pas considere comme la source de verite : ceci limite la +falsification du prix cote client. -### 4.2 Transitions de statut +### 4.2 Creation atomique (create + pay) Le parcours materialise les transitions T1 et T2 de -`docs/uml/state-commande.md`, en deux phases successives conformes a la regle -metier : +`docs/uml/state-commande.md` dans **un seul appel et une seule transaction** : -- `POST /api/orders` cree la commande composee en `pending_payment` (T1). -- `POST /api/orders/{id}/pay` enregistre le paiement et fait passer la commande - a `paid` (T2), avec l'horodatage `paye_a`. +- `POST /api/orders` cree la commande en `pending_payment` (T1) puis la fait + passer a `paid` (T2) avant le `COMMIT`. `paid_at` est renseigne. +- La saisie du numero de retrait tient lieu de paiement (cadre RNCP) ; il n'y a + pas d'appel `POST /api/orders/{id}/pay` separe (supprime par rapport au v0.1). +- Le decrement du stock (`ingredient.stock_quantity`) et la journalisation + (`stock_movement` type `sale`) sont inclus dans la meme transaction que + l'insert de la commande : soit tout reussit, soit tout est annule (`ROLLBACK`). -La separation des deux appels reflete les deux phases du cycle de vie : -composer la commande, puis la payer. +Le statut `pending_payment` n'est donc pas observable en dehors de la +transaction (coherent avec `mct.md` section 13). ### 4.3 Panier local jusqu'a la validation @@ -166,9 +177,20 @@ navigateur peut etre envisage plus tard. `PROJECT_CONTEXT.md` section 4 prevoit un mode de repli ou la Borne lit des fichiers JSON statiques si l'API est indisponible. Ce mode concerne uniquement -les lectures (phases 1 a 2). La validation (phase 4) et le paiement (phase 5) -requierent l'API ; sans elle, la commande n'est ni persistee ni payee. Ce cas -degrade n'est pas detaille dans le diagramme nominal ci-dessus. +les lectures (phases 1 a 2). La validation et la creation (phase 4) requierent +l'API ; sans elle, la commande n'est ni persistee ni payee. Ce cas degrade +n'est pas detaille dans le diagramme nominal ci-dessus. + +### 4.5 Garde-fous securite a venir (passe security-by-design) + +Le flux ci-dessus est la cible **fonctionnelle** v0.2. La passe security-by-design +ajoutera, en complement (append, sans reecrire ce flux), des garde-fous sur le +`POST /api/orders` anonyme : cle d'idempotence (`idempotency_key` UNIQUE pour +dedupliquer les POST rejoues), limitation de debit / anti-spam, et verrou +pessimiste `SELECT ... FOR UPDATE` sur les ingredients pendant le decrement +(anti-oversell multi-bornes). Ces ajouts dependent de decisions encore a +trancher (oversell/idempotence, throttling) et seront documentes dans un artefact +`docs/uml/security-sequence.md` dedie. --- @@ -176,18 +198,21 @@ degrade n'est pas detaille dans le diagramme nominal ci-dessus. | Verification | Resultat | |---|---| -| Endpoints utilises existent dans `PROJECT_CONTEXT.md` section 7 | `GET /api/categories`, `GET /api/products`, `GET /api/menus`, `POST /api/orders` ; `POST /api/orders/{id}/pay` est a confirmer en section 7 du brief | -| Entites manipulees presentes au MCD | Oui : `categorie`, `produit`, `menu`, `menu_produit`, `commande`, `ligne_commande` | -| Statuts utilises coherents avec `state-commande.md` | Oui : `pending_payment` puis `paid` (T1, T2), valeurs ENUM anglaises | -| Format de reponse JSON | Coherent avec `PROJECT_CONTEXT.md` section 7 (`{data, error}`) et la reponse `{id, number, status}` du POST orders | +| Endpoints utilises existent dans `PROJECT_CONTEXT.md` section 7 | `GET /api/categories`, `GET /api/products`, `GET /api/menus`, `POST /api/orders` ; l'appel `POST /api/orders/{id}/pay` du v0.1 est supprime (creation atomique) | +| Entites manipulees presentes au MCD / dictionnaire | Oui : `category`, `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `stock_movement` | +| Statuts utilises coherents avec `state-commande.md` | Oui : `pending_payment` puis `paid` (T1, T2), atomiques | +| Operation MCT correspondante | `mct.md` 3.3 CREATE_ORDER (transaction unique, snapshots, decrement stock, transition atomique) | +| Format de reponse JSON | Coherent avec `PROJECT_CONTEXT.md` section 7 (`{data, error}`) et la reponse `{id, order_number, status, total_ttc}` du POST orders | --- ## 6. Arbitrage tranche -La phase de paiement est integree au flux conformement a la regle metier des -deux phases (composer puis payer). La sequence suit la machine canonique de -`state-commande.md` : creation en `pending_payment` (T1) puis paiement vers -`paid` (T2), avec des valeurs ENUM en anglais. Point a confirmer au MCT : -l'endpoint de paiement (`POST /api/orders/{id}/pay`) doit etre reporte dans la -section 7 du brief s'il n'y figure pas encore. +La phase de paiement separee du v0.1 (`POST /api/orders/{id}/pay`) est supprimee : +la creation et le passage a `paid` sont atomiques dans `POST /api/orders`, +conformement au MCT v0.2 (3.3) et a la regle metier (saisie du numero = substitut +de paiement). Le decrement de stock et la journalisation `stock_movement` sont +inclus dans la meme transaction, garantissant la coherence stock/commande. Les +valeurs ENUM sont en anglais (`pending_payment`, `paid`). Les garde-fous de +securite (idempotence, rate-limit, verrou pessimiste) relevent de la passe +security-by-design et seront ajoutes en complement (section 4.5). diff --git a/docs/uml/state-commande.md b/docs/uml/state-commande.md index a99f309..55c38cd 100644 --- a/docs/uml/state-commande.md +++ b/docs/uml/state-commande.md @@ -1,8 +1,8 @@ # Diagramme d'etats-transitions - Commande **Phase UML** : P1 - Conception, complement UML (apres MCD) -**Statut** : v0.1 -**Date** : 2026-05-21 +**Statut** : v0.2 - prod-like, machine a 4 etats +**Date** : 2026-06-11 **Branche** : `feat/p1-conception` **Auteur methodologie** : BYAN @@ -10,38 +10,41 @@ ## 1. Objet du document -Ce document formalise la **machine a etats** de l'attribut `commande.statut`. +Ce document formalise la **machine a etats** de l'attribut `customer_order.status`. Il decrit les etats possibles d'une commande, les transitions autorisees entre ces etats, les **evenements** qui les declenchent et les **gardes** (conditions) qui les conditionnent. -Il complete le MCD (`docs/merise/mcd.md` section 9, qui esquisse le cycle de -vie) et le dictionnaire (`docs/merise/dictionary.md` 3.5, qui declare l'ENUM). +Il complete le MCD (`docs/merise/mcd.md`, cycle de vie de la commande), le +dictionnaire (`docs/merise/dictionary.md` 3.10, qui declare l'ENUM `status`) et +le MCT (`docs/merise/mct.md` section 13, qui resume les transitions par +operation). --- ## 2. Source de verite et regle metier -La regle metier confirmee fixe deux phases successives dans le cycle de vie -d'une commande : le client **compose** sa commande, **puis** il **paie**. Une -fois payee, la commande entre en preparation. Le paiement fait partie integrante -du cycle. Les valeurs d'etat sont en anglais et alignees sur l'ENUM du -dictionnaire. +Le modele v0.2 (prod-like) reduit la machine a **quatre etats**. La regle metier +distingue la **composition payee** de la **remise** : une commande est creee et +payee en une operation atomique (la saisie du numero de retrait tient lieu de +paiement dans le cadre RNCP), puis elle est remise au client en un geste unique. | Source | Valeurs de statut | |---|---| -| `dictionary.md` 3.5 (ENUM SQL) | `pending_payment`, `paid`, `preparing`, `ready`, `delivered`, `cancelled` | -| Regle metier confirmee | composer -> payer -> preparer -> pret -> remettre | +| `dictionary.md` 3.10 (ENUM SQL) | `pending_payment`, `paid`, `delivered`, `cancelled` | +| `mct.md` section 13 (transitions) | creer+payer -> remettre, annulation depuis tout etat non terminal | -**Machine a etats canonique** : la machine ci-dessous est la seule autorisee. -Elle suit l'ENUM du dictionnaire et la regle metier des deux phases : +> Le dictionnaire (`dictionary.md` 3.10) et la machine ci-dessous partagent la +> meme ENUM a 4 valeurs, ce qui maintient la coherence entre le modele de +> donnees et le modele d'etats (cross-validation, mantra #34). -- `pending_payment` : commande composee, en attente de paiement. -- `paid` : paiement effectue ; la commande peut entrer en file de preparation. - -> Le dictionnaire (`dictionary.md` 3.5) et la machine ci-dessous partagent la -> meme ENUM, ce qui maintient la coherence entre le modele de donnees et le -> modele d'etats (cross-validation, mantra #34). +**Etats supprimes par rapport au v0.1** : `preparing` et `ready`. En contexte +fast-food, l'affichage cuisine (KDS) est un dispositif visuel : l'equipier lit +le ticket et agit. Ces deux etats intermediaires ajoutaient des transitions sans +valeur metier proportionnelle. La cuisine est en **lecture seule** ; la remise +(`DELIVER_ORDER`) est le geste unique qui fait avancer le statut. Le KPI est le +temps total `delivered_at - paid_at` (SLA ~10 min) ; la couleur du KDS est +calculee a l'affichage depuis `now - paid_at`, sans etat stocke supplementaire. --- @@ -49,12 +52,16 @@ Elle suit l'ENUM du dictionnaire et la regle metier des deux phases : | Etat | Valeur ENUM | Signification | Acteur qui declenche l'entree | |---|---|---|---| -| En attente de paiement | `pending_payment` | Commande composee, panier fige, en attente de paiement. | Client (kiosk) ou Accueil (counter/drive) | -| Payee | `paid` | Paiement effectue ; la commande peut entrer en file de preparation. | Client (paiement) ou Accueil | -| En preparation | `preparing` | Prise en charge par la Preparation, en cuisine. | Preparation | -| Prete | `ready` | Preparation terminee, prete au comptoir. | Preparation | -| Livree | `delivered` | Remise effectuee au client. Etat **final**. | Accueil | -| Annulee | `cancelled` | Commande abandonnee ou annulee. Etat **final**. | Client, Accueil ou Administration | +| En attente de paiement | `pending_payment` | Etat initial transitoire : commande composee, en attente de paiement. Non observable hors transaction (voir section 7). | Client (kiosk) ou Counter/Drive (back-office) | +| Payee | `paid` | Paiement effectue ; la commande entre en file de preparation (lecture seule cuisine). | Client (kiosk) ou Counter/Drive | +| Livree | `delivered` | Remise effectuee au client. Etat **final**. | Counter ou Drive | +| Annulee | `cancelled` | Commande abandonnee ou annulee avant remise. Etat **final**. | Counter, Drive ou Admin | + +`pending_payment` est l'etat par defaut a l'INSERT (`dictionary.md` 3.10), mais +la transition vers `paid` est realisee dans la **meme transaction** que la +creation (operations `CREATE_ORDER` / `CREATE_COUNTER_ORDER` du MCT). Il est donc +conserve dans l'ENUM pour la lisibilite du cycle et pour laisser la porte +ouverte a un paiement reel ulterieur sans migration destructive. --- @@ -62,19 +69,13 @@ Elle suit l'ENUM du dictionnaire et la regle metier des deux phases : ```mermaid stateDiagram-v2 - [*] --> pending_payment : creer commande (kiosk / counter / drive) + [*] --> pending_payment : creer la commande (kiosk / counter / drive) - pending_payment --> paid : payer\n[panier contient au moins 1 ligne] - pending_payment --> cancelled : abandonner\n[avant paiement] + pending_payment --> paid : payer\n[atomique dans CREATE_ORDER / CREATE_COUNTER_ORDER\nsaisie du numero = substitut de paiement] + pending_payment --> cancelled : annuler\n[avant remise] - paid --> preparing : prendre en charge\n[acteur Preparation, file triee par heure croissante] - paid --> cancelled : annuler\n[Accueil ou Administration] - - preparing --> ready : declarer preparee\n[acteur Preparation] - preparing --> cancelled : annuler\n[rupture produit / decision Administration] - - ready --> delivered : remettre au client\n[acteur Accueil] - ready --> cancelled : annuler\n[client absent / non recuperee] + paid --> delivered : remettre au client\n[acteur Counter / Drive, geste unique] + paid --> cancelled : annuler\n[Counter / Drive / Admin, re-credit du stock] delivered --> [*] cancelled --> [*] @@ -86,29 +87,29 @@ stateDiagram-v2 | # | De | Vers | Evenement declencheur | Garde (condition) | Acteur | |---|---|---|---|---|---| -| T1 | (initial) | `pending_payment` | Creation de la commande composee | Au moins un item ajoute au panier en cours | Client / Accueil | -| T2 | `pending_payment` | `paid` | Paiement de la commande | La commande contient au moins une `ligne_commande` ; le paiement aboutit | Client / Accueil | -| T3 | `pending_payment` | `cancelled` | Abandon avant paiement | Commande pas encore payee | Client / Accueil | -| T4 | `paid` | `preparing` | Prise en charge en file | La commande est la plus ancienne non traitee (tri par heure de livraison croissante) | Preparation | -| T5 | `paid` | `cancelled` | Annulation avant preparation | Decision operationnelle | Accueil / Administration | -| T6 | `preparing` | `ready` | Declaration "preparee" | Preparation terminee | Preparation | -| T7 | `preparing` | `cancelled` | Annulation pendant preparation | Rupture produit ou decision Administration | Preparation / Administration | -| T8 | `ready` | `delivered` | Remise physique au client | Le client se presente avec le bon numero | Accueil | -| T9 | `ready` | `cancelled` | Annulation apres preparation | Client non present / commande non recuperee | Accueil / Administration | +| T1 | (initial) | `pending_payment` | Creation de la commande composee | Au moins une ligne (`order_item`) ; numero de retrait non vide | Client / Counter / Drive | +| T2 | `pending_payment` | `paid` | Paiement (atomique a la creation) | La commande contient au moins une `order_item` ; decrement du stock et insert dans la meme transaction | Client / Counter / Drive | +| T3 | `pending_payment` | `cancelled` | Abandon avant paiement | Commande pas encore payee | Counter / Drive / Admin | +| T4 | `paid` | `delivered` | Remise physique au client | La commande est `paid` ; l'acteur detient `order.deliver` ; source compatible avec son role (`role_visible_source`) | Counter / Drive | +| T5 | `paid` | `cancelled` | Annulation avant remise | L'acteur detient `order.cancel` ; le stock consomme est re-credite (`stock_movement` type `cancellation`) | Counter / Drive / Admin | ### Invariants de la machine a etats - `delivered` et `cancelled` sont des etats **finaux** : aucune transition n'en sort. -- Aucune transition ne revient en arriere (pas de `preparing -> paid`). Une - erreur operationnelle se traite par annulation puis nouvelle commande, pour - preserver l'integrite de l'historique et des snapshots de prix. -- La transition vers `cancelled` est possible depuis tous les etats **sauf** - `delivered` (une commande remise ne s'annule pas dans ce modele). Ceci est - coherent avec `mcd.md` section 9 : "Annuler : transition vers `cancelled` - (depuis tout statut sauf `delivered`)". -- `paye_a` (DATETIME, `dictionary.md` 3.5) est renseigne au moment de la - transition T2 (`pending_payment -> paid`) et reste NULL avant. +- Aucune transition ne revient en arriere. Une erreur operationnelle se traite + par annulation puis nouvelle commande, pour preserver l'integrite de + l'historique et des snapshots (`label_snapshot`, `unit_price_cents_snapshot`, + `vat_rate_snapshot` sur `order_item`). +- La transition vers `cancelled` est possible depuis `pending_payment` et + `paid`, mais pas depuis `delivered` (une commande remise ne s'annule pas dans + ce modele). Coherent avec `mct.md` 7.1 (`CANCEL_ORDER`). +- `paid_at` (DATETIME, `dictionary.md` 3.10) est renseigne a la transition T2. + `delivered_at` est renseigne a T4. `cancelled_at` est renseigne a T3/T5. Les + trois colonnes sont NULL tant que la transition correspondante n'a pas eu lieu. +- La cuisine (`kitchen`) ne declenche aucune transition : son acces a la file + des commandes `paid` est en **lecture seule** (`mct.md` 5.1, + `LIST_ORDERS_DISPLAY`). --- @@ -116,29 +117,36 @@ stateDiagram-v2 | Verification | Resultat | |---|---| -| Tous les etats du diagramme existent dans l'ENUM `dictionary.md` 3.5 | Oui (6 valeurs, toutes utilisees) | -| La regle "annulation possible sauf depuis delivered" de `mcd.md` 9 | Respectee (T5, T7, T9 ; pas de transition depuis `delivered`) | -| Cycle de vie esquisse dans `mcd.md` 9 | Couvert : `pending_payment` -> `paid` (payer), `paid` -> `preparing` (preparer), `preparing` -> `ready` (marquer pret), `ready` -> `delivered` (remettre) | -| Acteurs de `use-cases.md` | Preparation declenche T4/T6/T7 ; Accueil declenche T8/T9 ; Administration peut annuler | +| Tous les etats du diagramme existent dans l'ENUM `dictionary.md` 3.10 | Oui (4 valeurs, toutes utilisees) | +| La regle "annulation possible sauf depuis delivered" de `mct.md` 7.1 | Respectee (T3, T5 ; pas de transition depuis `delivered`) | +| Transition `paid -> delivered` en geste unique de `mct.md` 6.1 | Couvert par T4 (`DELIVER_ORDER`) | +| Atomicite `pending_payment -> paid` de `mct.md` 3.3 / 4.1 / section 13 | Couvert par T2 (saisie du numero = substitut de paiement) | +| Acteurs de `use-cases.md` | Counter/Drive declenchent T4/T5 ; Admin peut annuler (T3/T5) ; Kitchen en lecture seule | +| Timestamps de phase `paid_at` / `delivered_at` / `cancelled_at` | Renseignes a T2 / T4 / T3-T5 | --- ## 7. Arbitrage tranche -La divergence historique entre l'ENUM du dictionnaire et un parcours sans -paiement est resolue par la regle metier confirmee : le cycle de vie comporte -deux phases successives, la composition de la commande puis son paiement. Le -paiement fait partie integrante du cycle. - -La machine canonique retenue est donc : +La machine retenue est reduite a quatre etats (Decision 4, +`docs/notes/revue-alignement-p1.md` section 7) : ``` -pending_payment -> paid -> preparing -> ready -> delivered - (cancelled atteignable depuis pending_payment, paid, preparing) +pending_payment -> paid -> delivered + | | + +------------+--------> cancelled (depuis pending_payment ou paid) ``` -Cette machine est la source de verite partagee par `dictionary.md` 3.5, -`use-cases.md` (cas "Payer la commande" cote Client) et -`sequence-passer-commande.md` (etape paiement entre validation du panier et -confirmation). La colonne `paye_a` est renseignee a la transition T2. A -revalider lors du MCT. +**Note sur la transition `pending_payment -> paid`** : dans le cadre RNCP, le +paiement est remplace par la saisie du numero de commande (kiosk) ou par la +validation de l'equipier (counter/drive). La transition est **atomique** dans +`CREATE_ORDER` et `CREATE_COUNTER_ORDER` : le statut `pending_payment` n'est pas +observable en dehors de la transaction de creation. Il reste declare dans l'ENUM +pour exprimer le cycle de vie complet et pour autoriser un paiement reel +ulterieur (cout d'une valeur d'ENUM). + +Les etats `preparing` et `ready` du v0.1 sont supprimes : la cuisine est un +affichage visuel en lecture seule, et la remise fusionne preparation+remise en +un geste unique (`DELIVER_ORDER`). Effet de bord propage : les operations +`MARK_IN_PREPARATION` et `MARK_READY` disparaissent du MCT (voir `mct.md` +sections 1 et 13). diff --git a/docs/uml/use-cases.md b/docs/uml/use-cases.md index c9897be..7f7e6ab 100644 --- a/docs/uml/use-cases.md +++ b/docs/uml/use-cases.md @@ -1,8 +1,8 @@ # Diagramme de cas d'utilisation - Wakdo **Phase UML** : P1 - Conception, complement UML (apres MCD) -**Statut** : v0.1 -**Date** : 2026-05-21 +**Statut** : v0.2 - prod-like, 5 roles RBAC + catalogue de 23 permissions +**Date** : 2026-06-11 **Branche** : `feat/p1-conception` **Auteur methodologie** : BYAN @@ -12,174 +12,247 @@ Ce document recense les **cas d'utilisation** de Wakdo, c'est-a-dire les fonctionnalites observables du systeme du point de vue de ses acteurs. Il -complete le MCD (`docs/merise/mcd.md`) et le dictionnaire -(`docs/merise/dictionary.md`) en passant de la vue **donnees** a la vue -**usages**. +complete le MCD (`docs/merise/mcd.md`), le dictionnaire +(`docs/merise/dictionary.md`) et le MCT (`docs/merise/mct.md`, 26 operations) en +passant de la vue **donnees / traitements** a la vue **usages**. -Le diagramme reste au niveau conceptuel. Il ne prejuge pas de l'ecran ou de -l'endpoint qui realisera chaque cas, mais identifie qui fait quoi. +Le diagramme reste au niveau conceptuel : il identifie qui fait quoi, sans +prejuger de l'ecran ou de l'endpoint qui realise chaque cas. Chaque cas +back-office est rattache a la **permission** qui le conditionne (catalogue fige +de 23 codes, `dictionary.md` 3.17), conformement a la regle RBAC +permission-driven : le code teste une permission, pas un nom de role. **Sources** : -- `docs/PROJECT_CONTEXT.md` sections 2 (acteurs, processus), 7 (scope RBAC) -- `docs/merise/dictionary.md` (entites `commande`, `role`, `user`) +- `docs/PROJECT_CONTEXT.md` sections 2 (acteurs, processus), 7 (scope back-office) +- `docs/merise/dictionary.md` 3.14-3.18 (`user`, `role`, `role_visible_source`, `permission`, `role_permission`) +- `docs/merise/mct.md` (operations, acteurs, permissions par operation) --- ## 2. Acteurs - perimetre et challenge de pertinence -Le brief (`PROJECT_CONTEXT.md` section 2 et section 7) definit les acteurs -metier. Avant de les retenir, chaque acteur propose dans la consigne initiale -est confronte au perimetre reel du projet. +Le brief initial (`PROJECT_CONTEXT.md` section 2) decrivait quatre acteurs +metier (Client, Accueil, Preparation, Administration) adosses a 3 roles RBAC. Le +modele v0.2 (prod-like, Decision 4 de `revue-alignement-p1.md` section 7) raffine +le back-office en **5 roles** pour coller a l'organisation reelle d'un fast-food +multi-canal. Chaque acteur candidat est confronte au perimetre reel. -| Acteur candidat | Statut | Justification (perimetre reel) | +| Acteur candidat (brief) | Statut v0.2 | Justification (perimetre reel) | |---|---|---| -| **Client (borne kiosk)** | Retenu | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). Non authentifie. | -| **Manager / Admin** | Retenu, fusionne en **Administration** | Le brief ne distingue pas "manager" et "admin" comme deux roles. Le role RBAC reel est `admin` (section 7). Il porte le CRUD catalogue, la gestion des utilisateurs/roles et les stats. On nomme l'acteur **Administration** pour coller au vocabulaire du brief. | -| **Cuisine** | Retenu, renomme **Preparation** | Correspond au role RBAC `preparation` (section 7). Voit la file des commandes a preparer triees par heure de livraison croissante et fait avancer leur statut. Le terme "Cuisine" est un synonyme metier ; le role technique est `preparation`. | -| **Caisse** | Ecarte comme acteur distinct | Challenge : il n'existe pas de role RBAC `caisse` (les 3 roles sont `admin`, `preparation`, `accueil`). Le paiement existe dans le cycle (cote Client sur la borne et cote Accueil au comptoir/drive), mais aucun acteur "Caisse" dedie n'est modelise. L'equivalent operationnel le plus proche est l'**Accueil** (role `accueil`) qui saisit les commandes au comptoir/drive et remet les commandes livrees. | -| **Accueil** | Retenu (non liste dans la consigne mais present au brief) | Role RBAC `accueil`. Saisit les commandes au comptoir (canal `counter`) ou au drive (canal `drive`), puis remet les commandes au client (passage a `delivered`). C'est l'acteur qui recouvre le besoin que la consigne attribuait a "Caisse". | +| **Client (borne kiosk)** | Retenu (acteur `CUSTOMER`) | Acteur central du Bloc 1. Compose et valide une commande sur la borne tactile autonome (canal `kiosk`). **Non authentifie**. | +| **Accueil** | **Scinde** en `counter` et `drive` | Le besoin "Accueil" recouvre deux canaux operationnels distincts : le comptoir (`counter`) et le drive (`drive`). Le v0.2 les separe car le tag `source` de la commande et le filtre de dashboard (`role_visible_source`) different. Tous deux saisissent des commandes, les remettent et les annulent. | +| **Preparation** | Retenu, renomme `kitchen` | Role RBAC `kitchen`. Voit la file des commandes `paid` triees par `paid_at` croissant. **Lecture seule** : ne declenche aucune transition de statut (le KDS est un dispositif visuel ; la remise revient a `counter`/`drive`). | +| **Administration** | **Scinde** en `admin` et `manager` | Le v0.1 fusionnait "Manager/Admin". Le v0.2 distingue : `admin` (gestion des utilisateurs, des roles et permissions, suppressions catalogue) et `manager` (catalogue create/update, stock/reappro, stats), utilisateurs en lecture seule (`user.read`) et sans acces au RBAC. Resout le point ouvert v0.1 "Manager vs Admin". | +| **Caisse** | Ecarte (recouvert par `counter`/`drive`) | Aucun role `caisse` n'existe. L'encaissement est atomique a la creation de commande (saisie du numero = substitut de paiement) ; il est realise par le Client (kiosk) ou par `counter`/`drive` (back-office). Resout le point ouvert v0.1 "Caisse absente du RBAC". | +| **Systeme** | Retenu (acteur `SYS`) | Logique interne (generation du numero, reponse API de confirmation). Apparait dans le MCT (3.4 `DISPLAY_CONFIRMATION`) ; non represente comme acteur humain au diagramme. | ### Decision sur les acteurs retenus -Quatre acteurs sont conserves au diagramme : +Six acteurs sont conserves : un acteur public et cinq roles back-office. -1. **Client** (borne, non authentifie) -2. **Administration** (role `admin`) -3. **Preparation** (role `preparation`, ex-"Cuisine") -4. **Accueil** (role `accueil`, recouvre le besoin "Caisse") +1. **Customer** (borne kiosk, non authentifie) +2. **Admin** (role `admin`) +3. **Manager** (role `manager`) +4. **Kitchen** (role `kitchen`, ex-"Preparation", lecture seule) +5. **Counter** (role `counter`, ex-"Accueil" comptoir) +6. **Drive** (role `drive`, ex-"Accueil" drive) -> Decision actee : il n'y a **pas** de parcours employe dedie modelise a part. -> Les cas d'usage des employes (Administration, Preparation, Accueil) sont -> couverts directement ici. Cette decision suit le mantra du Rasoir d'Ockham -> (#37) : on evite une couche de modelisation redondante tant qu'aucun besoin -> ne la justifie. +> Regle RBAC permission-driven (`dictionary.md` 3.15) : les rattachements +> acteur -> cas ci-dessous refletent la **matrice de permissions par defaut au +> seed**. Le gardien reel est la permission, pas le nom du role : un role +> personnalise (ex. "chef-patissier") dote des bonnes permissions ouvre les +> memes cas, sans changement de code. Les 5 roles seed sont un point de depart, +> pas une liste fermee. --- ## 3. Diagramme de cas d'utilisation -Mermaid ne fournit pas de type `usecase` natif. La representation ci-dessous -utilise un `flowchart` : les acteurs sont des noeuds a gauche, les cas -d'utilisation sont des noeuds arrondis regroupes par sous-systeme, et les -fleches portent les relations (`<>`, `<>`) la ou elles -ont du sens. +Mermaid ne fournit pas de type `usecase` natif. La representation utilise un +`flowchart` : les acteurs sont a gauche, les cas d'utilisation regroupes par +sous-systeme. La permission qui conditionne chaque cas back-office est precisee +en section 4. ```mermaid flowchart LR %% Acteurs - Client(("Client
borne kiosk")) - Admin(("Administration
role admin")) - Prep(("Preparation
role preparation")) - Accueil(("Accueil
role accueil")) + Customer(("Customer
borne kiosk
non authentifie")) + Admin(("Admin
role admin")) + Manager(("Manager
role manager")) + Kitchen(("Kitchen
role kitchen
lecture seule")) + Counter(("Counter
role counter")) + Drive(("Drive
role drive")) %% Sous-systeme Borne client - subgraph BORNE["Borne client - Bloc 1"] + subgraph BORNE["Borne client - Bloc 1 (public)"] UC1(["Consulter le catalogue"]) - UC2(["Composer un menu"]) - UC3(["Passer une commande"]) - UC4(["Saisir le numero de retrait"]) - UC5(["Recevoir la confirmation"]) - UC6(["Payer la commande"]) + UC2(["Composer le panier"]) + UC3(["Consulter les allergenes"]) + UC4(["Passer une commande"]) + UC5(["Saisir le numero de retrait"]) + UC6(["Recevoir la confirmation"]) end - %% Sous-systeme Back-office - subgraph BACK["Back-office - Bloc 2"] - UC10(["Gerer le catalogue
categories, produits, menus"]) - UC11(["Gerer les utilisateurs et roles"]) - UC12(["Consulter les statistiques"]) - UC20(["Consulter la file de preparation"]) - UC21(["Faire avancer une commande"]) - UC30(["Saisir une commande
comptoir ou drive"]) - UC31(["Remettre la commande au client"]) - UC40(["S'authentifier"]) + %% Sous-systeme Operations commande + subgraph OPS["Operations commande - back-office"] + UC10(["Saisir une commande
comptoir / drive"]) + UC11(["Consulter la file de preparation"]) + UC12(["Remettre la commande"]) + UC13(["Annuler une commande"]) end - %% Relations Client - Client --> UC1 - Client --> UC2 - Client --> UC3 - Client --> UC6 - Client --> UC5 + %% Sous-systeme Catalogue + subgraph CAT["Catalogue - back-office"] + UC20(["Gerer produits"]) + UC21(["Gerer menus et slots"]) + UC22(["Gerer categories"]) + UC23(["Gerer ingredients,
compositions, allergenes"]) + end - %% include / extend cote borne - UC3 -. include .-> UC4 - UC3 -. include .-> UC6 + %% Sous-systeme Stock + subgraph STK["Stock - back-office"] + UC30(["Consulter le stock"]) + UC31(["Compter l'inventaire"]) + UC32(["Reapprovisionner"]) + end + + %% Sous-systeme Administration + subgraph ADM["Administration - back-office"] + UC40(["Gerer les utilisateurs"]) + UC41(["Gerer roles et permissions"]) + UC42(["Consulter les statistiques"]) + end + + %% Transverse + UC50(["S'authentifier"]) + UC51(["Se deconnecter"]) + + %% Relations Customer + Customer --> UC1 + Customer --> UC2 + Customer --> UC4 + Customer --> UC6 UC2 -. include .-> UC1 - UC3 -. extend .-> UC2 + UC2 -. extend .-> UC3 + UC4 -. include .-> UC5 + UC4 -. include .-> UC2 - %% Relations Administration + %% Relations Counter / Drive (operations commande + stock) + Counter --> UC10 + Counter --> UC11 + Counter --> UC12 + Counter --> UC13 + Drive --> UC10 + Drive --> UC11 + Drive --> UC12 + Drive --> UC13 + UC10 -. include .-> UC1 + + %% Kitchen (lecture seule) + Kitchen --> UC11 + + %% Stock (kitchen / counter / drive / manager / admin) + Kitchen --> UC30 + Kitchen --> UC31 + Counter --> UC30 + Counter --> UC31 + Drive --> UC30 + Drive --> UC31 + Manager --> UC30 + Manager --> UC31 + Manager --> UC32 + + %% Catalogue (manager + admin) + Manager --> UC20 + Manager --> UC21 + Manager --> UC22 + Manager --> UC23 + Admin --> UC20 + Admin --> UC21 + Admin --> UC22 + Admin --> UC23 + + %% Administration (admin) + stats (manager + admin) Admin --> UC40 - Admin --> UC10 + Admin --> UC41 + Admin --> UC42 + Admin --> UC30 + Admin --> UC31 + Admin --> UC32 Admin --> UC11 - Admin --> UC12 + Admin --> UC13 + Manager --> UC42 - %% Relations Preparation - Prep --> UC40 - Prep --> UC20 - Prep --> UC21 - - %% Relations Accueil - Accueil --> UC40 - Accueil --> UC30 - Accueil --> UC31 - UC30 -. include .-> UC1 - - %% Authentification mutualisee - UC10 -. include .-> UC40 - UC11 -. include .-> UC40 - UC20 -. include .-> UC40 - UC30 -. include .-> UC40 + %% Authentification mutualisee (tout cas back-office) + UC10 -. include .-> UC50 + UC11 -. include .-> UC50 + UC20 -. include .-> UC50 + UC30 -. include .-> UC50 + UC40 -. include .-> UC50 + UC42 -. include .-> UC50 ``` --- ## 4. Description des cas d'utilisation -### 4.1 Acteur Client (borne kiosk) +### 4.1 Acteur Customer (borne kiosk, non authentifie) -| Cas | Description | Entites manipulees | +| Cas | Operation MCT | Description | Entites manipulees | +|---|---|---|---| +| Consulter le catalogue | 3.1 LOAD_CATALOGUE | Parcourir categories, produits et menus disponibles, charges via `GET /api/categories`, `/api/products`, `/api/menus` (ou JSON fallback). | `category`, `product`, `menu`, `menu_slot`, `menu_slot_option` | +| Composer le panier | 3.2 COMPOSE_CART | Ajouter produits a la carte ou menus ; remplir les slots d'un menu (`order_item_selection`), choisir le format Normal/Maxi, ajouter/retirer des ingredients (`order_item_modifier`). Panier volatil cote front, aucun ecrit BDD a ce stade. | `product`, `menu`, `menu_slot`, `menu_slot_option`, `ingredient`, `product_ingredient` | +| Consulter les allergenes | (derive de 3.1) | Afficher en modal les allergenes d'un produit, **calcules** par jointure `product_ingredient -> ingredient_allergen -> allergen` (INCO 1169/2011). Etend la composition. | `allergen`, `ingredient_allergen`, `product_ingredient` | +| Passer une commande | 3.3 CREATE_ORDER | Valider le panier et saisir le numero de retrait. Creation atomique : INSERT `customer_order` (`pending_payment` puis `paid` dans la meme transaction), `order_item` + selections + modifiers snapshotes, decrement du stock + `stock_movement`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient`, `stock_movement` | +| Saisir le numero de retrait | (inclus dans 3.3) | Renseigner le numero qui identifie le client. Tient lieu de paiement (cadre RNCP). Cas inclus par "Passer une commande". | `customer_order.order_number` | +| Recevoir la confirmation | 3.4 DISPLAY_CONFIRMATION | Afficher l'ecran de confirmation avec le numero, apres reponse `201` (statut `paid`). La borne se reinitialise. | `customer_order` | + +### 4.2 Acteurs Counter et Drive (roles `counter`, `drive`) + +| Cas | Operation MCT | Permission | Description | Entites | +|---|---|---|---|---| +| Saisir une commande comptoir/drive | 4.1 CREATE_COUNTER_ORDER | `order.create` | Composer une commande pour un client au comptoir (`counter`) ou au drive (`drive`). Logique identique a CREATE_ORDER ; `source` auto-tague depuis `role.order_source`. Numero `C-`/`D-YYYY-MM-DD-NNN`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient`, `stock_movement` | +| Consulter la file de preparation | 5.1 LIST_ORDERS_DISPLAY | `order.read` | Voir les commandes `paid` triees par `paid_at` croissant, filtrees par `role_visible_source` (counter voit kiosk+counter ; drive voit drive). Couleur KDS = `now - paid_at`. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | +| Remettre la commande | 6.1 DELIVER_ORDER | `order.deliver` | Geste unique `paid -> delivered`, `delivered_at = NOW()`. | `customer_order` | +| Annuler une commande | 7.1 CANCEL_ORDER | `order.cancel` | Transition vers `cancelled` depuis `pending_payment`/`paid`, `cancelled_at = NOW()`. Re-credit du stock si `paid`. | `customer_order`, `ingredient`, `stock_movement` | + +### 4.3 Acteur Kitchen (role `kitchen`, lecture seule) + +| Cas | Operation MCT | Permission | Description | Entites | +|---|---|---|---|---| +| Consulter la file de preparation | 5.1 LIST_ORDERS_DISPLAY | `order.read` | Voir toutes les sources (kiosk, counter, drive) en lecture seule. **Aucune transition de statut** : le KDS est un affichage visuel. | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `role_visible_source` | + +### 4.4 Stock (Kitchen, Counter, Drive, Manager, Admin) + +| Cas | Operation MCT | Permission | Description | Entites | +|---|---|---|---|---| +| Consulter le stock | 9.3 READ_STOCK | `stock.read` | Lister les ingredients avec stock courant ; alerte rupture calculee a l'affichage (`stock_quantity <= low_stock_threshold`). | `ingredient`, `stock_movement` | +| Compter l'inventaire | 9.2 INVENTORY_COUNT | `stock.count` | Saisir un comptage physique ; le systeme enregistre l'ecart (`inventory_correction`). Inclut les equipiers (kitchen/counter/drive). | `ingredient`, `stock_movement` | +| Reapprovisionner | 9.1 RESTOCK | `stock.manage` | Enregistrer une livraison en conditionnements (`+= N * pack_size`). Reserve manager/admin. | `ingredient`, `stock_movement` | + +### 4.5 Catalogue (Manager, Admin) + +| Cas | Operation MCT | Permissions | Description | Entites | +|---|---|---|---|---| +| Gerer produits | 8.1-8.3 CREATE/UPDATE/DELETE_PRODUCT | `product.create`/`update` (manager+admin), `product.delete` (admin seul) | CRUD produits (nom, prix, `vat_rate`, image, dispo). La suppression physique est reservee a `admin` et bloquee si reference (FK RESTRICT). | `product`, `category` | +| Gerer menus et slots | 8.4-8.6 CREATE/UPDATE/DELETE_MENU | `menu.create`/`update` (manager+admin), `menu.delete` (admin seul) | CRUD menus avec leur configuration de slots (`menu_slot`, `menu_slot_option`) et le burger fixe. | `menu`, `menu_slot`, `menu_slot_option`, `product` | +| Gerer categories | 8.7 MANAGE_CATEGORY | `category.manage` (manager+admin) | CRUD categories ; desactivation `is_active=0`. | `category` | +| Gerer ingredients, compositions, allergenes | 8.8 MANAGE_INGREDIENT | `ingredient.manage` (manager+admin) | CRUD `ingredient` ; composition `product_ingredient` (quantites Normal/Maxi, retirable/ajoutable, supplement) ; mapping `ingredient_allergen` (14 allergenes UE). | `ingredient`, `product_ingredient`, `ingredient_allergen`, `allergen` | + +### 4.6 Administration (role `admin`) + Stats (Manager, Admin) + +| Cas | Operation MCT | Permissions | Description | Entites | +|---|---|---|---|---| +| Gerer les utilisateurs | 10.1-10.3 CREATE/UPDATE/DEACTIVATE_USER | `user.create`/`update`/`deactivate` (admin) ; `user.read` (admin+manager) | CRUD comptes back-office avec hash argon2id ; desactivation sans suppression (historique preserve). | `user`, `role` | +| Gerer roles et permissions | 10.4 MANAGE_RBAC | `role.manage` (admin) | Editer la matrice `role_permission`, creer/modifier des roles personnalises (`default_route`, `order_source`), regler `role_visible_source`. Permissions statiques (declarees en migration). | `role`, `permission`, `role_permission`, `role_visible_source` | +| Consulter les statistiques | 11.1 READ_STATS | `stats.read` (admin+manager) | Agregats par `service_day` (coupure 10h), top produits, taux d'annulation, temps moyen de remise `delivered_at - paid_at`, repartition par `source`/`service_mode`. | `customer_order`, `order_item` | + +### 4.7 Cas transverses - Authentification + +| Cas | Operation MCT | Description | |---|---|---| -| Consulter le catalogue | Parcourir les categories, produits et menus disponibles. Charges via `GET /api/categories`, `/api/products`, `/api/menus` (ou JSON fallback). | `categorie`, `produit`, `menu` | -| Composer un menu | Choisir burger + accompagnement + boisson + sauce, regler les options de taille (normale / grande) et de personnalisation. Etend "Passer une commande" car un menu compose est une variante d'item au panier. | `menu`, `menu_produit`, `produit` | -| Passer une commande | Valider le panier, declencher la creation de la commande composee. Inclut la saisie du numero de retrait et le paiement. | `commande`, `ligne_commande` | -| Saisir le numero de retrait | Renseigner le numero qui identifie le client au comptoir. Cas inclus par "Passer une commande". | `commande.numero` | -| Payer la commande | Regler la commande une fois le panier compose et valide. Materialise la transition `pending_payment -> paid` de `state-commande.md`. Cas inclus par "Passer une commande". | `commande.statut`, `commande.paye_a` | -| Recevoir la confirmation | Afficher l'ecran de confirmation avec le numero, apres paiement. | `commande` | - -> Note de coherence : le cycle de vie comporte deux phases successives, la -> composition de la commande puis son paiement (regle metier confirmee). Le cas -> "Payer la commande" est retenu cote Client et materialise la transition -> `pending_payment -> paid` de l'ENUM `statut` -> (`dictionary.md` 3.5, `state-commande.md`). - -### 4.2 Acteur Administration (role admin) - -| Cas | Description | Entites manipulees | -|---|---|---| -| Gerer le catalogue | CRUD sur categories, produits et menus (libelles, prix, images, disponibilite, composition de menu). | `categorie`, `produit`, `menu`, `menu_produit` | -| Gerer les utilisateurs et roles | CRUD sur les comptes back-office et leurs roles ; consultation de la matrice de permissions. | `user`, `role`, `permission`, `role_permission` | -| Consulter les statistiques | Voir les commandes du jour de service, le chiffre d'affaires, les produits les plus commandes. | `commande`, `ligne_commande` | - -### 4.3 Acteur Preparation (role preparation, ex-Cuisine) - -| Cas | Description | Entites manipulees | -|---|---|---| -| Consulter la file de preparation | Afficher les commandes a preparer triees par heure de livraison croissante, tous canaux confondus. | `commande`, `ligne_commande` | -| Faire avancer une commande | Declarer une commande "preparee", ce qui declenche une transition de statut (voir `state-commande.md`). | `commande.statut` | - -### 4.4 Acteur Accueil (role accueil, recouvre Caisse) - -| Cas | Description | Entites manipulees | -|---|---|---| -| Saisir une commande | Creer une commande pour un client au comptoir (`counter`) ou au drive (`drive`), en consultant le catalogue. | `commande`, `ligne_commande`, `produit`, `menu` | -| Remettre la commande au client | Declarer une commande "livree" au moment de la remise physique. | `commande.statut` | - -### 4.5 Cas transverse - S'authentifier - -Tous les acteurs du back-office (Administration, Preparation, Accueil) passent -par "S'authentifier" avant d'acceder a leurs cas. Modelise comme cas inclus -(`<>`) par chaque cas back-office pour eviter de surcharger le -diagramme. Le Client de la borne n'est pas authentifie (canal `kiosk` public). +| S'authentifier | 12.1 AUTHENTICATE_USER | Tous les roles back-office passent par ce cas avant d'acceder a leurs cas (relation `<>`). Verification argon2id, regeneration de session (anti-fixation), `is_active=1` requis, redirection vers `role.default_route`. Le Customer du kiosk n'est pas authentifie. | +| Se deconnecter | 12.2 LOGOUT_USER | Destruction de session (`session_destroy()`) sur clic ou expiration (idle 4h / absolu 10h). | --- @@ -188,35 +261,32 @@ diagramme. Le Client de la borne n'est pas authentifie (canal `kiosk` public). | Relation | Type | Justification | |---|---|---| | Passer une commande -> Saisir le numero de retrait | include | La saisie du numero fait partie integrante de toute validation de commande. | -| Passer une commande -> Payer la commande | include | Le paiement suit la composition du panier et fait partie integrante du parcours (phase 2 du cycle de vie). | -| Composer un menu -> Consulter le catalogue | include | Composer un menu suppose de parcourir les produits eligibles a chaque slot. | -| Passer une commande -> Composer un menu | extend | Le menu est un cas optionnel : une commande peut ne contenir que des produits a la carte. La composition etend le parcours seulement si le client choisit un menu. | -| Saisir une commande (Accueil) -> Consulter le catalogue | include | L'equipier consulte le catalogue pour saisir au comptoir / drive. | -| Cas back-office -> S'authentifier | include | Acces conditionne a une session authentifiee. | +| Passer une commande -> Composer le panier | include | Une commande resulte d'un panier compose. | +| Composer le panier -> Consulter le catalogue | include | Composer suppose de parcourir les produits eligibles (a la carte ou par slot). | +| Composer le panier -> Consulter les allergenes | extend | La consultation des allergenes est un cas optionnel declenche a la demande du client sur un produit. | +| Saisir une commande (Counter/Drive) -> Consulter le catalogue | include | L'equipier consulte le catalogue pour saisir au comptoir/drive. | +| Cas back-office -> S'authentifier | include | Acces conditionne a une session authentifiee detenant la permission requise. | --- -## 6. Incoherences remontees vers les autres livrables +## 6. Points resolus par rapport au v0.1 -Ces ecarts entre les sources sont signales pour arbitrage de l'auteur (la -modelisation finale releve de sa decision, mantra de validation humaine). +Les incoherences que le v0.1 remontait pour arbitrage sont desormais tranchees +par le modele v0.2 (`dictionary.md`, `mct.md`). -1. **ENUM `statut` et phase de paiement (tranche)** - Le dictionnaire (`dictionary.md` 3.5) definit - `statut ENUM('pending_payment','paid','preparing','ready','delivered','cancelled')` - avec un paiement explicite. La regle metier confirmee fixe deux phases - successives, la composition de la commande puis son paiement. Le cas - "Payer la commande" est donc retenu cote Client et materialise la transition - `pending_payment -> paid`. Cet ecart est tranche : la machine canonique de - `state-commande.md` fait foi. - -2. **Acteur "Caisse" absent du RBAC** - Aucun role `caisse` n'existe (`PROJECT_CONTEXT.md` section 7 : `admin`, - `preparation`, `accueil`). La fonction d'encaissement de la consigne a ete - rattachee a l'acteur **Accueil**. A confirmer. - -3. **"Manager" vs "Admin"** - La consigne parle de "Manager/Admin" ; le brief ne connait que `admin`. Les - deux ont ete fusionnes en un acteur **Administration**. A confirmer si un - role manager intermediaire est souhaite (le dictionnaire 3.8 mentionne un - role `manager` extensible, non present dans le scope section 7). +1. **Acteur "Caisse"** : ecarte. L'encaissement est atomique a la creation + (saisie du numero = substitut de paiement, `mct.md` section 13) et realise + par le Customer (kiosk) ou par `counter`/`drive` (back-office). Aucun role + `caisse` n'est necessaire. +2. **"Manager" vs "Admin"** : scindes en deux roles distincts. `manager` gere le + catalogue (create/update), le stock/reappro et les stats ; `admin` ajoute les + suppressions catalogue, la gestion des utilisateurs et le RBAC. +3. **"Accueil" unique** : scinde en `counter` et `drive`, car le tag `source` et + le filtre `role_visible_source` different selon le canal. +4. **Machine a etats** : alignee sur les 4 etats du `dictionary.md` 3.10 + (`pending_payment -> paid -> delivered` + `cancelled`). Plus de `preparing` / + `ready` : la cuisine (`kitchen`) est en lecture seule, la remise est un geste + unique (`counter`/`drive`). +5. **Modele permission-driven** : chaque cas back-office est rattache a sa + permission (catalogue de 23 codes fige, `dictionary.md` 3.17). Le diagramme + reflete la matrice seed ; le gardien effectif reste la permission. diff --git a/package-lock.json b/package-lock.json new file mode 100755 index 0000000..e3cedf4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,609 @@ +{ + "name": "wakdo", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wakdo", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "1.49.1", + "jsdom": "^26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", + "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/playwright": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", + "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.49.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", + "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100755 index 0000000..04c413a --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "wakdo", + "version": "0.0.0", + "private": true, + "description": "Wakdo - tests front borne (kiosk). Back-office PHP teste via PHPUnit (phpunit.phar). NB: pas de \"type\":\"module\" a la racine -> les .js du depot (hooks .claude, _byan, bin) restent CommonJS. L'ESM est declare localement la ou il s'applique (src/public/borne/assets/js, tests/js).", + "scripts": { + "test:js": "node --test tests/js/", + "test:e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.49.1", + "jsdom": "^26.0.0" + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..6e3dabb --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,22 @@ +# Analyse statique sans Composer : lancee via le .phar autonome +# (php phpstan.phar analyse --no-progress --error-format=raw). +# Aucune baseline, aucun vendor/ : colle au "from scratch" (PROJECT_CONTEXT.md decision 4). +parameters: + level: 6 + paths: + - src + - tests + treatPhpDocTypesAsCertain: false + # Les classes de PHPUnit (TestCase, ...) vivent dans le .phar autonome, hors + # de src/. On les expose a PHPStan en scannant le phar telecharge par la CI. + # Si phpunit.phar est absent (analyse de src/ seul en local), la ligne est + # sans effet : on neutralise alors le bruit "classe inconnue" cote tests. + scanDirectories: + - phar://phpunit.phar + ignoreErrors: + # Tolere l'absence de phpunit.phar en local : les symboles PHPUnit ne + # sont alors pas resolus. En CI le phar est present, l'analyse est complete. + - + identifier: class.notFound + path: tests/* + reportUnmatched: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9b2fbe7 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,30 @@ + + + + + + tests/Unit + + + + tests/Integration + + + + + src + + + diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..227d32e --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,23 @@ +// Configuration Playwright (E2E borne). CommonJS : la racine n'est pas "type:module". +// La stack est montee a part (tests/e2e/run.sh) ; BASE_URL pointe vers wakdo-web. +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests/e2e', + // Headless : tourne sur serveur sans ecran (dans le conteneur Playwright officiel). + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + use: { + // run.sh fixe BASE_URL (hostname .test, joignable via --add-host). + baseURL: process.env.BASE_URL || 'http://kiosk.wakdo.test', + headless: true, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +}); diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..e36a134 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# Wakdo - deploiement scripte (declenchement humain ; socle d'un CD pull-based). +# +# Strategie CD : deploiement DECLENCHE A LA MAIN pour l'instant (solo dev, prod +# unique). Ce script fiabilise l'operation (Cr 7.b.2). Il : +# 1. recupere la derniere `main` depuis Forgejo (git fetch + fast-forward) ; +# 2. RECONSTRUIT les images depuis les Dockerfiles -- les images wakdo +# (apache / php-fpm / cron) sont buildees localement, il n'y a pas de registre, +# donc on `build`, on ne `pull` pas ; +# 3. recree la stack. +# L'automatisation "pull-based" (un job cron cote hote qui detecte un nouveau `main` +# et lance ce script) est l'etape suivante : elle reutilisera ce meme script. +# +# A lancer SUR L'HOTE de prod, depuis la racine du depot : +# scripts/deploy.sh [BRANCHE] (defaut : main) +# GIT_REMOTE=origin scripts/deploy.sh (override du remote git) +# COMPOSE_FILE=docker-compose.yml scripts/deploy.sh (override du fichier compose) +# +# Prerequis : le fichier compose cible (defaut docker-compose.prod.yml, gitignore, +# propre a l'hote) declare les services wakdo en `build:` (memes contextes que le +# docker-compose.yml standalone) et un .env de prod renseigne. Le service one-shot +# wakdo-migrate applique migrations + seed (idempotents) avant que l'app ne serve. +# +# Exit codes : 0 = OK ; 1 = prerequis manquant / confirmation refusee. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +BRANCH="${1:-main}" +# Remote : le canonique du projet est `forgejo` (git.acadenice.com) ; on le prefere +# s'il existe, sinon `origin` (clone standard). Surchargeable via GIT_REMOTE. +if [ -n "${GIT_REMOTE:-}" ]; then + REMOTE="$GIT_REMOTE" +elif git remote | grep -qx forgejo; then + REMOTE="forgejo" +else + REMOTE="origin" +fi +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" + +if [ ! -f "$COMPOSE_FILE" ]; then + echo "deploy: $COMPOSE_FILE introuvable (fichier compose de l'hote)." >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "deploy: docker introuvable sur l'hote." >&2 + exit 1 +fi + +echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE" +printf 'Confirmer le deploiement en production ? [oui/NON] ' +read -r answer +if [ "$answer" != "oui" ]; then + echo "deploy: annule." + exit 1 +fi + +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/4] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)" +docker compose -f "$COMPOSE_FILE" build --pull + +echo "[3/4] demarrage de la stack (migrate + seed idempotents puis app)" +docker compose -f "$COMPOSE_FILE" up -d + +echo "[4/4] etat des services" +docker compose -f "$COMPOSE_FILE" ps + +echo "Deploiement termine." diff --git a/scripts/forgejo-branch-protection.sh b/scripts/forgejo-branch-protection.sh new file mode 100755 index 0000000..6f38715 --- /dev/null +++ b/scripts/forgejo-branch-protection.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Wakdo - applique (idempotent) les regles de protection de branche sur Forgejo. +# +# Pourquoi un script versionne : la regle de gouvernance devient reproductible +# et auditable (Cr 7.b), pas un clic dans une UI. Roter le token = editer .env. +# +# Regle posee : +# - main et dev : push direct interdit (PR obligatoire), force-push bloque +# - required_approvals = 0 (travail solo : on ne peut pas approuver sa propre PR) +# - status check : OPTIONNEL via REQUIRE_CI=1, contextes dans CI_CONTEXTS +# (a activer en lot D, une fois les jobs .forgejo/workflows/ nommes ; +# activer avant que le workflow n'existe bloquerait tout merge) +# +# Usage : +# scripts/forgejo-branch-protection.sh # baseline (PR requise) +# REQUIRE_CI=1 CI_CONTEXTS='ci' scripts/forgejo-branch-protection.sh # + CI verte requise +# +set -euo pipefail + +REPO_API="https://git.acadenice.com/api/v1/repos/AcadeNice/corentin_wakdo" +ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env" + +TOKEN="$(grep -E '^FORGEJO_TOKEN=' "$ENV_FILE" | cut -d= -f2-)" +if [ -z "${TOKEN:-}" ]; then + echo "ERREUR : FORGEJO_TOKEN absent de $ENV_FILE" >&2 + exit 1 +fi + +REQUIRE_CI="${REQUIRE_CI:-0}" +CI_CONTEXTS="${CI_CONTEXTS:-ci}" + +# Construit le tableau JSON des contextes de status check si REQUIRE_CI=1. +status_check_json="false" +contexts_json="[]" +if [ "$REQUIRE_CI" = "1" ]; then + status_check_json="true" + contexts_json="$(printf '%s' "$CI_CONTEXTS" | awk -F, '{printf "["; for(i=1;i<=NF;i++){printf "%s\"%s\"", (i>1?",":""), $i}; printf "]"}')" +fi + +for branch in main dev; do + payload=$(cat </dev/null +done + +echo "OK - protections appliquees sur main et dev." diff --git a/scripts/forgejo-pr-automerge.sh b/scripts/forgejo-pr-automerge.sh new file mode 100755 index 0000000..46f5952 --- /dev/null +++ b/scripts/forgejo-pr-automerge.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Wakdo - ouvre une PR Forgejo et planifie son auto-merge quand la CI passe. +# +# Strategie solo dev : la PR reste obligatoire (trace de gouvernance, Cr 4.f) +# mais le merge se declenche tout seul des que les checks requis sont verts. +# Prerequis : status checks requis sur la branche de base +# (voir scripts/forgejo-branch-protection.sh avec REQUIRE_CI=1). +# +# Usage : +# scripts/forgejo-pr-automerge.sh [HEAD] [BASE] ["Titre"] +# Defauts : HEAD = branche courante, BASE = dev, titre = dernier sujet de commit. +# +set -euo pipefail + +REPO_API="https://git.acadenice.com/api/v1/repos/AcadeNice/corentin_wakdo" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="$ROOT/.env" + +TOKEN="$(grep -E '^FORGEJO_TOKEN=' "$ENV_FILE" | cut -d= -f2-)" +[ -n "${TOKEN:-}" ] || { echo "ERREUR : FORGEJO_TOKEN absent de $ENV_FILE" >&2; exit 1; } + +HEAD="${1:-$(git -C "$ROOT" rev-parse --abbrev-ref HEAD)}" +BASE="${2:-dev}" +TITLE="${3:-$(git -C "$ROOT" log -1 --pretty=%s "$HEAD")}" + +if [ "$BASE" = "main" ] && [ "$HEAD" != "dev" ]; then + echo "Garde-fou : seules les PR depuis 'dev' visent 'main'. Abandon." >&2 + exit 1 +fi + +echo "PR : $HEAD -> $BASE" +echo "Titre : $TITLE" + +# 1. Creer la PR (ou recuperer l'index si elle existe deja). +create_resp=$(curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "$(printf '{"head":"%s","base":"%s","title":"%s"}' "$HEAD" "$BASE" "$TITLE")" \ + "$REPO_API/pulls") +index=$(printf '%s' "$create_resp" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('number',''))" 2>/dev/null || true) + +if [ -z "$index" ]; then + # PR deja existante : la retrouver par branche head. + index=$(curl -s -H "Authorization: token $TOKEN" "$REPO_API/pulls?state=open&limit=50" \ + | python3 -c "import sys,json;hs='$HEAD';d=json.load(sys.stdin);print(next((p['number'] for p in d if p['head']['ref']==hs),''))" 2>/dev/null || true) +fi +[ -n "$index" ] || { echo "Impossible de creer/trouver la PR. Reponse : $create_resp" >&2; exit 1; } +echo "PR #$index" + +# 2. Planifier l'auto-merge (squash) quand les checks requis sont verts. +merge_resp=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d '{"Do":"squash","merge_when_checks_succeed":true,"delete_branch_after_merge":false}' \ + "$REPO_API/pulls/$index/merge") + +case "$merge_resp" in + 200|201|202) echo "Auto-merge planifie sur PR #$index (squash a la CI verte)." ;; + 405) echo "PR #$index : merge differe - checks pas encore verts, auto-merge en attente." ;; + *) echo "Reponse merge HTTP $merge_resp sur PR #$index (verifier l'etat des checks / protections)." ;; +esac diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..963e4ab --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# +# Wakdo - installe les hooks Git versionnes (.githooks). +# +# Pointe core.hooksPath vers .githooks (versionne) au lieu de .git/hooks (local, +# non versionne). A lancer une fois apres le clone : +# scripts/install-hooks.sh +# +# Les hooks (pre-commit, commit-msg) sont alors actifs pour ce clone. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HOOKS_DIR="$ROOT/.githooks" + +if [ ! -d "$HOOKS_DIR" ]; then + echo "install-hooks: $HOOKS_DIR introuvable." >&2 + exit 1 +fi + +chmod +x "$HOOKS_DIR"/* 2>/dev/null || true +git -C "$ROOT" config core.hooksPath .githooks + +echo "Hooks Git installes (core.hooksPath = .githooks)." +echo "Actifs : $(cd "$HOOKS_DIR" && ls | tr '\n' ' ')" diff --git a/src/app/Auth/AuthResult.php b/src/app/Auth/AuthResult.php new file mode 100644 index 0000000..29c107a --- /dev/null +++ b/src/app/Auth/AuthResult.php @@ -0,0 +1,35 @@ + PRE-3 gate compte+IP -> RG-2 verify (leurre si miss). + * Succes : RG-3 regenerate + rotate CSRF, RG-4 session, RG-5/RG-9 reset+audit + * (une transaction), RG-7 redirection dynamique. Echec : RG-8 backoff degressif + * compte + upsert IP + audit (une transaction). Message d'echec unique (ERR-1/3). + */ + public function authenticate(string $email, string $password, string $ip, ?int $now = null): AuthResult + { + $now ??= time(); + $accountPolicy = ThrottlePolicy::fromConfig($this->config, 'account'); + $ipPolicy = ThrottlePolicy::fromConfig($this->config, 'ip'); + + // RG-1 : recherche systematique (hit ou miss) afin que le cout du SELECT + // soit paye dans les deux cas (limite l'oracle de timing par enumeration). + $user = $this->findActiveUserByEmail($email); + + // PRE-3 : porte de throttling AVANT toute verification de mot de passe. + $accountLockedUntil = $user !== null ? $this->stringOrNull($user['lockout_until'] ?? null) : null; + $accountLocked = $accountPolicy->isLockedUntil($accountLockedUntil, $now); + $ipLocked = $ipPolicy->isLockedUntil($this->ipLockoutUntil($ip), $now); + + if ($accountLocked || $ipLocked) { + // ERR-3 : meme message generique ; ne revele pas l'existence ni le verrou. + // Pas d'increment : le compteur tourne deja, le verrou est actif. + return AuthResult::failure(); + } + + // RG-2 : email inconnu -> verify leurre (timing) puis echec generique. + if ($user === null) { + $this->hasher->verifyDecoy($password); + $this->recordFailure(null, null, 0, $ip, $accountPolicy, $ipPolicy, $now); + + return AuthResult::failure(); + } + + $userId = (int) ($user['id'] ?? 0); + $roleId = (int) ($user['role_id'] ?? 0); + + if (!$this->hasher->verify($password, (string) ($user['password_hash'] ?? ''))) { + $attempts = (int) ($user['failed_login_attempts'] ?? 0); + $this->recordFailure($userId, $roleId, $attempts, $ip, $accountPolicy, $ipPolicy, $now); + + return AuthResult::failure(); + } + + // Succes : RG-3 (anti-fixation) d'abord (change l'ID, pas encore d'identite). + $this->session->regenerate(); + + // RG-5 + RG-9 : reset compteurs + clear IP + audit succes, une transaction. + // Fait AVANT de poser l'identite en session : si la base echoue, aucune + // session authentifiee ne subsiste (fail-closed, D9). + $this->recordSuccess($userId, $roleId, $ip, $now); + + // RG-4 : identite + horodatages pour les bornes idle/absolue (RG-6), + // puis rotation du jeton CSRF anterieur a l'authentification. + $this->session->set('user_id', $userId); + $this->session->set('role_id', $roleId); + $this->session->set('logged_in_at', $now); + $this->session->set('last_activity', $now); + Csrf::rotate($this->session); + + $routeRaw = $user['default_route'] ?? null; + $defaultRoute = is_string($routeRaw) && $routeRaw !== '' ? $routeRaw : '/'; + + return AuthResult::success($userId, $roleId, $defaultRoute); + } + + /** + * LOGOUT_USER (12.2) : efface puis detruit la session. Aucune I/O base. + */ + public function logout(): void + { + $this->session->clear(); + $this->session->destroy(); + } + + /** + * RG-1 : utilisateur actif par email, joint a son role pour la route de + * redirection dynamique (RG-7). Requete preparee (RG-T06). + * + * @return array|null + */ + private function findActiveUserByEmail(string $email): ?array + { + return $this->db->fetch( + 'SELECT u.id, u.password_hash, u.role_id, u.failed_login_attempts, u.lockout_until, r.default_route ' + . 'FROM user u JOIN role r ON r.id = u.role_id ' + . 'WHERE u.email = :email AND u.is_active = 1 LIMIT 1', + ['email' => $email], + ); + } + + private function ipLockoutUntil(string $ip): ?string + { + $row = $this->db->fetch( + 'SELECT lockout_until FROM login_throttle WHERE ip_address = :ip', + ['ip' => $ip], + ); + + return $row === null ? null : $this->stringOrNull($row['lockout_until'] ?? null); + } + + /** + * RG-8 : enregistre l'echec sur les deux dimensions (compte si connu + IP) + * et une ligne audit_log, le tout dans une seule transaction atomique (RG-T08). + */ + private function recordFailure( + ?int $userId, + ?int $roleId, + int $currentAttempts, + string $ip, + ThrottlePolicy $accountPolicy, + ThrottlePolicy $ipPolicy, + int $now, + ): void { + $nowDt = date('Y-m-d H:i:s', $now); + $windowSeconds = $this->config->int('IP_THROTTLE_WINDOW_SECONDS', 900); + + $windowCutoff = date('Y-m-d H:i:s', $now - $windowSeconds); + + $this->db->transaction(function (DatabaseInterface $db) use ( + $userId, + $roleId, + $currentAttempts, + $ip, + $accountPolicy, + $ipPolicy, + $now, + $nowDt, + $windowCutoff, + ): void { + // Dimension compte. Pour ne pas reveler par le timing si l'email existe + // (anti-enumeration, RG-2), on emet la MEME requete dans les deux cas : + // sur email inconnu, un UPDATE sur id = 0 (aucune ligne touchee car les + // PK user sont AUTO_INCREMENT >= 1), donc meme profil d'I/O, effet nul. + if ($userId !== null) { + $newAttempts = $currentAttempts + 1; + $lockSeconds = $accountPolicy->lockoutSeconds($newAttempts); + $lockUntil = $lockSeconds > 0 ? date('Y-m-d H:i:s', $now + $lockSeconds) : null; + + $db->execute( + 'UPDATE user SET failed_login_attempts = :attempts, last_failed_login_at = :now, ' + . 'lockout_until = :lock WHERE id = :id', + ['attempts' => $newAttempts, 'now' => $nowDt, 'lock' => $lockUntil, 'id' => $userId], + ); + } else { + $db->execute( + 'UPDATE user SET failed_login_attempts = :attempts, last_failed_login_at = :now, ' + . 'lockout_until = :lock WHERE id = :id', + ['attempts' => 0, 'now' => $nowDt, 'lock' => null, 'id' => 0], + ); + } + + // Dimension IP : increment ATOMIQUE cote SQL (failed_attempts + 1) pour + // eviter le lost-update sous concurrence ; la fenetre glissante est + // reinitialisee en SQL si elle a expire. Le verrou de ligne pris par + // l'upsert serialise les tentatives concurrentes sur la meme IP. + // Placeholders distincts : en prepare reelle (EMULATE_PREPARES = false) + // un meme nom ne peut pas etre lie plusieurs fois. + $db->execute( + 'INSERT INTO login_throttle (ip_address, failed_attempts, window_started_at, last_attempt_at) ' + . 'VALUES (:ip, 1, :now_i, :now_li) ' + . 'ON DUPLICATE KEY UPDATE ' + . 'failed_attempts = IF(window_started_at < :cutoff, 1, failed_attempts + 1), ' + . 'window_started_at = IF(window_started_at < :cutoff2, :now_w, window_started_at), ' + . 'last_attempt_at = :now_lu', + [ + 'ip' => $ip, + 'now_i' => $nowDt, + 'now_li' => $nowDt, + 'cutoff' => $windowCutoff, + 'cutoff2' => $windowCutoff, + 'now_w' => $nowDt, + 'now_lu' => $nowDt, + ], + ); + + // Relit le compteur post-increment (valeur autoritaire ecrite ci-dessus, + // ligne deja verrouillee par cette transaction) pour calculer le backoff + // IP en PHP via ThrottlePolicy, puis pose le verrou. + $row = $db->fetch('SELECT failed_attempts FROM login_throttle WHERE ip_address = :ip', ['ip' => $ip]); + $ipAttempts = (int) ($row['failed_attempts'] ?? 1); + $ipLockSeconds = $ipPolicy->lockoutSeconds($ipAttempts); + $ipLockUntil = $ipLockSeconds > 0 ? date('Y-m-d H:i:s', $now + $ipLockSeconds) : null; + + $db->execute( + 'UPDATE login_throttle SET lockout_until = :lock WHERE ip_address = :ip', + ['lock' => $ipLockUntil, 'ip' => $ip], + ); + + $this->writeAudit($db, 'auth.login_failed', $userId, $roleId, 'Echec de connexion'); + }); + } + + /** + * RG-9 : remise a zero du compteur compte + clear du throttle IP + audit du + * succes, une seule transaction (RG-T08). + */ + private function recordSuccess(int $userId, int $roleId, string $ip, int $now): void + { + $nowDt = date('Y-m-d H:i:s', $now); + + $this->db->transaction(function (DatabaseInterface $db) use ($userId, $roleId, $ip, $nowDt): void { + $db->execute( + 'UPDATE user SET failed_login_attempts = 0, lockout_until = NULL, last_login_at = :now WHERE id = :id', + ['now' => $nowDt, 'id' => $userId], + ); + + // Clear de la ligne IP : 0 ligne affectee si aucune n'existait (benin). + // Placeholders distincts (cf. recordFailure : prepare reelle, un nom + // ne peut etre lie qu'une fois). + $db->execute( + 'UPDATE login_throttle SET failed_attempts = 0, lockout_until = NULL, ' + . 'window_started_at = :now_w, last_attempt_at = :now_l WHERE ip_address = :ip', + ['now_w' => $nowDt, 'now_l' => $nowDt, 'ip' => $ip], + ); + + $this->writeAudit($db, 'auth.login_success', $userId, $roleId, 'Connexion reussie'); + }); + } + + /** + * RG-T14 : audit_log strictement en INSERT (jamais d'UPDATE/DELETE). summary + * non personnel ; details laisse NULL pour un evenement d'auth (aucune PII). + */ + private function writeAudit( + DatabaseInterface $db, + string $actionCode, + ?int $userId, + ?int $roleId, + string $summary, + ): void { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => $userId, + 'rid' => $roleId, + 'code' => $actionCode, + 'etype' => $userId !== null ? 'user' : null, + 'eid' => $userId, + 'summary' => $summary, + ], + ); + } + + private function stringOrNull(mixed $value): ?string + { + return is_string($value) ? $value : null; + } +} diff --git a/src/app/Auth/Authorizer.php b/src/app/Auth/Authorizer.php new file mode 100644 index 0000000..50369e2 --- /dev/null +++ b/src/app/Auth/Authorizer.php @@ -0,0 +1,75 @@ +db->fetch( + 'SELECT 1 AS granted FROM role_permission rp ' + . 'JOIN permission p ON p.id = rp.permission_id ' + . 'JOIN role r ON r.id = rp.role_id ' + . 'WHERE rp.role_id = :role AND p.code = :code AND r.is_active = 1 LIMIT 1', + ['role' => $roleId, 'code' => $permissionCode], + ); + + return $row !== null; + } + + /** + * Liste des codes de permission du role (pour /api/me et l'affichage UI). + * + * @return list + */ + public function permissionsFor(int $roleId): array + { + $rows = $this->db->fetchAll( + 'SELECT p.code FROM role_permission rp ' + . 'JOIN permission p ON p.id = rp.permission_id ' + . 'JOIN role r ON r.id = rp.role_id ' + . 'WHERE rp.role_id = :role AND r.is_active = 1 ORDER BY p.code', + ['role' => $roleId], + ); + + $codes = []; + foreach ($rows as $row) { + $code = $row['code'] ?? null; + if (is_string($code)) { + $codes[] = $code; + } + } + + return $codes; + } + + /** + * Code du role (ex. 'admin', 'counter'). Lecture de metadonnee de role, + * regroupee ici avec l'acces a role_permission pour un seul seam de donnees. + */ + public function roleCode(int $roleId): ?string + { + // Filtre is_active comme can()/permissionsFor() : un role desactive ne + // doit exposer ni droits ni libelle exploitable (coherence de l'invariant). + $row = $this->db->fetch('SELECT r.code FROM role r WHERE r.id = :id AND r.is_active = 1', ['id' => $roleId]); + + return is_string($row['code'] ?? null) ? $row['code'] : null; + } +} diff --git a/src/app/Auth/Csrf.php b/src/app/Auth/Csrf.php new file mode 100644 index 0000000..6b2c483 --- /dev/null +++ b/src/app/Auth/Csrf.php @@ -0,0 +1,60 @@ +get(self::KEY); + if (is_string($existing) && $existing !== '') { + return $existing; + } + + return self::rotate($session); + } + + /** + * Vrai uniquement si un jeton existe en session et egale (temps constant) le + * jeton soumis. Toute absence (pas de jeton, soumission vide) renvoie false. + */ + public static function validate(SessionManager $session, ?string $submitted): bool + { + $stored = $session->get(self::KEY); + + if (!is_string($stored) || $stored === '' || $submitted === null || $submitted === '') { + return false; + } + + return hash_equals($stored, $submitted); + } + + /** + * Re-genere le jeton (apres regeneration d'ID de session sur login reussi) : + * invalide tout jeton anterieur a l'authentification. + */ + public static function rotate(SessionManager $session): string + { + $token = bin2hex(random_bytes(32)); + $session->set(self::KEY, $token); + + return $token; + } +} diff --git a/src/app/Auth/GuardResult.php b/src/app/Auth/GuardResult.php new file mode 100644 index 0000000..faf58a0 --- /dev/null +++ b/src/app/Auth/GuardResult.php @@ -0,0 +1,22 @@ + logs du conteneur) pour pouvoir le + * recuperer en dev. Le lien contient le token brut, qui n'est jamais persiste. + */ +final class LogMailer implements Mailer +{ + public function sendPasswordReset(string $email, string $resetUrl): void + { + error_log(sprintf('[wakdo][password-reset] %s -> %s', $email, $resetUrl)); + } +} diff --git a/src/app/Auth/Mailer.php b/src/app/Auth/Mailer.php new file mode 100644 index 0000000..6a21a1d --- /dev/null +++ b/src/app/Auth/Mailer.php @@ -0,0 +1,16 @@ +options()); + } + + public function verify(string $plain, string $hash): bool + { + return password_verify($plain, $hash); + } + + /** + * Verifie le mot de passe soumis contre un leurre argon2id de meme cout, et + * jette le resultat. But : egaliser le temps CPU du chemin "email inconnu" + * avec celui du chemin "mauvais mot de passe", pour ne pas reveler par le + * timing si un compte existe (RG-2). Le leurre est calcule une fois par + * process sur un secret jetable ; il ne correspond a aucun mot de passe reel. + */ + public function verifyDecoy(string $plain): void + { + password_verify($plain, $this->decoyHash()); + } + + /** + * @return array{memory_cost: int, time_cost: int, threads: int} + */ + private function options(): array + { + // Defauts alignes sur .env.example / OWASP (64 MiB, 4 iterations, 1 thread). + return [ + 'memory_cost' => $this->config->int('ARGON2_MEMORY_COST', 65536), + 'time_cost' => $this->config->int('ARGON2_TIME_COST', 4), + 'threads' => $this->config->int('ARGON2_THREADS', 1), + ]; + } + + private function decoyHash(): string + { + // Cache statique par process : le hash argon2id du leurre est couteux et + // n'est calcule qu'une fois par worker, puis reutilise. Sans ce cache, + // comme le PasswordHasher est instancie a chaque requete, chaque tentative + // sur email inconnu paierait un password_hash supplementaire absent du + // chemin email connu -> ecart de timing reintroduisant l'oracle d'enumeration. + if (self::$decoy === null) { + self::$decoy = password_hash(bin2hex(random_bytes(16)), PASSWORD_ARGON2ID, $this->options()); + } + + return self::$decoy; + } +} diff --git a/src/app/Auth/PasswordResetService.php b/src/app/Auth/PasswordResetService.php new file mode 100644 index 0000000..c0a2869 --- /dev/null +++ b/src/app/Auth/PasswordResetService.php @@ -0,0 +1,154 @@ +db->fetch( + 'SELECT id FROM user WHERE email = :email AND is_active = 1 LIMIT 1', + ['email' => $email], + ); + + if ($user === null) { + // Anti-enumeration (RG-2) : egaliser le profil du chemin email-inconnu + // avec celui du chemin email-connu (meme cout de generation de token + + // UNE ecriture), sans rien persister d'exploitable. Sans ce leurre, une + // reponse instantanee et zero ecriture trahissent qu'aucun compte ne + // correspond a l'email. + $this->payEnumerationDecoy($now); + + return; + } + + $userId = (int) ($user['id'] ?? 0); + + // Token a haute entropie (256 bits). Stocke en SHA-256 : un hash rapide + // suffit (la robustesse vient de l'entropie, pas d'un KDF lent), et le + // brut n'est jamais persiste. Voir comment de confirmReset(). + $rawToken = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $rawToken); + $ttl = $this->config->int('PASSWORD_RESET_TTL', 3600); + $expiresAt = date('Y-m-d H:i:s', $now + $ttl); + + $this->db->execute( + 'UPDATE user SET password_reset_token_hash = :hash, password_reset_expires_at = :exp WHERE id = :id', + ['hash' => $tokenHash, 'exp' => $expiresAt, 'id' => $userId], + ); + + $resetUrl = rtrim($baseUrl, '/') . '/reset_password?token=' . $rawToken; + $this->mailer->sendPasswordReset($email, $resetUrl); + } + + /** + * Leurre anti-enumeration du chemin email-inconnu : reproduit le cout CPU + * (generation d'un token CSPRNG + SHA-256) et UNE ecriture du chemin + * email-connu, mais cible id = 0 (aucune ligne affectee, rien persiste). Le + * temps de reponse et le nombre d'ecritures ne revelent plus l'existence + * d'un compte (parite avec requestReset cote email connu). + */ + private function payEnumerationDecoy(int $now): void + { + $rawToken = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $rawToken); + $ttl = $this->config->int('PASSWORD_RESET_TTL', 3600); + $expiresAt = date('Y-m-d H:i:s', $now + $ttl); + + $this->db->execute( + 'UPDATE user SET password_reset_token_hash = :hash, password_reset_expires_at = :exp WHERE id = 0', + ['hash' => $tokenHash, 'exp' => $expiresAt], + ); + } + + /** + * Phase confirmation (RG-3/RG-4). Hash du token soumis, recherche par hash + + * expiration future (la recherche par egalite sur un token 256 bits EST la + * comparaison ; pas de souci de temps constant car ce n'est pas un secret a + * faible entropie et la colonne n'est jamais renvoyee au client). Min 8 + * caracteres, nouveau hash argon2id, token efface (usage unique), compteurs + * remis a zero, audit_log : le tout dans une transaction. + */ + public function confirmReset(string $rawToken, string $newPassword, ?int $now = null): AuthResult + { + $now ??= time(); + + if (strlen($newPassword) < 8) { + return AuthResult::failure('Le mot de passe doit contenir au moins 8 caracteres.'); + } + + if ($rawToken === '') { + return AuthResult::failure('Lien invalide ou expire.'); + } + + $tokenHash = hash('sha256', $rawToken); + $nowDt = date('Y-m-d H:i:s', $now); + + $user = $this->db->fetch( + 'SELECT id, role_id, password_reset_token_hash FROM user ' + . 'WHERE password_reset_token_hash = :hash AND password_reset_expires_at > :now ' + . 'AND is_active = 1 LIMIT 1', + ['hash' => $tokenHash, 'now' => $nowDt], + ); + + if ($user === null) { + return AuthResult::failure('Lien invalide ou expire.'); + } + + $userId = (int) ($user['id'] ?? 0); + $roleId = (int) ($user['role_id'] ?? 0); + $newHash = $this->hasher->hash($newPassword); + + $this->db->transaction(function (DatabaseInterface $db) use ($userId, $roleId, $newHash): void { + // Usage unique : on efface token + expiration et on remet les + // compteurs anti brute-force a zero (le compte redevient utilisable). + $db->execute( + 'UPDATE user SET password_hash = :hash, password_reset_token_hash = NULL, ' + . 'password_reset_expires_at = NULL, failed_login_attempts = 0, lockout_until = NULL ' + . 'WHERE id = :id', + ['hash' => $newHash, 'id' => $userId], + ); + + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => $userId, + 'rid' => $roleId, + 'code' => 'auth.password_reset', + 'etype' => 'user', + 'eid' => $userId, + 'summary' => 'Reinitialisation du mot de passe', + ], + ); + }); + + return AuthResult::success($userId, $roleId, '/login?reset=ok'); + } +} diff --git a/src/app/Auth/PinThrottle.php b/src/app/Auth/PinThrottle.php new file mode 100644 index 0000000..8542681 --- /dev/null +++ b/src/app/Auth/PinThrottle.php @@ -0,0 +1,162 @@ + non + * verrouille (defensif). + */ + public function isLocked(int $actorUserId, ?int $now = null): bool + { + if ($actorUserId <= 0) { + return false; + } + + $now ??= time(); + + $row = $this->db->fetch( + 'SELECT lockout_until FROM pin_throttle WHERE actor_user_id = :uid', + ['uid' => $actorUserId], + ); + + $lockoutUntil = is_string($row['lockout_until'] ?? null) ? (string) $row['lockout_until'] : null; + + return ThrottlePolicy::fromConfig($this->config, 'pin')->isLockedUntil($lockoutUntil, $now); + } + + /** + * Enregistre un echec de PIN pour l'utilisateur agissant, en une transaction + * (RG-T08) : upsert atomique du compteur (fenetre glissante reinitialisee en SQL + * si expiree, verrou de ligne anti lost-update) puis pose du verrou degressif. + * Ne touche JAMAIS user ni login_throttle (RG-T22) et n'ecrit pas d'audit_log. + */ + public function recordFailure(int $actorUserId, ?int $now = null): void + { + if ($actorUserId <= 0) { + return; + } + + // Variante autonome : ouvre sa propre transaction. Le controleur, lui, + // prefere recordFailureWithin() pour ecrire la trace pin.failed et cet + // increment dans UNE SEULE transaction (RG-T08). + $this->db->transaction(function (DatabaseInterface $db) use ($actorUserId, $now): void { + $this->recordFailureWithin($db, $actorUserId, $now); + }); + } + + /** + * Variante SANS transaction propre : suppose que l'appelant a deja ouvert une + * transaction (le controleur enveloppe la trace audit pin.failed (RG-T14) et + * cet increment dans la meme, RG-T08 : pas d'etat partiel si crash entre les + * deux). Memes effets que recordFailure : upsert atomique sous verrou de ligne, + * fenetre glissante reinitialisee en SQL, backoff degressif. Ne touche jamais + * user ni login_throttle (RG-T22). + */ + public function recordFailureWithin(DatabaseInterface $db, int $actorUserId, ?int $now = null): void + { + if ($actorUserId <= 0) { + return; + } + + $now ??= time(); + $nowDt = date('Y-m-d H:i:s', $now); + $windowSeconds = $this->config->int('PIN_THROTTLE_WINDOW_SECONDS', 900); + $windowCutoff = date('Y-m-d H:i:s', $now - $windowSeconds); + $policy = ThrottlePolicy::fromConfig($this->config, 'pin'); + + // Increment ATOMIQUE cote SQL sous le verrou de ligne pris par l'upsert + // (anti lost-update sous POSTs concurrents). Placeholders distincts : en + // prepare reelle (EMULATE_PREPARES = false) un meme nom ne peut etre lie + // qu'une fois. Meme forme que AuthService (dimension IP). + $db->execute( + 'INSERT INTO pin_throttle (actor_user_id, failed_attempts, window_started_at, last_attempt_at) ' + . 'VALUES (:uid, 1, :now_i, :now_li) ' + . 'ON DUPLICATE KEY UPDATE ' + . 'failed_attempts = IF(window_started_at < :cutoff, 1, failed_attempts + 1), ' + . 'window_started_at = IF(window_started_at < :cutoff2, :now_w, window_started_at), ' + . 'last_attempt_at = :now_lu', + [ + 'uid' => $actorUserId, + 'now_i' => $nowDt, + 'now_li' => $nowDt, + 'cutoff' => $windowCutoff, + 'cutoff2' => $windowCutoff, + 'now_w' => $nowDt, + 'now_lu' => $nowDt, + ], + ); + + // Relit le compteur autoritaire (ligne deja verrouillee par cette tx) + // pour calculer le backoff en PHP, puis pose le verrou. + $row = $db->fetch('SELECT failed_attempts FROM pin_throttle WHERE actor_user_id = :uid', ['uid' => $actorUserId]); + $attempts = (int) ($row['failed_attempts'] ?? 1); + $lockSeconds = $policy->lockoutSeconds($attempts); + $lockUntil = $lockSeconds > 0 ? date('Y-m-d H:i:s', $now + $lockSeconds) : null; + + $db->execute( + 'UPDATE pin_throttle SET lockout_until = :lock WHERE actor_user_id = :uid', + ['lock' => $lockUntil, 'uid' => $actorUserId], + ); + } + + /** + * PIN valide : remet a zero le compteur de l'utilisateur agissant (un manager + * qui s'est trompe puis a reussi n'est pas penalise plus tard). UPDATE simple + * (0 ligne si aucune n'existait, benin), SANS transaction propre : le controleur + * l'appelle apres l'effet reussi, sur sa propre connexion. + */ + public function reset(int $actorUserId, ?int $now = null): void + { + if ($actorUserId <= 0) { + return; + } + + $now ??= time(); + $nowDt = date('Y-m-d H:i:s', $now); + + $this->db->execute( + 'UPDATE pin_throttle SET failed_attempts = 0, lockout_until = NULL, ' + . 'window_started_at = :now_w, last_attempt_at = :now_l WHERE actor_user_id = :uid', + ['now_w' => $nowDt, 'now_l' => $nowDt, 'uid' => $actorUserId], + ); + } +} diff --git a/src/app/Auth/PinVerifier.php b/src/app/Auth/PinVerifier.php new file mode 100644 index 0000000..4bcf4c7 --- /dev/null +++ b/src/app/Auth/PinVerifier.php @@ -0,0 +1,126 @@ +db->fetch( + 'SELECT pin_hash FROM user WHERE id = :id AND is_active = 1', + ['id' => $userId], + ); + + $hash = is_string($row['pin_hash'] ?? null) ? (string) $row['pin_hash'] : ''; + + if ($hash === '') { + // Egalise le timing avec le chemin mauvais-PIN (verify argon2id) : sans + // ce leurre, un compte sans PIN (ou inactif/absent) repondrait plus vite, + // revelant par la latence quels comptes ont un PIN defini (anti-enumeration, + // meme posture que AuthService RG-2). Le leurre est mis en cache process. + $this->hasher->verifyDecoy($pin); + + return false; + } + + return $this->hasher->verify($pin, $hash); + } + + /** + * Modele "identifiant equipier + PIN" (RG-T13) : sur un poste a session + * partagee, l'individu qui realise l'action sensible se ré-authentifie par + * email + PIN. Resout l'utilisateur ACTIF par email, verifie le PIN contre son + * pin_hash, et renvoie son identite {id, role_id} (l'acteur ecrit dans + * audit_log) ou null. Email/PIN absent ou inconnu : verify leurre (timing). + * + * @return array{id: int, role_id: int}|null + */ + public function resolveActingUser(string $email, string $pin): ?array + { + if ($pin === '' || $email === '') { + $this->hasher->verifyDecoy($pin); + + return null; + } + + $row = $this->db->fetch( + 'SELECT id, role_id, pin_hash FROM user WHERE email = :email AND is_active = 1 LIMIT 1', + ['email' => $email], + ); + + $hash = is_string($row['pin_hash'] ?? null) ? (string) $row['pin_hash'] : ''; + + if ($hash === '' || !$this->hasher->verify($pin, $hash)) { + if ($hash === '') { + $this->hasher->verifyDecoy($pin); + } + + return null; + } + + return ['id' => (int) ($row['id'] ?? 0), 'role_id' => (int) ($row['role_id'] ?? 0)]; + } + + /** + * Paie le cout de hachage d'un leurre argon2id sans verifier de PIN reel. Sert + * au chemin "acteur verrouille" (RG-T22) : quand le throttle bloque AVANT toute + * verification, on paie quand meme ce cout pour egaliser le timing avec le + * chemin mauvais-PIN. Sans lui, une reponse verrouillee reviendrait en + * microsecondes (aucun verify) et trahirait l'etat de verrou par la latence. + */ + public function payTimingDecoy(string $pin): void + { + $this->hasher->verifyDecoy($pin); + } + + /** + * Politique de PIN a verifier cote serveur avant de hacher un nouveau PIN + * (P3, definition du PIN) : chiffres ASCII uniquement, bornes min ET max + * (RG-T18). ctype_digit garantit le charset numerique, ce qui rend strlen + * fiable comme nombre de caracteres. + */ + public function meetsLengthPolicy(string $pin): bool + { + $min = $this->config->int('STAFF_PIN_MIN_LENGTH', 4); + $max = $this->config->int('STAFF_PIN_MAX_LENGTH', 12); + + return $pin !== '' && ctype_digit($pin) && strlen($pin) >= $min && strlen($pin) <= $max; + } +} diff --git a/src/app/Auth/RoleRepository.php b/src/app/Auth/RoleRepository.php new file mode 100644 index 0000000..60dd2c9 --- /dev/null +++ b/src/app/Auth/RoleRepository.php @@ -0,0 +1,214 @@ +> + */ + public function allRoles(): array + { + return $this->db->fetchAll( + 'SELECT id, code, label, description, default_route, order_source, is_active ' + . 'FROM role ORDER BY id', + ); + } + + /** + * @return array|null + */ + public function findRole(int $id): ?array + { + return $this->db->fetch( + 'SELECT id, code, label, description, default_route, order_source, is_active ' + . 'FROM role WHERE id = :id', + ['id' => $id], + ); + } + + public function codeExists(string $code, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM role WHERE code = :code AND id <> :id', + ['code' => $code, 'id' => $exceptId], + ) !== null; + } + + /** + * Catalogue complet des permissions (fige au seed), pour peupler la matrice. + * + * @return array> + */ + public function allPermissions(): array + { + return $this->db->fetchAll('SELECT id, code, label FROM permission ORDER BY id'); + } + + /** + * @return list + */ + public function permissionIdsFor(int $roleId): array + { + $rows = $this->db->fetchAll( + 'SELECT permission_id FROM role_permission WHERE role_id = :id', + ['id' => $roleId], + ); + + return array_map(static fn (array $r): int => (int) ($r['permission_id'] ?? 0), $rows); + } + + /** + * Codes de permission d'un role (pour le diff d'audit RG-6 : add/remove). + * + * @return list + */ + public function permissionCodesFor(int $roleId): array + { + $rows = $this->db->fetchAll( + 'SELECT p.code FROM role_permission rp JOIN permission p ON p.id = rp.permission_id ' + . 'WHERE rp.role_id = :id ORDER BY p.code', + ['id' => $roleId], + ); + + return array_map(static fn (array $r): string => (string) ($r['code'] ?? ''), $rows); + } + + /** + * Reecrit la matrice d'un role (mlt 10.4 RG-1) : DELETE puis INSERT des paires + * selectionnees, dans UNE transaction. L'appelant a deja filtre les + * permission_id au catalogue existant (PRE-3). + * + * @param list $permissionIds + */ + public function setPermissions(int $roleId, array $permissionIds): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($roleId, $permissionIds): void { + $this->replacePermissions($db, $roleId, $permissionIds); + }); + } + + /** + * Variante SANS transaction propre : reecrit la matrice sur le $db fourni, pour + * que le controleur l'enrobe dans UNE transaction avec l'ecriture d'audit (RG-6, + * audit du diff dans la meme transaction que l'effet). Ne pas appeler hors d'une + * transaction de l'appelant. + * + * @param list $permissionIds + */ + public function replacePermissions(DatabaseInterface $db, int $roleId, array $permissionIds): void + { + $db->execute('DELETE FROM role_permission WHERE role_id = :id', ['id' => $roleId]); + foreach (array_values(array_unique($permissionIds)) as $permissionId) { + $db->execute( + 'INSERT INTO role_permission (role_id, permission_id) VALUES (:role, :perm)', + ['role' => $roleId, 'perm' => $permissionId], + ); + } + } + + /** + * Creation d'un role personnalise (mlt 10.4 RG-4). `is_active` pose cote serveur. + * Retourne l'id. Allowlist RG-T16. + * + * @param array{code: string, label: string, description: ?string, default_route: ?string, order_source: ?string} $data + */ + public function createRole(array $data): int + { + $this->db->execute( + 'INSERT INTO role (code, label, description, default_route, order_source, is_active) ' + . 'VALUES (:code, :label, :description, :route, :source, 1)', + [ + 'code' => $data['code'], + 'label' => $data['label'], + 'description' => $data['description'], + 'route' => $data['default_route'], + 'source' => $data['order_source'], + ], + ); + + return (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + } + + /** + * Mise a jour d'un role. Le `code` n'est PAS lie (immuable apres creation). + * + * @param array{label: string, description: ?string, default_route: ?string, order_source: ?string, is_active: int} $data + */ + public function updateRole(int $id, array $data): void + { + $this->db->execute( + 'UPDATE role SET label = :label, description = :description, default_route = :route, ' + . 'order_source = :source, is_active = :active WHERE id = :id', + [ + 'label' => $data['label'], + 'description' => $data['description'], + 'route' => $data['default_route'], + 'source' => $data['order_source'], + 'active' => $data['is_active'], + 'id' => $id, + ], + ); + } + + /** + * @return list + */ + public function visibleSources(int $roleId): array + { + $rows = $this->db->fetchAll( + 'SELECT source FROM role_visible_source WHERE role_id = :id', + ['id' => $roleId], + ); + + return array_map(static fn (array $r): string => (string) ($r['source'] ?? ''), $rows); + } + + /** + * Reecrit les sources visibles d'un role (delete-and-reinsert, tx). L'appelant + * filtre $sources a l'ENUM valide ('kiosk','counter','drive'). + * + * @param list $sources + */ + public function setVisibleSources(int $roleId, array $sources): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($roleId, $sources): void { + $this->replaceVisibleSources($db, $roleId, $sources); + }); + } + + /** + * Variante SANS transaction propre (cf. replacePermissions), pour enrobage par + * le controleur dans une transaction unique. + * + * @param list $sources + */ + public function replaceVisibleSources(DatabaseInterface $db, int $roleId, array $sources): void + { + $db->execute('DELETE FROM role_visible_source WHERE role_id = :id', ['id' => $roleId]); + foreach (array_values(array_unique($sources)) as $source) { + $db->execute( + 'INSERT INTO role_visible_source (role_id, source) VALUES (:role, :source)', + ['role' => $roleId, 'source' => $source], + ); + } + } +} diff --git a/src/app/Auth/SessionGuard.php b/src/app/Auth/SessionGuard.php new file mode 100644 index 0000000..1f7f45b --- /dev/null +++ b/src/app/Auth/SessionGuard.php @@ -0,0 +1,67 @@ +session->getInt('user_id'); + $roleId = $this->session->getInt('role_id'); + $loggedInAt = $this->session->getInt('logged_in_at'); + $lastActivity = $this->session->getInt('last_activity'); + + if ($userId === null || $roleId === null || $loggedInAt === null) { + return new GuardResult(false, null, null, 'no_session'); + } + + $idleLimit = $this->config->int('SESSION_LIFETIME_IDLE', 14400); + $absoluteLimit = $this->config->int('SESSION_LIFETIME_ABSOLUTE', 36000); + + if ($lastActivity === null || ($now - $lastActivity) > $idleLimit) { + return new GuardResult(false, null, null, 'idle_timeout'); + } + + if (($now - $loggedInAt) > $absoluteLimit) { + return new GuardResult(false, null, null, 'absolute_timeout'); + } + + // RG-T02 : is_active re-verifie a chaque requete (un compte desactive en + // cours de session perd l'acces des la requete suivante). + $row = $this->db->fetch('SELECT is_active FROM user WHERE id = :id', ['id' => $userId]); + + if ($row === null || (int) ($row['is_active'] ?? 0) !== 1) { + return new GuardResult(false, null, null, 'inactive'); + } + + $this->session->set('last_activity', $now); + + return new GuardResult(true, $userId, $roleId, null); + } +} diff --git a/src/app/Auth/SessionManager.php b/src/app/Auth/SessionManager.php new file mode 100644 index 0000000..17dbdc9 --- /dev/null +++ b/src/app/Auth/SessionManager.php @@ -0,0 +1,195 @@ + */ + private array $bag = []; + + public function __construct( + private readonly Config $config, + private readonly bool $testMode = false, + ) { + } + + /** + * Demarre la session du vhost admin avec des cookies durcis. Idempotent : + * le front controller peut l'avoir deja demarree avant le dispatch. + */ + public function start(): void + { + if ($this->testMode) { + return; + } + + if (session_status() === PHP_SESSION_ACTIVE) { + return; + } + + // Defense : ne pas tenter de poser le cookie si la sortie a commence. + if (headers_sent()) { + return; + } + + // lifetime=0 : cookie de session ; les bornes idle 4h / absolue 10h sont + // appliquees applicativement par SessionGuard (RG-6), pas par le cookie. + // secure (conditionnel HTTPS, cf. cookieSecure)+httponly+SameSite=Strict : + // back-office, aucune entree cross-site. + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'secure' => $this->cookieSecure(), + 'httponly' => true, + 'samesite' => 'Strict', + ]); + session_name($this->config->get('SESSION_NAME', 'WAKDO_SID') ?? 'WAKDO_SID'); + session_start(); + } + + /** + * Regenere l'identifiant de session (RG-3) : protege contre la fixation de + * session apres une authentification reussie. + */ + public function regenerate(): void + { + if ($this->testMode) { + return; + } + + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + } + + public function get(string $key): mixed + { + if ($this->testMode) { + return $this->bag[$key] ?? null; + } + + return $_SESSION[$key] ?? null; + } + + /** + * Accesseur type : evite qu'une valeur mixed de session ne file dans un + * parametre lie PDO ou un calcul d'entier (friction PHPStan L6). + * Les identifiants et timestamps stockes sont des entiers positifs. + */ + public function getInt(string $key): ?int + { + $value = $this->get($key); + + if (is_int($value)) { + return $value; + } + + if (is_string($value) && ctype_digit($value)) { + return (int) $value; + } + + return null; + } + + public function set(string $key, mixed $value): void + { + if ($this->testMode) { + $this->bag[$key] = $value; + + return; + } + + $_SESSION[$key] = $value; + } + + /** + * Efface les donnees de session (RG-1 de LOGOUT_USER). + */ + public function clear(): void + { + if ($this->testMode) { + $this->bag = []; + + return; + } + + $_SESSION = []; + } + + /** + * Expire le cookie de session cote client puis detruit la session serveur + * (RG-2 + RG-3 de LOGOUT_USER). Le cookie reprend les memes attributs durcis. + */ + public function destroy(): void + { + if ($this->testMode) { + $this->bag = []; + + return; + } + + if (ini_get('session.use_cookies') !== false) { + $name = session_name(); + if ($name !== false) { + setcookie($name, '', [ + 'expires' => time() - 3600, + 'path' => '/', + 'secure' => $this->cookieSecure(), + 'httponly' => true, + 'samesite' => 'Strict', + ]); + } + } + + if (session_status() === PHP_SESSION_ACTIVE) { + session_destroy(); + } + } + + /** + * Le cookie de session est marque Secure UNIQUEMENT sur une connexion HTTPS. + * En HTTP (dev / standalone local) un cookie Secure serait rejete par le + * navigateur et casserait la session. En prod, Traefik termine le TLS et + * transmet X-Forwarded-Proto=https ; l'app n'etant joignable que par ce proxy + * sur le reseau interne, cet en-tete est fiable ici. + */ + private function cookieSecure(): bool + { + $forwarded = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''; + if (is_string($forwarded) && strtolower($forwarded) === 'https') { + return true; + } + + $https = $_SERVER['HTTPS'] ?? ''; + if (is_string($https) && $https !== '' && strtolower($https) !== 'off') { + return true; + } + + return ((int) ($_SERVER['SERVER_PORT'] ?? 0)) === 443; + } + + public function id(): string + { + if ($this->testMode) { + return 'test-session'; + } + + $id = session_id(); + + return $id === false ? '' : $id; + } +} diff --git a/src/app/Auth/ThrottlePolicy.php b/src/app/Auth/ThrottlePolicy.php new file mode 100644 index 0000000..130a970 --- /dev/null +++ b/src/app/Auth/ThrottlePolicy.php @@ -0,0 +1,99 @@ +threshold) { + return 0; + } + + $exponent = $attempts - $this->threshold; + + // Garde anti-debordement : au-dela d'un exposant raisonnable, 2^exposant + // depasserait PHP_INT_MAX. Comme le resultat est de toute facon plafonne, + // on court-circuite des que la valeur ne peut que depasser le plafond. + if ($exponent >= 31) { + return $this->maxSeconds; + } + + $seconds = $this->baseSeconds * (2 ** $exponent); + + return (int) min($seconds, $this->maxSeconds); + } + + /** + * Vrai si le verrou ($lockoutUntil, datetime 'Y-m-d H:i:s' ou null) est + * strictement dans le futur a l'instant $now (timestamp Unix injecte pour + * des comparaisons deterministes en test). null/vide/illisible => pas de verrou. + */ + public function isLockedUntil(?string $lockoutUntil, int $now): bool + { + if ($lockoutUntil === null || $lockoutUntil === '') { + return false; + } + + $until = strtotime($lockoutUntil); + + return $until !== false && $until > $now; + } + + /** + * Construit la politique pour la dimension 'account' (par compte), 'ip' (par IP + * source) ou 'pin' (par utilisateur agissant, RG-T22). RG-8 precise "le meme + * backoff degressif" pour l'IP, donc la dimension IP reutilise base/max et prend + * IP_THROTTLE_MAX_ATTEMPTS comme seuil. La dimension 'pin' a ses PROPRES bornes + * (PIN_THROTTLE_*) : volontairement plus permissives que le login (base 30s, + * plafond 300s) car un faux positif bloque un manager en plein rush et le PIN + * est un controle de dissuasion (residuel Faible). + */ + public static function fromConfig(Config $config, string $dimension): self + { + if ($dimension === 'pin') { + return new self( + $config->int('PIN_THROTTLE_THRESHOLD', 5), + $config->int('PIN_THROTTLE_BASE_SECONDS', 30), + $config->int('PIN_THROTTLE_MAX_SECONDS', 300), + ); + } + + $base = $config->int('ACCOUNT_LOCKOUT_BASE_SECONDS', 60); + $max = $config->int('ACCOUNT_LOCKOUT_MAX_SECONDS', 900); + + if ($dimension === 'ip') { + return new self($config->int('IP_THROTTLE_MAX_ATTEMPTS', 20), $base, $max); + } + + return new self($config->int('ACCOUNT_LOCKOUT_THRESHOLD', 5), $base, $max); + } +} diff --git a/src/app/Auth/UserDirectory.php b/src/app/Auth/UserDirectory.php new file mode 100644 index 0000000..96af531 --- /dev/null +++ b/src/app/Auth/UserDirectory.php @@ -0,0 +1,41 @@ +db->fetch( + '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], + ); + + $first = is_string($row['first_name'] ?? null) ? $row['first_name'] : ''; + $last = is_string($row['last_name'] ?? null) ? $row['last_name'] : ''; + $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'] : '', + ]; + } +} diff --git a/src/app/Auth/UserRepository.php b/src/app/Auth/UserRepository.php new file mode 100644 index 0000000..096f210 --- /dev/null +++ b/src/app/Auth/UserRepository.php @@ -0,0 +1,193 @@ +> + */ + public function all(): array + { + return $this->db->fetchAll( + 'SELECT u.id, u.email, u.first_name, u.last_name, u.role_id, u.is_active, ' + . 'u.last_login_at, u.anonymized_at, r.label AS role_label, r.code AS role_code ' + . 'FROM user u JOIN role r ON r.id = u.role_id ' + . 'ORDER BY u.is_active DESC, u.last_name, u.first_name', + ); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + return $this->db->fetch( + 'SELECT id, email, first_name, last_name, role_id, is_active, anonymized_at ' + . 'FROM user WHERE id = :id', + ['id' => $id], + ); + } + + public function emailExists(string $email, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM user WHERE email = :email AND id <> :id', + ['email' => $email, 'id' => $exceptId], + ) !== null; + } + + /** Le role existe ET est actif (PRE-3 de CREATE_USER, vecteur d'escalade). */ + public function activeRoleExists(int $roleId): bool + { + return $this->db->fetch('SELECT id FROM role WHERE id = :id AND is_active = 1', ['id' => $roleId]) !== null; + } + + /** + * Creation (mlt 10.1). `is_active` est pose cote serveur (=1), pas lie a la + * requete (RG-T16). Le hash est argon2id, calcule par l'appelant. Retourne l'id. + * + * @param array{email: string, password_hash: string, first_name: string, last_name: string, role_id: int} $data + */ + public function create(array $data): int + { + $this->db->execute( + 'INSERT INTO user (email, password_hash, first_name, last_name, role_id, is_active) ' + . 'VALUES (:email, :hash, :first, :last, :role, 1)', + [ + 'email' => $data['email'], + 'hash' => $data['password_hash'], + 'first' => $data['first_name'], + 'last' => $data['last_name'], + 'role' => $data['role_id'], + ], + ); + + return (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + } + + /** + * Mise a jour (mlt 10.2). Allowlist RG-T16 : email/prenom/nom/role_id/is_active. + * Le mot de passe (re-hachage optionnel) et le PIN passent par des methodes + * dediees, jamais lies ici. + * + * @param array{email: string, first_name: string, last_name: string, role_id: int, is_active: int} $data + */ + public function update(int $id, array $data): void + { + $this->db->execute( + 'UPDATE user SET email = :email, first_name = :first, last_name = :last, ' + . 'role_id = :role, is_active = :active WHERE id = :id', + [ + 'email' => $data['email'], + 'first' => $data['first_name'], + 'last' => $data['last_name'], + 'role' => $data['role_id'], + 'active' => $data['is_active'], + 'id' => $id, + ], + ); + } + + /** Re-hachage du mot de passe par un admin (mlt 10.2 RG-1, reset cote admin). */ + public function setPasswordHash(int $id, string $hash): int + { + return $this->db->execute('UPDATE user SET password_hash = :hash WHERE id = :id', ['hash' => $hash, 'id' => $id]); + } + + /** + * Reinitialise le PIN d'un equipier (admin) : on le met a NULL plutot que d'en + * poser un (l'admin n'a pas a connaitre le PIN d'autrui) ; l'equipier le + * redefinit ensuite en self-service (ProfileController). + */ + public function clearPin(int $id): int + { + return $this->db->execute('UPDATE user SET pin_hash = NULL WHERE id = :id', ['id' => $id]); + } + + /** Desactivation (mlt 10.3) : soft, l'historique reste intact. */ + public function deactivate(int $id): int + { + return $this->db->execute('UPDATE user SET is_active = 0 WHERE id = :id', ['id' => $id]); + } + + /** + * Anonymisation RGPD (mlt 10.5 RG-1) : vide la PII en GARDANT la ligne (les FK + * entrantes stock_movement/customer_order/audit_log restent valides). Email -> + * placeholder unique en `.invalid` (RFC 2606), conserve l'unicite sans etre + * identifiant. Idempotence : ne reanonymise pas une ligne deja anonymisee + * (clause anonymized_at IS NULL) -> 0 ligne affectee si deja fait. + */ + public function anonymise(int $id): int + { + return $this->db->execute( + "UPDATE user SET email = CONCAT('anon-', id, '@wakdo.invalid'), first_name = '', " + . "last_name = '', password_hash = '', pin_hash = NULL, password_reset_token_hash = NULL, " + . 'is_active = 0, anonymized_at = NOW() WHERE id = :id AND anonymized_at IS NULL', + ['id' => $id], + ); + } + + /** + * Nombre d'administrateurs ACTIFS (role code 'admin'). Garde-fou : empeche de + * desactiver/anonymiser/retrograder le dernier admin actif (verrouillage total + * du back-office). Ce garde-fou va au-dela du mlt (qui ne borne que + * l'auto-desactivation) mais previent un lock-out irrecuperable. + */ + public function activeAdminCount(): int + { + return (int) ($this->db->fetch( + "SELECT COUNT(*) AS n FROM user u JOIN role r ON r.id = u.role_id " + . "WHERE r.code = 'admin' AND u.is_active = 1", + )['n'] ?? 0); + } + + /** L'utilisateur a-t-il le role admin (actif ou non) ? */ + public function isAdmin(int $id): bool + { + return $this->db->fetch( + "SELECT u.id FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id AND r.code = 'admin'", + ['id' => $id], + ) !== null; + } + + /** + * Retourne le nombre de lignes affectees (1 attendu). Le hash argon2id + * change a chaque appel (sel aleatoire), donc une cible existante donne + * toujours 1 ; 0 revele une cible inexistante (defense en profondeur). + */ + public function setPinHash(int $userId, string $hash): int + { + return $this->db->execute('UPDATE user SET pin_hash = :hash WHERE id = :id', ['hash' => $hash, 'id' => $userId]); + } + + public function pinIsSet(int $userId): bool + { + return $this->db->fetch( + 'SELECT id FROM user WHERE id = :id AND pin_hash IS NOT NULL', + ['id' => $userId], + ) !== null; + } +} diff --git a/src/app/Catalogue/AllergenRepository.php b/src/app/Catalogue/AllergenRepository.php new file mode 100644 index 0000000..7b916dd --- /dev/null +++ b/src/app/Catalogue/AllergenRepository.php @@ -0,0 +1,32 @@ + double sans base). + */ +class AllergenRepository +{ + public function __construct(private readonly DatabaseInterface $db) + { + } + + /** + * Les allergenes references, tries par id (ordre INCO du seed). + * + * @return list> + */ + public function all(): array + { + return $this->db->fetchAll('SELECT id, code, name FROM allergen ORDER BY id'); + } +} diff --git a/src/app/Catalogue/CategoryRepository.php b/src/app/Catalogue/CategoryRepository.php new file mode 100644 index 0000000..c76458f --- /dev/null +++ b/src/app/Catalogue/CategoryRepository.php @@ -0,0 +1,120 @@ +> + */ + public function all(): array + { + return $this->db->fetchAll( + 'SELECT id, name, slug, image_path, display_order, is_active ' + . 'FROM category ORDER BY display_order, name', + ); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + return $this->db->fetch( + 'SELECT id, name, slug, image_path, display_order, is_active FROM category WHERE id = :id', + ['id' => $id], + ); + } + + /** + * Lecture publique pour la borne (P4, docs/api/conventions.md 5.2) : seulement + * les categories actives, triees comme la liste back-office. Le flag is_active + * n'est pas selectionne (toutes celles-ci le sont) -> rien d'inutile a la borne. + * + * @return array> + */ + public function activeForCatalogue(): array + { + return $this->db->fetchAll( + 'SELECT id, name, slug, image_path, display_order ' + . 'FROM category WHERE is_active = 1 ORDER BY display_order, name', + ); + } + + public function nameExists(string $name, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM category WHERE name = :name AND id <> :id LIMIT 1', + ['name' => $name, 'id' => $exceptId], + ) !== null; + } + + public function slugExists(string $slug, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM category WHERE slug = :slug AND id <> :id LIMIT 1', + ['slug' => $slug, 'id' => $exceptId], + ) !== null; + } + + /** + * @param array{name: string, slug: string, image_path: ?string, display_order: int, is_active: int} $data + */ + public function create(array $data): void + { + $this->db->execute( + 'INSERT INTO category (name, slug, image_path, display_order, is_active) ' + . 'VALUES (:name, :slug, :image, :ord, :active)', + [ + 'name' => $data['name'], + 'slug' => $data['slug'], + 'image' => $data['image_path'], + 'ord' => $data['display_order'], + 'active' => $data['is_active'], + ], + ); + } + + /** + * @param array{name: string, slug: string, image_path: ?string, display_order: int} $data + */ + public function update(int $id, array $data): void + { + $this->db->execute( + 'UPDATE category SET name = :name, slug = :slug, image_path = :image, display_order = :ord WHERE id = :id', + [ + 'name' => $data['name'], + 'slug' => $data['slug'], + 'image' => $data['image_path'], + 'ord' => $data['display_order'], + 'id' => $id, + ], + ); + } + + public function setActive(int $id, bool $active): void + { + $this->db->execute( + 'UPDATE category SET is_active = :active WHERE id = :id', + ['active' => $active ? 1 : 0, 'id' => $id], + ); + } +} diff --git a/src/app/Catalogue/IngredientRepository.php b/src/app/Catalogue/IngredientRepository.php new file mode 100644 index 0000000..ad7a814 --- /dev/null +++ b/src/app/Catalogue/IngredientRepository.php @@ -0,0 +1,315 @@ + 0) = 100 % de + * reference ; stock_pct et la bande (normal/low/critical) sont CALCULES, jamais + * stockes (stockPct/stockBand). stock_quantity est SIGNE : il peut devenir + * negatif quand les ventes depassent le stock compte (survente assumee, remontee + * au manager) ; le systeme ne bloque jamais une commande sur le stock. + * + * Le stock ne bouge JAMAIS par ecriture directe de stock_quantity hors creation : + * - restock(...) : +N packs (mlt 9.1), sans PIN, acteur capture par permission ; + * - inventoryCount(...) : comptage absolu (mlt 9.2), PIN, ecrit une ligne MEME si delta=0. + * Chaque mouvement insere une ligne stock_movement (journal append-only) dans la + * MEME transaction que la mise a jour du stock (RG-T08). L'imputabilite passe par + * stock_movement.user_id, PAS par audit_log (RG-T14 exclut le stock du double-journal). + * + * Topologie FK (db/migrations/0001) : ingredient est reference par product_ingredient + * (RESTRICT) et stock_movement (RESTRICT) -> la suppression dure est bloquee des + * qu'une recette ou un mouvement existe ; le controleur traduit la violation + * (SQLSTATE 23000) en 409 et propose la desactivation (is_active). + */ +final class IngredientRepository +{ + public function __construct(private readonly DatabaseInterface $db) + { + } + + /** + * Liste pour le back-office, enrichie du pourcentage et de la bande calcules. + * + * @return array> + */ + public function all(): array + { + $rows = $this->db->fetchAll( + 'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, ' + . 'energy_kcal_100g, nutrition_source, nutrition_fetched_at, ' + . 'low_stock_pct, critical_stock_pct, is_active FROM ingredient ORDER BY name', + ); + + return array_map([self::class, 'withStatus'], $rows); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + $row = $this->db->fetch( + 'SELECT id, name, unit, stock_quantity, stock_capacity, pack_size, pack_label, ' + . 'energy_kcal_100g, nutrition_source, nutrition_fetched_at, ' + . 'low_stock_pct, critical_stock_pct, is_active FROM ingredient WHERE id = :id', + ['id' => $id], + ); + + return $row === null ? null : self::withStatus($row); + } + + public function nameExists(string $name, int $exceptId = 0): bool + { + return $this->db->fetch( + 'SELECT id FROM ingredient WHERE name = :name AND id <> :id', + ['name' => $name, 'id' => $exceptId], + ) !== null; + } + + /** + * Creation : pose les valeurs initiales, stock_quantity inclus (point de + * depart du stock). Allowlist RG-T16. + * + * @param array{name: string, unit: string, stock_quantity: int, stock_capacity: int, pack_size: int, pack_label: ?string, low_stock_pct: int, critical_stock_pct: int, is_active: int} $data + */ + public function create(array $data): void + { + $this->db->execute( + 'INSERT INTO ingredient (name, unit, stock_quantity, stock_capacity, pack_size, ' + . 'pack_label, low_stock_pct, critical_stock_pct, is_active) ' + . 'VALUES (:name, :unit, :qty, :cap, :pack, :label, :low, :crit, :active)', + [ + 'name' => $data['name'], + 'unit' => $data['unit'], + 'qty' => $data['stock_quantity'], + 'cap' => $data['stock_capacity'], + 'pack' => $data['pack_size'], + 'label' => $data['pack_label'], + 'low' => $data['low_stock_pct'], + 'crit' => $data['critical_stock_pct'], + 'active' => $data['is_active'], + ], + ); + } + + /** + * Mise a jour des attributs de definition. Allowlist RG-T16 : stock_quantity + * et is_active NE sont PAS modifiables ici. Le stock ne bouge que via + * restock/inventoryCount (ledger) ; is_active bascule via setActive + * (soft-delete). Les lier ici ouvrirait une affectation de masse non voulue. + * + * @param array{name: string, unit: string, stock_capacity: int, pack_size: int, pack_label: ?string, low_stock_pct: int, critical_stock_pct: int} $data + */ + public function update(int $id, array $data): void + { + $this->db->execute( + 'UPDATE ingredient SET name = :name, unit = :unit, stock_capacity = :cap, ' + . 'pack_size = :pack, pack_label = :label, low_stock_pct = :low, ' + . 'critical_stock_pct = :crit WHERE id = :id', + [ + 'name' => $data['name'], + 'unit' => $data['unit'], + 'cap' => $data['stock_capacity'], + 'pack' => $data['pack_size'], + 'label' => $data['pack_label'], + 'low' => $data['low_stock_pct'], + 'crit' => $data['critical_stock_pct'], + 'id' => $id, + ], + ); + } + + public function setActive(int $id, bool $active): int + { + return $this->db->execute( + 'UPDATE ingredient SET is_active = :a WHERE id = :id', + ['a' => $active ? 1 : 0, 'id' => $id], + ); + } + + /** + * Suppression dure. Bloquee par FK RESTRICT (product_ingredient / stock_movement) + * des qu'une recette ou un mouvement reference l'ingredient ; le controleur + * attrape SQLSTATE 23000 -> 409 et propose la desactivation. + */ + public function delete(int $id): int + { + return $this->db->execute('DELETE FROM ingredient WHERE id = :id', ['id' => $id]); + } + + /** + * Pre-verification FK-safe : l'ingredient est-il reference par une recette + * (product_ingredient) ou un mouvement de stock (stock_movement) ? Les deux + * FK sont RESTRICT, donc l'un ou l'autre bloque la suppression dure. + */ + /** + * Enregistre les donnees nutritionnelles importees d'une source externe + * (Cr 3.a.3). Allowlist de colonnes (RG-T16) : seules energy / source / + * fetched_at sont ecrites ; l'horodatage est pose cote SQL (NOW()). + * + * @param array{energy_kcal_100g:int, source:string} $data + */ + public function setNutrition(int $id, array $data): int + { + return $this->db->execute( + 'UPDATE ingredient SET energy_kcal_100g = :kcal, nutrition_source = :src, ' + . 'nutrition_fetched_at = NOW() WHERE id = :id', + ['kcal' => $data['energy_kcal_100g'], 'src' => $data['source'], 'id' => $id], + ); + } + + public function isReferenced(int $id): bool + { + if ($this->db->fetch('SELECT ingredient_id FROM product_ingredient WHERE ingredient_id = :id LIMIT 1', ['id' => $id]) !== null) { + return true; + } + + return $this->db->fetch('SELECT id FROM stock_movement WHERE ingredient_id = :id LIMIT 1', ['id' => $id]) !== null; + } + + /** + * Reapprovisionnement (mlt 9.1) : +N packs => stock += N * pack_size, et une + * ligne stock_movement(restock) dans la MEME transaction (RG-T08). Sans PIN : + * $userId est l'acteur de session (capture par la permission stock.manage, + * RG-4), pas un acteur resolu par PIN. Les bornes d'entree (packs >= 1, mlt 9.1 + * PRE-3) sont validees par l'appelant (controleur, RG-T18), pas ici. + */ + public function restock(int $id, int $packs, ?int $userId, ?string $note = null): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($id, $packs, $userId, $note): void { + $packSize = (int) ($db->fetch('SELECT pack_size FROM ingredient WHERE id = :id', ['id' => $id])['pack_size'] ?? 0); + $delta = $packs * $packSize; + $db->execute( + 'UPDATE ingredient SET stock_quantity = stock_quantity + :delta WHERE id = :id', + ['delta' => $delta, 'id' => $id], + ); + $this->insertMovement($db, $id, 'restock', $delta, $userId, $note); + }); + } + + /** + * Inventaire (mlt 9.2) : comptage physique absolu => stock_quantity = compte, + * et une ligne stock_movement(inventory_correction, delta = compte - actuel) + * dans la MEME transaction. RG-3 : la ligne est ecrite MEME si delta = 0 (un + * comptage conforme reste une preuve de controle a tracer). $userId est + * l'acteur resolu par le PIN (RG-T13). La borne d'entree (compte >= 0, mlt 9.2 + * PRE-3) est validee par l'appelant (controleur, RG-T18), pas ici. + */ + public function inventoryCount(int $id, int $countedQuantity, ?int $userId, ?string $note = null): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($id, $countedQuantity, $userId, $note): void { + $current = (int) ($db->fetch('SELECT stock_quantity FROM ingredient WHERE id = :id', ['id' => $id])['stock_quantity'] ?? 0); + $delta = $countedQuantity - $current; + $db->execute( + 'UPDATE ingredient SET stock_quantity = :q WHERE id = :id', + ['q' => $countedQuantity, 'id' => $id], + ); + $this->insertMovement($db, $id, 'inventory_correction', $delta, $userId, $note); + }); + } + + /** + * Registre append-only des mouvements d'un ingredient, du plus recent au plus + * ancien, BORNE (mlt 9.3 READ_STOCK RG-3 prescrit LIMIT :n ; stock_movement + * croit a chaque vente, on ne materialise pas tout). La FK order_id reste NULL + * pour restock/inventory (renseignee cote commande en P4). La visibilite de + * user_id (RG-4 : manager/admin seulement) est appliquee par le controleur, pas ici. + * + * @return array> + */ + public function movements(int $id, int $limit = 50): array + { + // La borne est interpolee en entier (cast int + plancher 1) plutot que + // liee en placeholder : avec ATTR_EMULATE_PREPARES=false (Database), un + // ':limit' lie comme chaine fait echouer MariaDB sur LIMIT. Un int n'a + // aucun risque d'injection. + $bounded = max(1, $limit); + + return $this->db->fetchAll( + 'SELECT id, ingredient_id, movement_type, delta, order_id, user_id, note, created_at ' + . 'FROM stock_movement WHERE ingredient_id = :id ORDER BY created_at DESC, id DESC ' + . 'LIMIT ' . $bounded, + ['id' => $id], + ); + } + + private function insertMovement(DatabaseInterface $db, int $ingredientId, string $type, int $delta, ?int $userId, ?string $note): void + { + $db->execute( + 'INSERT INTO stock_movement (ingredient_id, movement_type, delta, order_id, user_id, note) ' + . 'VALUES (:ingredient, :type, :delta, NULL, :user, :note)', + [ + 'ingredient' => $ingredientId, + 'type' => $type, + 'delta' => $delta, + 'user' => $userId, + 'note' => $note, + ], + ); + } + + /** + * Pourcentage de stock = round(quantity / capacity * 100). Calcule, non stocke. + * Garde anti division par zero (stock_capacity porte un CHECK > 0 en base). + */ + public static function stockPct(int $quantity, int $capacity): int + { + if ($capacity <= 0) { + return 0; + } + + return (int) round($quantity * 100 / $capacity); + } + + /** + * Bande a 3 niveaux (mcd 5.3), en arithmetique entiere (pas de flottant) : + * - critical : quantity <= capacity * critical_pct / 100 (rupture auto) + * - low : quantity <= capacity * low_pct / 100 (alerte, encore commandable) + * - normal : au-dessus. + * Un stock negatif (survente) tombe en critical. critical_pct < low_pct est + * garanti par un CHECK de table. + */ + public static function stockBand(int $quantity, int $capacity, int $lowPct, int $critPct): string + { + if ($capacity <= 0) { + return 'critical'; + } + + $scaled = $quantity * 100; + if ($scaled <= $capacity * $critPct) { + return 'critical'; + } + if ($scaled <= $capacity * $lowPct) { + return 'low'; + } + + return 'normal'; + } + + /** + * Enrichit une ligne ingredient des champs calcules stock_pct et stock_band. + * + * @param array $row + * @return array + */ + private static function withStatus(array $row): array + { + $quantity = (int) ($row['stock_quantity'] ?? 0); + $capacity = (int) ($row['stock_capacity'] ?? 0); + $lowPct = (int) ($row['low_stock_pct'] ?? 0); + $critPct = (int) ($row['critical_stock_pct'] ?? 0); + + $row['stock_pct'] = self::stockPct($quantity, $capacity); + $row['stock_band'] = self::stockBand($quantity, $capacity, $lowPct, $critPct); + + return $row; + } +} diff --git a/src/app/Catalogue/MenuRepository.php b/src/app/Catalogue/MenuRepository.php new file mode 100644 index 0000000..18bf773 --- /dev/null +++ b/src/app/Catalogue/MenuRepository.php @@ -0,0 +1,275 @@ + la suppression dure est bloquee si le menu + * est reference par une commande historique (mlt 8.6 RG-1 : le controleur + * traduit la violation en 409 et propose la desactivation). + * + * create() et update() ecrivent menu + slots + options dans UNE transaction + * (RG-T08). update() reconstruit les slots en delete-and-reinsert (mlt 8.5 RG-2). + */ +final class MenuRepository +{ + public function __construct(private readonly DatabaseInterface $db) + { + } + + /** + * Liste pour le back-office, avec le libelle de categorie et le nom du burger. + * + * @return array> + */ + public function all(): array + { + return $this->db->fetchAll( + 'SELECT m.id, m.category_id, m.burger_product_id, m.name, m.price_normal_cents, ' + . 'm.price_maxi_cents, m.is_available, m.display_order, ' + . 'c.name AS category_name, p.name AS burger_name ' + . 'FROM menu m ' + . 'JOIN category c ON c.id = m.category_id ' + . 'JOIN product p ON p.id = m.burger_product_id ' + . 'ORDER BY m.display_order, m.name', + ); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + return $this->db->fetch( + 'SELECT id, category_id, burger_product_id, name, price_normal_cents, ' + . 'price_maxi_cents, is_available, display_order FROM menu WHERE id = :id', + ['id' => $id], + ); + } + + /** + * Lecture publique pour la borne (P4, docs/api/conventions.md 5.2) : menus + * disponibles (is_available = 1) ET en categorie active (c.is_active = 1). + * Projection enrichie (description, image_path) absente de all() back-office. + * Liste LEGERE : sans les slots (le detail /api/menus/{id} les porte). La + * disponibilite du burger impose (B1) reste un raffinement de la dispo calculee + * RG-T21, differe au seed des recettes. + * + * @return array> + */ + public function availableForCatalogue(): array + { + return $this->db->fetchAll( + 'SELECT m.id, m.category_id, m.burger_product_id, m.name, m.description, ' + . 'm.price_normal_cents, m.price_maxi_cents, m.image_path, m.display_order ' + . 'FROM menu m JOIN category c ON c.id = m.category_id ' + . 'WHERE m.is_available = 1 AND c.is_active = 1 ' + . 'ORDER BY m.display_order, m.name', + ); + } + + /** + * Detail menu pour la borne : meme projection que la liste, seulement si le + * menu est disponible en categorie active ; sinon null (le controleur rend + * 404). Les slots sont charges a part (slotsWithOptions) puis assembles par le + * controleur. + * + * @return array|null + */ + public function findForCatalogue(int $id): ?array + { + return $this->db->fetch( + 'SELECT m.id, m.category_id, m.burger_product_id, m.name, m.description, ' + . 'm.price_normal_cents, m.price_maxi_cents, m.image_path, m.display_order ' + . 'FROM menu m JOIN category c ON c.id = m.category_id ' + . 'WHERE m.id = :id AND m.is_available = 1 AND c.is_active = 1', + ['id' => $id], + ); + } + + /** + * Slots d'un menu (ordonnes), chacun avec la liste de ses product_id eligibles. + * Une seule requete (LEFT JOIN) regroupee en PHP par slot. + * + * @return list}> + */ + public function slotsWithOptions(int $menuId): array + { + $rows = $this->db->fetchAll( + 'SELECT s.id, s.name, s.slot_type, s.is_required, s.display_order, o.product_id ' + . 'FROM menu_slot s ' + . 'LEFT JOIN menu_slot_option o ON o.menu_slot_id = s.id ' + . 'WHERE s.menu_id = :id ORDER BY s.display_order, s.id', + ['id' => $menuId], + ); + + /** @var array}> $slots */ + $slots = []; + foreach ($rows as $r) { + $sid = (int) ($r['id'] ?? 0); + if (!isset($slots[$sid])) { + $slots[$sid] = [ + 'id' => $sid, + 'name' => (string) ($r['name'] ?? ''), + 'slot_type' => (string) ($r['slot_type'] ?? ''), + 'is_required' => (int) ($r['is_required'] ?? 0), + 'display_order' => (int) ($r['display_order'] ?? 0), + 'option_product_ids' => [], + ]; + } + if (($r['product_id'] ?? null) !== null) { + $slots[$sid]['option_product_ids'][] = (int) $r['product_id']; + } + } + + return array_values($slots); + } + + public function categoryExists(int $id): bool + { + return $this->db->fetch('SELECT id FROM category WHERE id = :id', ['id' => $id]) !== null; + } + + public function productExists(int $id): bool + { + return $this->db->fetch('SELECT id FROM product WHERE id = :id', ['id' => $id]) !== null; + } + + /** + * Pre-verification FK-safe (mlt 8.6 RG-1) : le menu est-il reference par une + * ligne de commande historique ? La FK order_item.menu_id est RESTRICT. + */ + public function isReferencedByOrders(int $id): bool + { + return $this->db->fetch('SELECT menu_id FROM order_item WHERE menu_id = :id LIMIT 1', ['id' => $id]) !== null; + } + + /** + * Cree le menu et sa configuration de slots dans UNE transaction (mlt 8.4 RG-2). + * Retourne l'id du menu cree. + * + * @param array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int} $data + * @param list}> $slots + */ + public function create(array $data, array $slots): int + { + $newId = 0; + $this->db->transaction(function (DatabaseInterface $db) use ($data, $slots, &$newId): void { + $db->execute( + 'INSERT INTO menu (category_id, burger_product_id, name, price_normal_cents, ' + . 'price_maxi_cents, is_available, display_order) ' + . 'VALUES (:category, :burger, :name, :pnormal, :pmaxi, :available, :ord)', + $this->bindMenu($data), + ); + $newId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + $this->insertSlots($db, $newId, $slots); + }); + + return $newId; + } + + /** + * Met a jour le menu et RECONSTRUIT ses slots (delete-and-reinsert, mlt 8.5 + * RG-2) dans UNE transaction : un edit de la configuration de slots est plus + * simple et sur a re-poser entierement qu'a reconcilier en place. + * + * @param array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int} $data + * @param list}> $slots + */ + public function update(int $id, array $data, array $slots): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($id, $data, $slots): void { + $db->execute( + 'UPDATE menu SET category_id = :category, burger_product_id = :burger, name = :name, ' + . 'price_normal_cents = :pnormal, price_maxi_cents = :pmaxi, is_available = :available, ' + . 'display_order = :ord WHERE id = :id', + $this->bindMenu($data) + ['id' => $id], + ); + // Options d'abord (FK vers slot), puis slots, puis re-insertion. + $db->execute( + 'DELETE FROM menu_slot_option WHERE menu_slot_id IN ' + . '(SELECT id FROM menu_slot WHERE menu_id = :id)', + ['id' => $id], + ); + $db->execute('DELETE FROM menu_slot WHERE menu_id = :id', ['id' => $id]); + $this->insertSlots($db, $id, $slots); + }); + } + + /** + * Suppression dure. CASCADE retire menu_slot + menu_slot_option ; + * order_item.menu_id (RESTRICT) bloque si une commande historique reference le + * menu (le controleur attrape SQLSTATE 23000 -> 409). + */ + public function delete(int $id): int + { + return $this->db->execute('DELETE FROM menu WHERE id = :id', ['id' => $id]); + } + + public function setActive(int $id, bool $active): int + { + return $this->db->execute( + 'UPDATE menu SET is_available = :a WHERE id = :id', + ['a' => $active ? 1 : 0, 'id' => $id], + ); + } + + /** + * Insere les slots d'un menu et leurs options (helper partage create/update). + * + * @param list}> $slots + */ + private function insertSlots(DatabaseInterface $db, int $menuId, array $slots): void + { + foreach ($slots as $slot) { + $db->execute( + 'INSERT INTO menu_slot (menu_id, name, slot_type, is_required, display_order) ' + . 'VALUES (:menu, :name, :type, :required, :ord)', + [ + 'menu' => $menuId, + 'name' => $slot['name'], + 'type' => $slot['slot_type'], + 'required' => $slot['is_required'], + 'ord' => $slot['display_order'], + ], + ); + $slotId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + foreach ($slot['options'] as $productId) { + $db->execute( + 'INSERT INTO menu_slot_option (menu_slot_id, product_id) VALUES (:slot, :product)', + ['slot' => $slotId, 'product' => $productId], + ); + } + } + } + + /** + * Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees. + * + * @param array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int} $data + * @return array + */ + private function bindMenu(array $data): array + { + return [ + 'category' => $data['category_id'], + 'burger' => $data['burger_product_id'], + 'name' => $data['name'], + 'pnormal' => $data['price_normal_cents'], + 'pmaxi' => $data['price_maxi_cents'], + 'available' => $data['is_available'], + 'ord' => $data['display_order'], + ]; + } +} diff --git a/src/app/Catalogue/NutritionGateway.php b/src/app/Catalogue/NutritionGateway.php new file mode 100644 index 0000000..22e1491 --- /dev/null +++ b/src/app/Catalogue/NutritionGateway.php @@ -0,0 +1,23 @@ + l'appelant affiche + * un message sans interrompre l'usage. + */ +final class OpenFoodFactsGateway implements NutritionGateway +{ + private const ENDPOINT = 'https://world.openfoodfacts.org/cgi/search.pl'; + + public function __construct(private readonly int $timeoutSeconds = 5) + { + } + + public function lookupByName(string $name): ?array + { + $name = trim($name); + if ($name === '' || !function_exists('curl_init')) { + return null; + } + + $url = self::ENDPOINT . '?' . http_build_query([ + 'search_terms' => $name, + 'search_simple' => 1, + 'action' => 'process', + 'json' => 1, + 'page_size' => 1, + 'fields' => 'product_name,nutriments', + ]); + + $handle = curl_init($url); + if ($handle === false) { + return null; + } + curl_setopt_array($handle, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => $this->timeoutSeconds, + CURLOPT_USERAGENT => 'Wakdo/1.0 (projet pedagogique RNCP)', + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + $body = curl_exec($handle); + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + curl_close($handle); + + if (!is_string($body) || $status < 200 || $status >= 300) { + return null; + } + + return $this->parse($body); + } + + /** + * Extrait l'apport energetique du premier produit de la reponse OpenFoodFacts. + * Isole pour etre testable sans reseau. + * + * @return array{energy_kcal_100g:int, source:string}|null + */ + public function parse(string $body): ?array + { + $data = json_decode($body, true); + if (!is_array($data) || !isset($data['products'][0]['nutriments']) + || !is_array($data['products'][0]['nutriments'])) { + return null; + } + + $kcal = $data['products'][0]['nutriments']['energy-kcal_100g'] ?? null; + if (!is_numeric($kcal)) { + return null; + } + + $kcal = (int) round((float) $kcal); + if ($kcal < 0 || $kcal > 65535) { + return null; + } + + return ['energy_kcal_100g' => $kcal, 'source' => 'OpenFoodFacts']; + } +} diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php new file mode 100644 index 0000000..98a46de --- /dev/null +++ b/src/app/Catalogue/ProductRepository.php @@ -0,0 +1,360 @@ + 409 Conflit, plutot que de pre-tester chaque reference. + * - CASCADE : product_ingredient (la recette appartient au produit ; la + * supprimer avec le produit est voulu). La suppression n'est donc PAS bloquee + * par une recette existante. Le nombre de lignes cascade-supprimees est compte + * (compositionCount) et trace dans le resume d'audit par ProductController::destroy + * (dette #27 close) pour ne laisser aucune perte hors-trace. + */ +final class ProductRepository +{ + public function __construct(private readonly DatabaseInterface $db) + { + } + + /** + * Liste pour le back-office, avec le libelle de categorie. + * + * @return array> + */ + public function all(): array + { + return $this->db->fetchAll( + 'SELECT p.id, p.category_id, p.name, p.price_cents, p.vat_rate, p.is_available, ' + . 'p.display_order, c.name AS category_name ' + . 'FROM product p JOIN category c ON c.id = p.category_id ' + . 'ORDER BY p.display_order, p.name', + ); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + // maxi_variant_product_id : expose la variante Grande de l'accompagnement + // pour que OrderRepository::resolveSelections puisse substituer au format + // Maxi (cote serveur uniquement ; la borne n'en a pas besoin). + return $this->db->fetch( + 'SELECT id, category_id, name, description, price_cents, maxi_variant_product_id, ' + . 'vat_rate, image_path, is_available, display_order FROM product WHERE id = :id', + ['id' => $id], + ); + } + + /** + * Lecture publique pour la borne (P4, docs/api/conventions.md 5.2) : produits + * commandables seulement (is_available = 1) ET dont la categorie est active + * (c.is_active = 1), pour ne jamais proposer un produit dont l'onglet de + * categorie n'apparait pas. vat_rate n'est pas selectionne : le calcul fiscal + * vit cote serveur a la commande, la borne ne l'affiche pas. Filtre de + * disponibilite = flag is_available ; la dispo CALCULEE RG-T21 (exclusion des + * ruptures auto via autoUnavailableIds) se branchera au seed des recettes. + * + * 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 + * picker de taille de la base, qui les expose par sizesForProduct(). size_cl est + * remonte pour que le controleur sache quelles bases portent une dimension taille. + * + * @return array> + */ + public function availableForCatalogue(): array + { + // mv.name (LEFT JOIN sur la variante Maxi) : la borne affiche ce nom quand le + // menu est commande en Maxi, sans refaire un aller-retour pour resoudre la + // variante. NULL si le produit n'a pas de variante Maxi. La SUBSTITUTION reelle + // a la commande reste serveur (OrderRepository::resolveSelections) ; ici c'est + // un libelle d'affichage seulement. + return $this->db->fetchAll( + 'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, p.size_cl, ' + . 'p.image_path, p.display_order, mv.name AS maxi_variant_name ' + . 'FROM product p JOIN category c ON c.id = p.category_id ' + . 'LEFT JOIN product mv ON mv.id = p.maxi_variant_product_id ' + . 'WHERE p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL ' + . 'ORDER BY p.display_order, p.name', + ); + } + + /** + * Tailles commandables d'un produit de base (R4) : la base elle-meme + ses + * variantes de taille disponibles, triees par volume croissant (30 cl puis + * 50 cl). Chaque ligne porte son propre product_id et son price_cents : la + * borne resout la taille choisie en product_id, le domaine commande facture + * ce product_id sans logique de taille (flux inchange). Seules les variantes + * disponibles (is_available = 1) sont remontees ; la base est toujours incluse + * (l'appelant ne demande les tailles que pour une base deja affichable). + * NULLs de size_cl tries en premier (la base sans dimension n'a pas de variante, + * ce cas ne remonte qu'une ligne). + * + * @return array> + */ + public function sizesForProduct(int $baseId): array + { + return $this->db->fetchAll( + 'SELECT id, size_cl, price_cents FROM product ' + . 'WHERE (id = :base OR base_product_id = :base) AND is_available = 1 ' + . 'ORDER BY size_cl IS NULL DESC, size_cl, id', + ['base' => $baseId], + ); + } + + /** + * Toutes les tailles des produits AYANT au moins une variante de taille (R4), + * indexees par id de la base, en UNE requete (evite le N+1 sur la liste + * /api/products, cache-friendly cote borne). Ne remonte que les bases dont une + * variante existe : un produit mono-taille n'apparait pas (le controleur lui + * laisse alors un tableau sizes vide). La base est incluse parmi ses tailles. + * Lignes triees par base puis volume croissant (30 cl avant 50 cl). + * + * @return array>> base_id => [{id, size_cl, price_cents}, ...] + */ + public function sizesByBase(): array + { + $rows = $this->db->fetchAll( + 'SELECT COALESCE(p.base_product_id, p.id) AS base_id, p.id, p.size_cl, p.price_cents ' + . 'FROM product p ' + . 'WHERE p.is_available = 1 AND (' + . ' p.base_product_id IS NOT NULL ' + . ' OR EXISTS (SELECT 1 FROM product v WHERE v.base_product_id = p.id AND v.is_available = 1)' + . ') ' + . 'ORDER BY base_id, p.size_cl IS NULL DESC, p.size_cl, p.id', + ); + + /** @var array>> $byBase */ + $byBase = []; + foreach ($rows as $row) { + $baseId = (int) ($row['base_id'] ?? 0); + $byBase[$baseId][] = [ + 'id' => (int) ($row['id'] ?? 0), + 'size_cl' => (int) ($row['size_cl'] ?? 0), + 'price_cents' => (int) ($row['price_cents'] ?? 0), + ]; + } + + return $byBase; + } + + /** + * Detail produit pour la borne : la meme projection que la liste, et seulement + * si le produit est commandable (is_available = 1) en categorie active ; sinon + * null (le controleur rend 404). Un produit retire ou en categorie masquee est + * donc invisible meme par lien direct. + * + * base_product_id IS NULL (R4) : meme invariant que availableForCatalogue() -- + * une VARIANTE de taille n'est jamais une fiche detail autonome. Un acces direct + * a /api/products/{idVariante} rend donc null -> 404 ; la 50 cl ne s'atteint que + * via le picker de taille de sa base, jamais par lien direct. + * + * @return array|null + */ + public function findForCatalogue(int $id): ?array + { + // Meme projection (et meme LEFT JOIN variante Maxi) que la liste : la borne + // recoit maxi_variant_name aussi par lien direct (NULL si pas de variante). + return $this->db->fetch( + 'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, ' + . '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.id = :id AND p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL', + ['id' => $id], + ); + } + + public function categoryExists(int $categoryId): bool + { + return $this->db->fetch('SELECT id FROM category WHERE id = :id', ['id' => $categoryId]) !== null; + } + + /** + * @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data + */ + public function create(array $data): void + { + $this->db->execute( + 'INSERT INTO product (category_id, name, description, price_cents, vat_rate, image_path, is_available, display_order) ' + . 'VALUES (:category, :name, :description, :price, :vat, :image, :available, :ord)', + $this->bind($data), + ); + } + + /** + * @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data + */ + public function update(int $id, array $data): void + { + $this->db->execute( + 'UPDATE product SET category_id = :category, name = :name, description = :description, ' + . 'price_cents = :price, vat_rate = :vat, image_path = :image, is_available = :available, ' + . 'display_order = :ord WHERE id = :id', + $this->bind($data) + ['id' => $id], + ); + } + + public function delete(int $id): int + { + return $this->db->execute('DELETE FROM product WHERE id = :id', ['id' => $id]); + } + + public function ingredientExists(int $id): bool + { + return $this->db->fetch('SELECT id FROM ingredient WHERE id = :id', ['id' => $id]) !== null; + } + + /** + * Composition (recette) d'un produit : lignes product_ingredient enrichies du + * nom + de l'unite de l'ingredient et de ses champs de stock (pour la + * disponibilite calculee RG-T21). Ordonnee par nom d'ingredient. + * + * @return array> + */ + public function composition(int $productId): array + { + return $this->db->fetchAll( + 'SELECT pi.product_id, pi.ingredient_id, pi.quantity_normal, pi.quantity_maxi, ' + . 'pi.is_removable, pi.is_addable, pi.extra_price_cents, ' + . 'i.name AS ingredient_name, i.unit AS ingredient_unit, ' + . 'i.stock_quantity, i.stock_capacity, i.low_stock_pct, i.critical_stock_pct ' + . 'FROM product_ingredient pi JOIN ingredient i ON i.id = pi.ingredient_id ' + . 'WHERE pi.product_id = :id ORDER BY i.name', + ['id' => $productId], + ); + } + + /** + * Nombre de lignes de composition d'un produit. Sert a tracer la cascade #27 : + * combien de product_ingredient seront emportees par la suppression du produit + * (FK product_id CASCADE), pour ne laisser aucune perte hors-trace dans l'audit. + */ + public function compositionCount(int $productId): int + { + return (int) ($this->db->fetch( + 'SELECT COUNT(*) AS n FROM product_ingredient WHERE product_id = :id', + ['id' => $productId], + )['n'] ?? 0); + } + + /** + * Remplace integralement la composition d'un produit (delete-and-reinsert, mlt + * 8.5 RG-2 transpose a la recette) dans UNE transaction (RG-T08) : reposer + * l'ensemble est plus simple et sur qu'une reconciliation en place. La PK + * composite (product_id, ingredient_id) garantit l'unicite par ingredient ; + * l'appelant (controleur) a deja deduplique et valide les bornes (RG-T18). + * + * @param list $lines + */ + public function setComposition(int $productId, array $lines): void + { + $this->db->transaction(function (DatabaseInterface $db) use ($productId, $lines): void { + $db->execute('DELETE FROM product_ingredient WHERE product_id = :id', ['id' => $productId]); + foreach ($lines as $line) { + $db->execute( + 'INSERT INTO product_ingredient (product_id, ingredient_id, quantity_normal, ' + . 'quantity_maxi, is_removable, is_addable, extra_price_cents) ' + . 'VALUES (:product, :ingredient, :qn, :qm, :rem, :add, :extra)', + [ + 'product' => $productId, + 'ingredient' => $line['ingredient_id'], + 'qn' => $line['quantity_normal'], + 'qm' => $line['quantity_maxi'], + 'rem' => $line['is_removable'], + 'add' => $line['is_addable'], + 'extra' => $line['extra_price_cents'], + ], + ); + } + }); + } + + /** + * Ids des produits en RUPTURE AUTOMATIQUE par le stock (RG-T21) : au moins un + * ingredient requis (is_removable=0) au niveau ou sous la bande critique + * (stock_quantity * 100 <= stock_capacity * critical_stock_pct, l'arithmetique + * entiere de IngredientRepository::stockBand). Calcule en UNE requete pour + * eviter le N+1 a l'affichage de la liste. Distinct du retrait manuel + * (is_available=0), que la vue signale separement. + * + * @return list + */ + public function autoUnavailableIds(): array + { + $rows = $this->db->fetchAll( + 'SELECT DISTINCT pi.product_id FROM product_ingredient pi ' + . 'JOIN ingredient i ON i.id = pi.ingredient_id ' + . 'WHERE pi.is_removable = 0 AND i.stock_quantity * 100 <= i.stock_capacity * i.critical_stock_pct', + ); + + return array_map(static fn (array $r): int => (int) ($r['product_id'] ?? 0), $rows); + } + + /** + * Disponibilite produit CALCULEE (RG-T21) : commandable ssi le flag + * is_available vaut 1 ET chaque ingredient NON RETIRABLE (is_removable=0) de la + * composition est au-dessus de la bande critique (stockBand != 'critical'). + * Derivation pure, sans ecriture ni cascade : un ingredient requis tombant en + * critique met le produit en rupture automatique ; un ingredient retirable/ + * optionnel en critique ne bloque pas (seul son supplement devient indispo) ; + * un retrait manuel (is_available=0) prime sur tout. La bande critique est celle + * d'IngredientRepository::stockBand (source unique de la derivation). + * + * @param array> $composition lignes de composition() + */ + public static function isOrderable(bool $flagAvailable, array $composition): bool + { + if (!$flagAvailable) { + return false; + } + + foreach ($composition as $line) { + if ((int) ($line['is_removable'] ?? 1) !== 0) { + continue; // retirable/optionnel : n'entre pas dans la disponibilite du produit + } + $band = IngredientRepository::stockBand( + (int) ($line['stock_quantity'] ?? 0), + (int) ($line['stock_capacity'] ?? 0), + (int) ($line['low_stock_pct'] ?? 0), + (int) ($line['critical_stock_pct'] ?? 0), + ); + if ($band === 'critical') { + return false; + } + } + + return true; + } + + /** + * Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees. + * + * @param array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data + * @return array + */ + private function bind(array $data): array + { + return [ + 'category' => $data['category_id'], + 'name' => $data['name'], + 'description' => $data['description'], + 'price' => $data['price_cents'], + 'vat' => $data['vat_rate'], + 'image' => $data['image_path'], + 'available' => $data['is_available'], + 'ord' => $data['display_order'], + ]; + } +} diff --git a/src/app/Catalogue/StatsRepository.php b/src/app/Catalogue/StatsRepository.php new file mode 100644 index 0000000..e5a38d6 --- /dev/null +++ b/src/app/Catalogue/StatsRepository.php @@ -0,0 +1,91 @@ +> + */ + public function counts(): array + { + return [ + 'products' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_available = 1), 0) AS n FROM product', 'available'), + 'categories' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_active = 1), 0) AS n FROM category', 'active'), + 'menus' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_available = 1), 0) AS n FROM menu', 'available'), + 'ingredients' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_active = 1), 0) AS n FROM ingredient', 'active'), + ]; + } + + /** + * @return array + */ + private function pair(string $sql, string $key): array + { + $row = $this->db->fetch($sql) ?? []; + + return ['total' => (int) ($row['total'] ?? 0), $key => (int) ($row['n'] ?? 0)]; + } + + /** + * Sante du stock : repartition des ingredients ACTIFS par bande (RG-T21, via + * IngredientRepository::stockBand = source unique de la derivation) + liste + * d'alerte (bandes low/critical), triee du plus critique au moins critique. + * + * @return array{active_total:int, bands:array{normal:int,low:int,critical:int}, alerts:list} + */ + public function stockHealth(): array + { + $rows = $this->db->fetchAll( + 'SELECT name, stock_quantity, stock_capacity, low_stock_pct, critical_stock_pct ' + . 'FROM ingredient WHERE is_active = 1 ORDER BY name', + ); + + $bands = ['normal' => 0, 'low' => 0, 'critical' => 0]; + $alerts = []; + foreach ($rows as $r) { + $qty = (int) ($r['stock_quantity'] ?? 0); + $cap = (int) ($r['stock_capacity'] ?? 0); + $band = IngredientRepository::stockBand( + $qty, + $cap, + (int) ($r['low_stock_pct'] ?? 0), + (int) ($r['critical_stock_pct'] ?? 0), + ); + $bands[$band]++; + if ($band !== 'normal') { + $alerts[] = [ + 'name' => (string) ($r['name'] ?? ''), + 'stock_pct' => IngredientRepository::stockPct($qty, $cap), + 'stock_band' => $band, + ]; + } + } + + // Plus critique (pourcentage le plus bas) en tete. + usort($alerts, static fn (array $a, array $b): int => $a['stock_pct'] <=> $b['stock_pct']); + + return ['active_total' => count($rows), 'bands' => $bands, 'alerts' => $alerts]; + } +} diff --git a/src/app/Controllers/AdminController.php b/src/app/Controllers/AdminController.php new file mode 100644 index 0000000..dce2a93 --- /dev/null +++ b/src/app/Controllers/AdminController.php @@ -0,0 +1,102 @@ +sessionGuard()->check(); + + if (!$result->authenticated || $result->userId === null || $result->roleId === null) { + return Response::make('', 302, ['Location' => '/login']); + } + + if ($permission !== null && !$this->authorizer()->can($result->roleId, $permission)) { + return $this->adminView('admin/forbidden', ['title' => 'Acces refuse', 'activeNav' => ''], $result, 403); + } + + return $result; + } + + /** + * Rend une vue dans le shell admin en injectant le contexte commun + * (nom/role de l'utilisateur, permissions pour la navigation, jeton CSRF). + * Les cles passees dans $data ont priorite (ex. activeNav). + * + * @param array $data + */ + protected function adminView(string $name, array $data, GuardResult $guard, int $status = 200): Response + { + $userId = $guard->userId ?? 0; + $roleId = $guard->roleId ?? 0; + $info = $this->userDirectory()->displayInfo($userId); + + $context = [ + 'currentUserName' => $info['name'], + 'currentUserRole' => $info['role_label'], + 'currentUserEmail' => $info['email'], + 'permissions' => $this->authorizer()->permissionsFor($roleId), + 'csrfToken' => Csrf::token($this->sessionManager()), + 'activeNav' => '', + 'flash' => $this->takeFlash(), + ]; + + return $this->view($name, $data + $context, $status); + } + + protected function userDirectory(): UserDirectory + { + return new UserDirectory($this->db()); + } + + /** + * Message de confirmation a afficher apres une redirection (pose avant le 302, + * consomme au rendu suivant). Stocke en session pour survivre a la redirection. + */ + protected function setFlash(string $message): void + { + $this->sessionManager()->set('_flash', $message); + } + + private function takeFlash(): ?string + { + $flash = $this->sessionManager()->get('_flash'); + if ($flash === null) { + return null; + } + + $this->sessionManager()->set('_flash', null); + + return is_string($flash) ? $flash : null; + } +} diff --git a/src/app/Controllers/AuthController.php b/src/app/Controllers/AuthController.php new file mode 100644 index 0000000..6a7aee2 --- /dev/null +++ b/src/app/Controllers/AuthController.php @@ -0,0 +1,127 @@ + $params + */ + public function showLogin(array $params = []): Response + { + $notice = $this->request->query('reset') === 'ok' + ? 'Mot de passe reinitialise. Vous pouvez vous connecter.' + : null; + + return $this->renderLogin(null, $notice); + } + + /** + * @param array $params + */ + public function login(array $params = []): Response + { + $form = $this->request->formBody(); + + // PRE-2 / ERR-2 : jeton CSRF valide sinon 403, avant tout traitement. + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->renderLogin('Session expiree, merci de reessayer.', null, 403); + } + + // RG-T18 : validation et bornes de longueur cote serveur. + $email = trim($form['email'] ?? ''); + $password = $form['password'] ?? ''; + + if ($email === '' || $password === '' || strlen($email) > 254 || strlen($password) > 4096) { + return $this->renderLogin(self::GENERIC_ERROR); + } + + try { + $result = $this->authService()->authenticate($email, $password, $this->request->clientIp()); + } catch (Throwable $exception) { + // Fail-closed : une panne base ne doit jamais authentifier. On ne + // divulgue rien, on re-affiche le formulaire avec le message generique. + error_log('[wakdo][auth] login failure: ' . $exception->getMessage()); + + return $this->renderLogin(self::GENERIC_ERROR); + } + + if ($result->success && $result->redirectTo !== null) { + return $this->redirect($result->redirectTo); + } + + return $this->renderLogin($result->error ?? self::GENERIC_ERROR); + } + + /** + * @param array $params + */ + public function logout(array $params = []): Response + { + $form = $this->request->formBody(); + + // D11 : deconnexion en POST garde par CSRF (un GET forgeable pourrait + // deconnecter un poste en plein service). CSRF invalide -> 403, pas de destroy. + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } + + $this->authService()->logout(); + + return $this->redirect('/login'); + } + + protected function sessionManager(): SessionManager + { + return new SessionManager($this->config); + } + + protected function authService(): AuthService + { + return new AuthService( + $this->database, + $this->config, + $this->sessionManager(), + new PasswordHasher($this->config), + ); + } + + private function redirect(string $location, int $status = 302): Response + { + return Response::make('', $status, ['Location' => $location]); + } + + private function renderLogin(?string $error, ?string $notice = null, int $status = 200): Response + { + return $this->view('auth/login', [ + 'title' => 'Connexion - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'error' => $error, + 'notice' => $notice, + ], $status); + } +} diff --git a/src/app/Controllers/AuthenticatedController.php b/src/app/Controllers/AuthenticatedController.php new file mode 100644 index 0000000..0121f00 --- /dev/null +++ b/src/app/Controllers/AuthenticatedController.php @@ -0,0 +1,47 @@ +config); + } + + /** + * Acces aux donnees via l'interface. Centralise le seam pour que toutes les + * dependances DB (garde, autorisation, repositories, transactions, audit) + * passent par un point unique surchargeable en test. + */ + protected function db(): DatabaseInterface + { + return $this->database; + } + + protected function sessionGuard(): SessionGuard + { + return new SessionGuard($this->sessionManager(), $this->db(), $this->config); + } + + protected function authorizer(): Authorizer + { + return new Authorizer($this->db()); + } +} diff --git a/src/app/Controllers/CatalogueController.php b/src/app/Controllers/CatalogueController.php new file mode 100644 index 0000000..c549cf7 --- /dev/null +++ b/src/app/Controllers/CatalogueController.php @@ -0,0 +1,286 @@ + $params + */ + public function categories(array $params = []): Response + { + $rows = array_map( + fn (array $row): array => $this->presentCategory($row), + $this->categoriesRepo()->activeForCatalogue(), + ); + + return $this->json(['data' => $rows, 'total' => count($rows)]); + } + + /** + * @param array $params + */ + public function products(array $params = []): Response + { + $repo = $this->productsRepo(); + // R4 : les tailles de TOUS les produits a variantes sont chargees en UNE + // 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(); + $rows = array_map( + fn (array $row): array => $this->presentProduct($row, $sizesByBase[(int) ($row['id'] ?? 0)] ?? []), + $repo->availableForCatalogue(), + ); + + return $this->json(['data' => $rows, 'total' => count($rows)]); + } + + /** + * @param array $params + */ + public function product(array $params = []): Response + { + $id = (int) ($params['id'] ?? 0); + $repo = $this->productsRepo(); + $row = $id > 0 ? $repo->findForCatalogue($id) : null; + + if ($row === null) { + return $this->json( + ['data' => null, 'error' => ['code' => 'NOT_FOUND', 'message' => 'Produit introuvable.']], + 404, + ); + } + + // R4 : sur le detail, les tailles ne sont presentees que si le produit en a + // 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); + + return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [])]); + } + + /** + * @param array $params + */ + public function menus(array $params = []): Response + { + $rows = array_map( + fn (array $row): array => $this->presentMenu($row), + $this->menusRepo()->availableForCatalogue(), + ); + + return $this->json(['data' => $rows, 'total' => count($rows)]); + } + + /** + * @param array $params + */ + public function menu(array $params = []): Response + { + $id = (int) ($params['id'] ?? 0); + $repo = $this->menusRepo(); + $row = $id > 0 ? $repo->findForCatalogue($id) : null; + + if ($row === null) { + return $this->json( + ['data' => null, 'error' => ['code' => 'NOT_FOUND', 'message' => 'Menu introuvable.']], + 404, + ); + } + + // Detail = menu + ses slots de composition (B1 burger impose, B2 Normal/Maxi). + $menu = $this->presentMenu($row) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))]; + + return $this->json(['data' => $menu]); + } + + /** + * Allergenes INCO (info generale, 14 categories). Public anonyme, lecture seule. + * + * @param array $params + */ + public function allergens(array $params = []): Response + { + $rows = array_map( + fn (array $row): array => $this->presentAllergen($row), + $this->allergensRepo()->all(), + ); + + return $this->json(['data' => $rows, 'total' => count($rows)]); + } + + protected function categoriesRepo(): CategoryRepository + { + return new CategoryRepository($this->db()); + } + + protected function productsRepo(): ProductRepository + { + return new ProductRepository($this->db()); + } + + protected function menusRepo(): MenuRepository + { + return new MenuRepository($this->db()); + } + + protected function allergensRepo(): AllergenRepository + { + return new AllergenRepository($this->db()); + } + + /** + * Acces BDD comme DatabaseInterface (seam de test). Database l'implemente. + */ + protected function db(): DatabaseInterface + { + return $this->database; + } + + /** + * @param array $row + * @return array{id: int, code: string, name: string} + */ + private function presentAllergen(array $row): array + { + return [ + 'id' => (int) ($row['id'] ?? 0), + 'code' => (string) ($row['code'] ?? ''), + 'name' => (string) ($row['name'] ?? ''), + ]; + } + + /** + * @param array $row + * @return array{id: int, name: string, slug: string, image_path: ?string, display_order: int} + */ + private function presentCategory(array $row): array + { + return [ + 'id' => (int) ($row['id'] ?? 0), + 'name' => (string) ($row['name'] ?? ''), + 'slug' => (string) ($row['slug'] ?? ''), + 'image_path' => $this->nullableString($row['image_path'] ?? null), + 'display_order' => (int) ($row['display_order'] ?? 0), + ]; + } + + /** + * @param array $row + * @param array> $sizes tailles de la base (R4) : base + + * 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} + */ + private function presentProduct(array $row, array $sizes = []): array + { + return [ + 'id' => (int) ($row['id'] ?? 0), + 'category_id' => (int) ($row['category_id'] ?? 0), + 'name' => (string) ($row['name'] ?? ''), + 'description' => $this->nullableString($row['description'] ?? null), + 'price_cents' => (int) ($row['price_cents'] ?? 0), + 'image_path' => $this->nullableString($row['image_path'] ?? null), + 'display_order' => (int) ($row['display_order'] ?? 0), + // Nom de la variante Maxi de l'accompagnement (ex. "Grande Frite") ; NULL si + // le produit n'a pas de variante. La borne l'affiche en format Maxi pour ne + // pas montrer "Moyenne Frite" sur un menu agrandi. + 'maxi_variant_name' => $this->nullableString($row['maxi_variant_name'] ?? null), + 'sizes' => array_map( + static function (array $size): array { + $cl = (int) ($size['size_cl'] ?? 0); + + return [ + 'product_id' => (int) ($size['id'] ?? 0), + 'size_cl' => $cl, + 'price_cents' => (int) ($size['price_cents'] ?? 0), + 'label' => $cl . ' cl', + ]; + }, + array_values($sizes), + ), + ]; + } + + /** + * @param array $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} + */ + private function presentMenu(array $row): array + { + return [ + 'id' => (int) ($row['id'] ?? 0), + 'category_id' => (int) ($row['category_id'] ?? 0), + 'burger_product_id' => (int) ($row['burger_product_id'] ?? 0), + 'name' => (string) ($row['name'] ?? ''), + 'description' => $this->nullableString($row['description'] ?? null), + 'price_normal_cents' => (int) ($row['price_normal_cents'] ?? 0), + 'price_maxi_cents' => (int) ($row['price_maxi_cents'] ?? 0), + 'image_path' => $this->nullableString($row['image_path'] ?? null), + 'display_order' => (int) ($row['display_order'] ?? 0), + ]; + } + + /** + * Slots de composition d'un menu pour la borne. MenuRepository::slotsWithOptions + * a deja groupe les options par slot et type les valeurs ; on expose is_required + * en vrai booleen (plus naturel pour le client JS) et on garde la liste d'ids + * de produits eligibles (la borne resout les libelles via /api/products). + * + * @param list}> $slots + * @return list}> + */ + private function presentSlots(array $slots): array + { + return array_map( + static fn (array $slot): array => [ + 'id' => $slot['id'], + 'name' => $slot['name'], + 'slot_type' => $slot['slot_type'], + 'is_required' => $slot['is_required'] !== 0, + 'display_order' => $slot['display_order'], + 'option_product_ids' => $slot['option_product_ids'], + ], + $slots, + ); + } + + /** + * Preserve NULL (colonne nullable) tout en restant strictement type : un + * scalaire devient une chaine, tout le reste (null, tableau) devient null. + */ + private function nullableString(mixed $value): ?string + { + return is_scalar($value) ? (string) $value : null; + } +} diff --git a/src/app/Controllers/CategoryController.php b/src/app/Controllers/CategoryController.php new file mode 100644 index 0000000..6394082 --- /dev/null +++ b/src/app/Controllers/CategoryController.php @@ -0,0 +1,281 @@ + $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/categories/index', [ + 'title' => 'Categories - Wakdo Admin', + 'activeNav' => 'categories', + 'categories' => $this->categoryRepository()->all(), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], []); + } + + /** + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $repo = $this->categoryRepository(); + [$data, $errors] = $this->validate($form, $repo, 0); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $errors, 422); + } + + try { + $repo->create($data); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, 0, $form); + } + + $this->setFlash('Categorie creee.'); + + return $this->redirect('/admin/categories'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $category = $this->categoryRepository()->find($id); + if ($category === null) { + return $this->notFound($guard); + } + + return $this->renderForm($guard, $id, $category, []); + } + + /** + * @param array $params + */ + public function update(array $params): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $repo = $this->categoryRepository(); + if ($repo->find($id) === null) { + return $this->notFound($guard); + } + + [$data, $errors] = $this->validate($form, $repo, $id); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form, $errors, 422); + } + + try { + $repo->update($id, $data); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, $id, $form); + } + + $this->setFlash('Categorie mise a jour.'); + + return $this->redirect('/admin/categories'); + } + + /** + * @param array $params + */ + public function toggle(array $params): Response + { + $guard = $this->guard(self::PERMISSION); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $repo = $this->categoryRepository(); + $category = $repo->find($id); + if ($category === null) { + return $this->notFound($guard); + } + + $newActive = (int) ($category['is_active'] ?? 0) !== 1; + $repo->setActive($id, $newActive); + $this->setFlash($newActive ? 'Categorie affichee.' : 'Categorie masquee.'); + + return $this->redirect('/admin/categories'); + } + + protected function categoryRepository(): CategoryRepository + { + return new CategoryRepository($this->database); + } + + /** + * Validation serveur (RG-T18) + unicite. Renvoie [donnees normalisees, erreurs]. + * + * @param array $form + * @return array{0: array{name: string, slug: string, image_path: ?string, display_order: int, is_active: int}, 1: array} + */ + private function validate(array $form, CategoryRepository $repo, int $exceptId): array + { + $name = trim($form['name'] ?? ''); + $slug = trim($form['slug'] ?? ''); + $image = trim($form['image_path'] ?? ''); + $orderRaw = trim($form['display_order'] ?? '0'); + + $errors = []; + + if ($name === '' || mb_strlen($name) > 60) { + $errors['name'] = 'Le libelle est requis (60 caracteres max).'; + } elseif ($repo->nameExists($name, $exceptId)) { + $errors['name'] = 'Ce libelle existe deja.'; + } + + if ($slug === '' || mb_strlen($slug) > 60 || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug) !== 1) { + $errors['slug'] = 'Reference requise : minuscules, chiffres et tirets (60 max).'; + } elseif ($repo->slugExists($slug, $exceptId)) { + $errors['slug'] = 'Cette reference existe deja.'; + } + + if ($image !== '' && mb_strlen($image) > 255) { + $errors['image_path'] = 'Chemin image trop long (255 max).'; + } + + // Borne haute = SMALLINT UNSIGNED (0..65535) : refuse cote serveur (RG-T18) + // plutot que de laisser un debordement remonter en 500 depuis la base. + if (!ctype_digit($orderRaw) || (int) $orderRaw > 65535) { + $errors['display_order'] = 'L ordre d affichage doit etre un entier entre 0 et 65535.'; + } + + $data = [ + 'name' => $name, + 'slug' => $slug, + 'image_path' => $image !== '' ? $image : null, + 'display_order' => (ctype_digit($orderRaw) && (int) $orderRaw <= 65535) ? (int) $orderRaw : 0, + 'is_active' => 1, + ]; + + return [$data, $errors]; + } + + /** + * @param array $values + * @param array $errors + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/categories/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouvelle') . ' categorie - Wakdo Admin', + 'activeNav' => 'categories', + 'categoryId' => $id, + 'values' => [ + 'name' => (string) ($values['name'] ?? ''), + 'slug' => (string) ($values['slug'] ?? ''), + 'image_path' => (string) ($values['image_path'] ?? ''), + 'display_order' => (string) ($values['display_order'] ?? '0'), + ], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * Traduit une violation de contrainte d'unicite (SQLSTATE 23000) en + * re-affichage 409 du formulaire plutot qu'en 500. Conflit remonte par la + * base (slug/name deja pris) = 409 Conflict, aligne sur le contrat d'API + * (SLUG_EXISTS). La pre-verification nameExists/slugExists reste, elle, en + * 422 (validation du formulaire) ; ce catch couvre la fenetre de concurrence + * entre ce controle et l'ecriture. Tout autre code d'erreur est repropage. + * + * @param array $form + */ + private function onWriteConflict(PDOException $exception, GuardResult $guard, int $id, array $form): Response + { + // getCode() rend la chaine SQLSTATE pour une vraie PDOException ; le cast + // couvre aussi un code entier (23000 = violation de contrainte d'integrite). + if ((string) $exception->getCode() === '23000') { + return $this->renderForm($guard, $id, $form, ['slug' => 'Ce libelle ou cette reference existe deja.'], 409); + } + + throw $exception; + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'categories'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Controllers/CounterOrderController.php b/src/app/Controllers/CounterOrderController.php new file mode 100644 index 0000000..c21f377 --- /dev/null +++ b/src/app/Controllers/CounterOrderController.php @@ -0,0 +1,472 @@ + 'drive', sinon 'counter'). Ce choix + * evite un controleur par canal alors que la logique est identique ; seules la source + * auto-tagguee, le titre et les liens d'action changent. Le decoupage par chemin (et + * non par parametre de route) garantit que counter et drive restent etanches : un + * equipier drive ne peut pas creer une commande comptoir en falsifiant un champ. + * + * Composeur (sous-lot 3c) : produits ET menus composes (slots accompagnement/ + * boisson/sauce + format Normal/Maxi) ET modificateurs d'ingredients (retrait/ajout). + * La composition PROPOSABLE de chaque produit a la carte et du burger de chaque menu + * (ingredients is_removable / is_addable + surcout) est embarquee en data-* pour que + * counter-order.js affiche les cases "retirer" / "ajouter +X.XX EUR". Le serveur reste + * seul juge : resolveModifiers revalide chaque modificateur metier (l'ingredient doit + * appartenir a la recette du produit support, etre retirable pour 'remove' / ajoutable + * pour 'add') et fige extra_price_cents (RG-T16) ; le client ne fait que PROPOSER. + * Le panier est construit cote client (counter-order.js) et serialise en JSON dans + * le champ cache `items_json` ; le serveur (store) le decode, revalide la forme + * (RG-T18) puis delegue a createStaffOrder qui resout/calcule cote serveur (RG-T16). + * Le chemin legacy `qty_` (3a) reste accepte en repli quand `items_json` est + * absent (degradation sans JS). La commande est creee directement `paid` + * (encaissement immediat, RG-5/POST-1) sans PIN : la permission order.create suffit. + * + * Non `final` : les tests sous-classent pour injecter des doubles (db/orderQuery/orders). + */ +class CounterOrderController extends AdminController +{ + /** + * Liste des commandes recentes du canal courant + lien "Nouvelle commande". + * Corrige le 404 des landings /counter/orders et /drive/orders (role.default_route). + * + * @param array $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('order.create'); + if ($guard instanceof Response) { + return $guard; + } + + $source = $this->source(); + + // RG-1 (5.1, source filter) : ne lister que les commandes du canal. recent() + // ramene les plus recentes tous canaux ; on filtre sur la source derivee du + // chemin pour que le comptoir ne voie pas le drive et inversement. + $orders = array_values(array_filter( + $this->orderQuery()->recent(50), + static fn (array $o): bool => (string) ($o['source'] ?? '') === $source, + )); + + return $this->channelView('admin/counter/index', $source, [ + 'title' => $this->channelTitle($source) . ' - Wakdo Admin', + 'orders' => $orders, + ], $guard); + } + + /** + * Composeur de commande (GET .../new) : produits commandables, menus composes + * (slots + options) + select service_mode. Tout est passe a la vue qui l'embarque + * en data-* pour counter-order.js (aucun endpoint slots : page back-office authentifiee). + * + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard('order.create'); + if ($guard instanceof Response) { + return $guard; + } + + $source = $this->source(); + + return $this->renderForm($guard, $source, [], null); + } + + /** + * Soumission de la commande (POST). Le panier est decode depuis le champ cache + * `items_json` (produits + menus composes construits cote client) ; en repli + * sans JS, les quantites legacy `qty_` (3a) sont relues. Chaque item est + * revalide dans sa FORME (RG-T18) cote serveur, puis createStaffOrder resout les + * references, recalcule les prix (RG-T16) et encaisse (source derivee du chemin, + * acteur = equipier authentifie). Panier vide / RG-T09 / indisponibilite -> flash + re-rendu. + * + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard('order.create'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $source = $this->source(); + $serviceMode = (string) ($form['service_mode'] ?? ''); + + // 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_ ne servent qu'au repli sans JS (degradation gracieuse). + $itemsJson = (string) ($form['items_json'] ?? ''); + $items = trim($itemsJson) !== '' + ? $this->decodeItems($itemsJson) + : $this->legacyQuantities($form); + + if ($items === []) { + return $this->renderForm($guard, $source, $form, 'Ajoutez au moins un produit ou un menu.', 422); + } + + try { + $order = $this->orders()->createStaffOrder( + ['service_mode' => $serviceMode, 'items' => $items], + $guard->userId ?? 0, + $source, + ); + } catch (OrderValidationException $exception) { + return $this->renderForm($guard, $source, $form, $this->messageFor($exception->getMessage()), 422); + } + + $this->setFlash('Commande ' . $order['order_number'] . ' enregistree et encaissee.'); + + return $this->redirect($this->landing($source)); + } + + /** + * Decode + normalise le panier soumis en JSON par counter-order.js (RG-T18 : + * revalidation de la FORME cote serveur ; le client n'est jamais cru). Chaque + * item mal forme est ECARTE silencieusement (un client falsifie ne bloque pas le + * traitement des items valides ; un panier integralement invalide retombe vide -> + * 422). La validation METIER (existence, disponibilite, options de slot, recette) + * et le calcul de prix restent dans OrderRepository::resolveLine (source unique). + * + * Forme produite (calque sur ce qu'attend resolveLine) : + * - produit : {type:'product', product_id:int>0, quantity:int>=1, modifiers?:[...]} + * - menu : {type:'menu', menu_id:int>0, quantity:int>=1, format:'normal'|'maxi', + * selections:[{menu_slot_id:int>0, product_id:int>0}], modifiers?:[...]} + * - modifier: {ingredient_id:int>0, action:'add'|'remove'} + * + * @return list> + */ + private function decodeItems(string $json): array + { + /** @var mixed $decoded */ + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + return []; + } + + $items = []; + foreach ($decoded as $raw) { + if (!is_array($raw)) { + continue; + } + $type = (string) ($raw['type'] ?? ''); + $quantity = $this->positiveInt($raw['quantity'] ?? null, 1); + $modifiers = $this->normaliseModifiers($raw['modifiers'] ?? null); + + if ($type === 'product') { + $productId = $this->positiveInt($raw['product_id'] ?? null, 0); + if ($productId > 0) { + $items[] = [ + 'type' => 'product', + 'product_id' => $productId, + 'quantity' => $quantity, + 'modifiers' => $modifiers, + ]; + } + continue; + } + + if ($type === 'menu') { + $menuId = $this->positiveInt($raw['menu_id'] ?? null, 0); + if ($menuId > 0) { + $items[] = [ + 'type' => 'menu', + 'menu_id' => $menuId, + 'quantity' => $quantity, + 'format' => ($raw['format'] ?? 'normal') === 'maxi' ? 'maxi' : 'normal', + 'selections' => $this->normaliseSelections($raw['selections'] ?? null), + 'modifiers' => $modifiers, + ]; + } + } + } + + return $items; + } + + /** + * Selections de slot normalisees (forme), revalidees metier par resolveSelections. + * + * @return list + */ + private function normaliseSelections(mixed $raw): array + { + if (!is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $sel) { + if (!is_array($sel)) { + continue; + } + $slotId = $this->positiveInt($sel['menu_slot_id'] ?? null, 0); + $productId = $this->positiveInt($sel['product_id'] ?? null, 0); + if ($slotId > 0 && $productId > 0) { + $out[] = ['menu_slot_id' => $slotId, 'product_id' => $productId]; + } + } + + return $out; + } + + /** + * Modificateurs d'ingredients normalises (forme), revalides metier par resolveModifiers. + * + * @return list + */ + private function normaliseModifiers(mixed $raw): array + { + if (!is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $mod) { + if (!is_array($mod)) { + continue; + } + $ingredientId = $this->positiveInt($mod['ingredient_id'] ?? null, 0); + $action = ($mod['action'] ?? '') === 'add' ? 'add' : 'remove'; + if ($ingredientId > 0) { + $out[] = ['ingredient_id' => $ingredientId, 'action' => $action]; + } + } + + return $out; + } + + /** + * Entier positif tolerant (le JSON decode peut livrer int|string|float|null). + */ + private function positiveInt(mixed $value, int $minimum): int + { + $int = is_numeric($value) ? (int) $value : 0; + + return $int >= $minimum ? max($int, $minimum) : $minimum; + } + + /** + * Repli sans JS : panier produit construit depuis les champs `qty_` (3a). + * Conserve pour ne pas casser la saisie quand counter-order.js ne s'execute pas. + * + * @param array $form + * @return list> + */ + private function legacyQuantities(array $form): array + { + $items = []; + foreach ($form as $key => $value) { + if (!is_string($key) || !str_starts_with($key, 'qty_')) { + continue; + } + $productId = (int) substr($key, 4); + $quantity = ctype_digit(trim((string) $value)) ? (int) $value : 0; + if ($productId > 0 && $quantity >= 1) { + $items[] = ['type' => 'product', 'product_id' => $productId, 'quantity' => $quantity]; + } + } + + return $items; + } + + protected function orderQuery(): OrderQueryRepository + { + return new OrderQueryRepository($this->db()); + } + + protected function orders(): OrderRepository + { + $db = $this->db(); + + return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db)); + } + + protected function productRepository(): ProductRepository + { + return new ProductRepository($this->db()); + } + + protected function menuRepository(): MenuRepository + { + return new MenuRepository($this->db()); + } + + /** + * Canal derive du chemin de la requete : tout chemin sous /drive est le canal + * drive, le reste (/counter...) est le comptoir. Source unique de la verite pour + * la source auto-tagguee, les titres et les liens. + */ + private function source(): string + { + return str_starts_with($this->request->path(), '/drive') ? 'drive' : 'counter'; + } + + private function landing(string $source): string + { + return $source === 'drive' ? '/drive/orders' : '/counter/orders'; + } + + private function newPath(string $source): string + { + return $source === 'drive' ? '/drive/orders/new' : '/counter/orders/new'; + } + + private function channelTitle(string $source): string + { + return $source === 'drive' ? 'Commandes drive' : 'Commandes comptoir'; + } + + /** + * Rend le composeur produits (vue partagee par les deux canaux). + * + * @param array $values valeurs du formulaire a reafficher (re-rendu d'erreur) + */ + private function renderForm(GuardResult $guard, string $source, array $values, ?string $error, int $status = 200): Response + { + $productRepository = $this->productRepository(); + $products = $productRepository->availableForCatalogue(); + + // Modificateurs proposables par produit a la carte : seuls les produits dont la + // recette offre au moins un ingredient retirable/ajoutable portent une compo. + $products = array_map(function (array $product) use ($productRepository): array { + $product['modifiers'] = $this->proposableModifiers($productRepository, (int) ($product['id'] ?? 0)); + + return $product; + }, $products); + + return $this->channelView('admin/counter/new', $source, [ + 'title' => 'Nouvelle commande ' . ($source === 'drive' ? 'drive' : 'comptoir') . ' - Wakdo Admin', + 'products' => $products, + 'menus' => $this->menusWithSlots($productRepository), + 'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')), + 'error' => $error, + ], $guard, $status); + } + + /** + * Menus commandables enrichis de leurs slots+options (lecture catalogue) ET des + * modificateurs proposables du burger support, pour que counter-order.js compose + * chaque menu SANS appel reseau supplementaire : toute la configuration est + * embarquee en data-* au rendu (page back-office authentifiee). La forme `slots` + * calque slotsWithOptions() (id, name, slot_type, is_required, display_order, + * option_product_ids), consommable par la meme logique que page-product-menu.js + * cote borne ; `burger_modifiers` calque proposableModifiers() (la selection de + * modificateurs d'un menu cible le burger, comme resolveModifiers cote serveur). + * + * @return list> + */ + private function menusWithSlots(ProductRepository $productRepository): array + { + $menuRepository = $this->menuRepository(); + $menus = $menuRepository->availableForCatalogue(); + + return array_map(function (array $menu) use ($menuRepository, $productRepository): array { + $menu['slots'] = $menuRepository->slotsWithOptions((int) ($menu['id'] ?? 0)); + $menu['burger_modifiers'] = $this->proposableModifiers($productRepository, (int) ($menu['burger_product_id'] ?? 0)); + + return $menu; + }, $menus); + } + + /** + * Modificateurs PROPOSABLES d'un produit support : les lignes de composition() + * dont l'ingredient est retirable (is_removable=1) OU ajoutable (is_addable=1), + * projetees a ce dont l'UI a besoin (ingredient_id, name, is_removable, is_addable, + * extra_price_cents). Les ingredients ni retirables ni ajoutables sont ECARTES : + * ils n'offrent aucune case a cocher cote client, donc embarquer leur ligne + * alourdirait le data-* sans usage. Le client ne fait que PROPOSER ces choix ; + * resolveModifiers revalide tout cote serveur et fige le surcout (RG-T16). + * + * @return list + */ + private function proposableModifiers(ProductRepository $productRepository, int $productId): array + { + if ($productId <= 0) { + return []; + } + + $out = []; + foreach ($productRepository->composition($productId) as $line) { + $isRemovable = (int) ($line['is_removable'] ?? 0); + $isAddable = (int) ($line['is_addable'] ?? 0); + if ($isRemovable !== 1 && $isAddable !== 1) { + continue; + } + $out[] = [ + 'ingredient_id' => (int) ($line['ingredient_id'] ?? 0), + 'name' => (string) ($line['ingredient_name'] ?? ''), + 'is_removable' => $isRemovable, + 'is_addable' => $isAddable, + 'extra_price_cents' => (int) ($line['extra_price_cents'] ?? 0), + ]; + } + + return $out; + } + + /** + * Vue de canal : injecte les liens et le titre derives de la source pour que les + * vues partagees (comptoir/drive) s'adaptent sans connaitre le decoupage par chemin. + * + * @param array $data + */ + private function channelView(string $name, string $source, array $data, GuardResult $guard, int $status = 200): Response + { + return $this->adminView($name, $data + [ + 'activeNav' => $source === 'drive' ? 'drive' : 'counter', + 'source' => $source, + 'channelTitle' => $this->channelTitle($source), + 'landing' => $this->landing($source), + 'newPath' => $this->newPath($source), + ], $guard, $status); + } + + /** + * Message lisible pour un code d'erreur metier (re-rendu de formulaire). + */ + private function messageFor(string $code): string + { + return match ($code) { + 'EMPTY_ORDER' => 'La commande est vide : ajoutez au moins un produit ou un menu.', + 'INVALID_SERVICE_MODE' => 'Mode de service invalide (le drive impose le mode drive).', + 'PRODUCT_UNAVAILABLE' => 'Un produit selectionne est indisponible.', + 'MENU_UNAVAILABLE' => 'Un menu selectionne est indisponible.', + 'INVALID_SELECTION' => 'Un choix de menu (accompagnement / boisson / sauce) est invalide.', + 'INVALID_MODIFIER', + 'INGREDIENT_NOT_REMOVABLE', + 'INGREDIENT_NOT_ADDABLE' => 'Une modification d\'ingredient est invalide.', + default => 'Commande invalide, verifiez votre saisie.', + }; + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Controllers/DashboardController.php b/src/app/Controllers/DashboardController.php new file mode 100644 index 0000000..9ce7f91 --- /dev/null +++ b/src/app/Controllers/DashboardController.php @@ -0,0 +1,48 @@ + $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard(); + if ($guard instanceof Response) { + return $guard; + } + + $stats = $this->statsRepository(); + + return $this->adminView( + 'admin/dashboard', + [ + 'title' => 'Tableau de bord - Wakdo Admin', + 'activeNav' => 'dashboard', + 'counts' => $stats->counts(), + 'stock' => $stats->stockHealth(), + ], + $guard, + ); + } + + protected function statsRepository(): StatsRepository + { + return new StatsRepository($this->db()); + } +} diff --git a/src/app/Controllers/HealthController.php b/src/app/Controllers/HealthController.php new file mode 100644 index 0000000..d9e7750 --- /dev/null +++ b/src/app/Controllers/HealthController.php @@ -0,0 +1,49 @@ + routeur + * -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. + */ +final class HealthController extends Controller +{ + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $dbStatus = 'ok'; + $categories = null; + $httpStatus = 200; + + try { + $row = $this->database->fetch('SELECT COUNT(*) AS total FROM category'); + $categories = (int) ($row['total'] ?? 0); + } catch (Throwable) { + // Detail de l'erreur volontairement non expose (information disclosure) ; + // un statut degrade suffit a la sonde, les logs conteneur portent le reste. + $dbStatus = 'error'; + $httpStatus = 503; + } + + return $this->json( + [ + 'status' => $dbStatus === 'ok' ? 'ok' : 'degraded', + 'app_env' => $this->config->appEnv(), + 'php_version' => PHP_VERSION, + 'db' => $dbStatus, + 'categories' => $categories, + ], + $httpStatus, + ); + } +} diff --git a/src/app/Controllers/HomeController.php b/src/app/Controllers/HomeController.php new file mode 100644 index 0000000..f647871 --- /dev/null +++ b/src/app/Controllers/HomeController.php @@ -0,0 +1,26 @@ + $params + */ + public function index(array $params = []): Response + { + return Response::make('', 302, ['Location' => '/login']); + } +} diff --git a/src/app/Controllers/IngredientController.php b/src/app/Controllers/IngredientController.php new file mode 100644 index 0000000..d272e90 --- /dev/null +++ b/src/app/Controllers/IngredientController.php @@ -0,0 +1,722 @@ + 409. + * - RESTOCK (9.1) : `stock.manage`, SANS PIN ; PRE-2 ingredient actif, PRE-3 N>=1 ; + * user_id = acteur de SESSION (capture par permission, RG-4). + * - INVENTORY_COUNT (9.2) : `stock.count` + PIN equipier (RG-T13) ; PRE-3 compte>=0 ; + * user_id = acteur resolu par PIN, ecrit dans stock_movement.user_id. PAS d'audit_log + * au succes (RG-T14 : le stock_movement EST la trace). Echec PIN -> pin.failed + + * throttle (RG-T22), comme produit/menu. + * - READ_STOCK (9.3) : `stock.read` ; le user_id des mouvements n'est expose qu'a + * manager/admin (RG-4), detecte via la permission stock.manage. + * + * Le stock ne bouge JAMAIS par le formulaire de definition : creation pose + * stock_quantity=0 (RG-CREATE-ING), update ne lie ni stock_quantity ni is_active + * (RG-T16 ; is_active bascule via toggle, soft-delete). Non `final` : les tests + * sous-classent pour injecter des doubles. + */ +class IngredientController extends AdminController +{ + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('stock.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/ingredients/index', [ + 'title' => 'Stock - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredients' => $this->ingredientRepository()->all(), + 'canManage' => $this->may($guard, 'ingredient.manage'), + 'canRestock' => $this->may($guard, 'stock.manage'), + 'canCount' => $this->may($guard, 'stock.count'), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], []); + } + + /** + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + [$data, $errors] = $this->validate($form, 0); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $errors, 422); + } + + // stock_quantity initial = 0 (RG-CREATE-ING) ; is_active = 1 : valeurs posees + // cote serveur, pas liees au formulaire (RG-T16). Le stock s'etablit ensuite + // via restock/inventaire (chaque mouvement laisse une trace). + try { + $this->ingredientRepository()->create($data + ['stock_quantity' => 0, 'is_active' => 1]); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, 0, $form); + } + + $this->setFlash('Ingredient cree.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderForm($guard, $id, $ingredient, []); + } + + /** + * @param array $params + */ + public function update(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + if ($this->ingredientRepository()->find($id) === null) { + return $this->notFound($guard); + } + + [$data, $errors] = $this->validate($form, $id); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form, $errors, 422); + } + + try { + $this->ingredientRepository()->update($id, $data); + } catch (PDOException $exception) { + return $this->onWriteConflict($exception, $guard, $id, $form); + } + + $this->setFlash('Ingredient mis a jour.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function toggle(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + $newActive = (int) ($ingredient['is_active'] ?? 0) !== 1; + $this->ingredientRepository()->setActive($id, $newActive); + $this->setFlash($newActive ? 'Ingredient reactive.' : 'Ingredient desactive.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * Enrichit un ingredient avec des donnees nutritionnelles importees d'une API + * EXTERNE (OpenFoodFacts, Cr 3.a.3). Action explicite (POST + CSRF), gardee par + * ingredient.manage, SANS PIN (hors ensemble sensible RG-T13). Tolerante : si la + * source ne renvoie rien, on le signale sans erreur (le flux reste utilisable). + * + * @param array $params + */ + public function enrich(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + $data = $this->nutritionGateway()->lookupByName((string) ($ingredient['name'] ?? '')); + if ($data === null) { + $this->setFlash('Aucune donnee nutritionnelle trouvee pour cet ingredient (source externe).'); + } else { + $this->ingredientRepository()->setNutrition($id, $data); + $this->setFlash('Donnees nutritionnelles importees depuis ' . $data['source'] . '.'); + } + + return $this->redirect('/admin/ingredients/' . $id . '/edit'); + } + + /** + * Passerelle nutritionnelle externe. Hook protege : les tests redefinissent ce + * seam pour injecter un double sans appel reseau. + */ + protected function nutritionGateway(): NutritionGateway + { + return new OpenFoodFactsGateway(); + } + + /** + * @param array $params + */ + public function confirmDelete(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderDelete($guard, $id, $ingredient, null); + } + + /** + * @param array $params + */ + public function destroy(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + // 8.8 n'est PAS dans l'ensemble PIN (RG-T13) : pas de PIN a la suppression. + // Hard-delete bloquee par FK RESTRICT (product_ingredient / stock_movement) + // -> PDOException 23000 -> 409 Conflit (proposer la desactivation). + try { + $this->ingredientRepository()->delete($id); + } catch (PDOException $exception) { + if ((string) $exception->getCode() === '23000') { + return $this->renderDelete($guard, $id, $ingredient, 'Ingredient reference par une recette ou des mouvements de stock : suppression impossible. Desactivez-le plutot.', 409); + } + + throw $exception; + } + + $this->setFlash('Ingredient supprime.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function restockForm(array $params): Response + { + $guard = $this->guard('stock.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderRestock($guard, $id, $ingredient, [], []); + } + + /** + * @param array $params + */ + public function restock(array $params): Response + { + $guard = $this->guard('stock.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + $errors = []; + + // PRE-2 (9.1) : on ne reapprovisionne qu'un ingredient actif. + if ((int) ($ingredient['is_active'] ?? 0) !== 1) { + $errors['packs'] = 'Ingredient inactif : reactivez-le avant de reapprovisionner.'; + } + + // PRE-3 (9.1) : N >= 1 (borne haute pour eviter un debordement de stock_quantity). + $packsRaw = trim($form['packs'] ?? ''); + $packsValid = ctype_digit($packsRaw) && (int) $packsRaw >= 1 && (int) $packsRaw <= 65535; + if (!$packsValid && !isset($errors['packs'])) { + $errors['packs'] = 'Le nombre de packs doit etre un entier entre 1 et 65535.'; + } + + $note = trim($form['note'] ?? ''); + if (mb_strlen($note) > 255) { + $errors['note'] = 'Note trop longue (255 caracteres max).'; + } + + if ($errors !== []) { + return $this->renderRestock($guard, $id, $ingredient, $form, $errors, 422); + } + + $this->ingredientRepository()->restock($id, (int) $packsRaw, $guard->userId, $note !== '' ? $note : null); + $this->setFlash('Reapprovisionnement enregistre.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function inventoryForm(array $params): Response + { + $guard = $this->guard('stock.count'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + return $this->renderInventory($guard, $id, $ingredient, [], []); + } + + /** + * @param array $params + */ + public function inventory(array $params): Response + { + $guard = $this->guard('stock.count'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + $errors = []; + + // PRE-3 (9.2) : comptage physique non negatif. ctype_digit borne deja >= 0. + $actualRaw = trim($form['actual_quantity'] ?? ''); + $actualValid = ctype_digit($actualRaw) && (int) $actualRaw <= 2147483647; + if (!$actualValid) { + $errors['actual_quantity'] = 'Le comptage doit etre un entier >= 0.'; + } + + $note = trim($form['note'] ?? ''); + if (mb_strlen($note) > 255) { + $errors['note'] = 'Note trop longue (255 caracteres max).'; + } + + if ($errors !== []) { + return $this->renderInventory($guard, $id, $ingredient, $form, $errors, 422); + } + + // RG-T13/RG-4 : correction d'inventaire = action sensible, PIN equipier. + // RG-T22 : verrou du throttle par utilisateur AGISSANT (session), evalue AVANT + // la verification ; sous verrou, leurre de timing et message generique, pas de + // nouvelle ligne pin.failed. + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderInventory($guard, $id, $ingredient, $form, ['pin' => 'Email ou PIN invalide (requis pour l inventaire).'], 422); + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + // RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans UNE + // transaction. pin.failed est un evenement securite (aucun stock_movement + // n'est cree), il n'entre donc pas en conflit avec l'exclusion stock de RG-T14. + $email = trim($form['pin_email'] ?? ''); + $this->db()->transaction(function (DatabaseInterface $db) use ($email, $id, $actorId): void { + $this->logFailedPin($db, $email, $id); + $this->pinThrottle()->recordFailureWithin($db, $actorId); + }); + + return $this->renderInventory($guard, $id, $ingredient, $form, ['pin' => 'Email ou PIN invalide (requis pour l inventaire).'], 422); + } + + // Succes : la correction ecrit stock_movement.user_id (acteur resolu par PIN). + // PAS de ligne audit_log (RG-T14 : la trace stock_movement suffit, pas de + // double-journal). inventoryCount ouvre sa propre transaction (UPDATE+INSERT). + $this->ingredientRepository()->inventoryCount($id, (int) $actualRaw, $actor['id'], $note !== '' ? $note : null); + $this->pinThrottle()->reset($actorId); + + $this->setFlash('Inventaire enregistre.'); + + return $this->redirect('/admin/ingredients'); + } + + /** + * @param array $params + */ + public function movements(array $params): Response + { + $guard = $this->guard('stock.read'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $ingredient = $this->ingredientRepository()->find($id); + if ($ingredient === null) { + return $this->notFound($guard); + } + + // RG-4 (9.3) : l'identite de l'acteur d'un mouvement n'est exposee qu'a + // manager/admin (detenteurs de stock.manage) ; le personnel de ligne voit + // les deltas sans l'auteur. + $showActor = $this->may($guard, 'stock.manage'); + $movements = $this->ingredientRepository()->movements($id); + + $actorNames = []; + if ($showActor) { + foreach ($movements as $movement) { + $uid = $movement['user_id'] !== null ? (int) $movement['user_id'] : 0; + if ($uid > 0 && !isset($actorNames[$uid])) { + $actorNames[$uid] = $this->userDirectory()->displayInfo($uid)['name']; + } + } + } + + return $this->adminView('admin/ingredients/movements', [ + 'title' => 'Mouvements de stock - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredient' => $ingredient, + 'movements' => $movements, + 'showActor' => $showActor, + 'actorNames' => $actorNames, + ], $guard); + } + + protected function ingredientRepository(): IngredientRepository + { + return new IngredientRepository($this->db()); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); + } + + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } + + /** + * RG-T03 : la permission est-elle detenue par le role de la session courante ? + * Utilise pour adapter l'affichage (liens d'action, visibilite acteur RG-4) sans + * remplacer la garde par-action (chaque route reste gardee independamment). + */ + private function may(GuardResult $guard, string $permission): bool + { + return $guard->roleId !== null && $this->authorizer()->can($guard->roleId, $permission); + } + + /** + * Validation serveur (RG-T18) + allowlist des champs de definition (RG-T16). + * stock_quantity et is_active ne sont jamais lies ici (poses cote serveur a la + * creation, modifies via restock/inventaire/toggle). Renvoie [donnees, erreurs]. + * + * @param array $form + * @return array{0: array{name: string, unit: string, stock_capacity: int, pack_size: int, pack_label: ?string, low_stock_pct: int, critical_stock_pct: int}, 1: array} + */ + private function validate(array $form, int $exceptId): array + { + $errors = []; + + $name = trim($form['name'] ?? ''); + if ($name === '' || mb_strlen($name) > 120) { + $errors['name'] = 'Le nom est requis (120 caracteres max).'; + } elseif ($this->ingredientRepository()->nameExists($name, $exceptId)) { + $errors['name'] = 'Cet ingredient existe deja.'; + } + + $unit = trim($form['unit'] ?? ''); + if ($unit === '' || mb_strlen($unit) > 40) { + $errors['unit'] = 'L unite est requise (40 caracteres max).'; + } + + $capRaw = trim($form['stock_capacity'] ?? ''); + $capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647; + if (!$capValid) { + $errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.'; + } + + $packRaw = trim($form['pack_size'] ?? ''); + $packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535; + if (!$packValid) { + $errors['pack_size'] = 'La taille de pack doit etre un entier entre 1 et 65535.'; + } + + $label = trim($form['pack_label'] ?? ''); + if ($label !== '' && mb_strlen($label) > 80) { + $errors['pack_label'] = 'Libelle de pack trop long (80 caracteres max).'; + } + + $lowRaw = trim($form['low_stock_pct'] ?? ''); + $lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100; + if (!$lowValid) { + $errors['low_stock_pct'] = 'Le seuil d alerte doit etre un entier entre 0 et 100.'; + } + + $critRaw = trim($form['critical_stock_pct'] ?? ''); + $critValid = ctype_digit($critRaw) && (int) $critRaw <= 100; + if (!$critValid) { + $errors['critical_stock_pct'] = 'Le seuil critique doit etre un entier entre 0 et 100.'; + } + + // RG-CREATE-ING : critical_stock_pct < low_stock_pct (strict). + if ($lowValid && $critValid && (int) $critRaw >= (int) $lowRaw) { + $errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.'; + } + + $data = [ + 'name' => $name, + 'unit' => $unit, + 'stock_capacity' => $capValid ? (int) $capRaw : 0, + 'pack_size' => $packValid ? (int) $packRaw : 0, + 'pack_label' => $label !== '' ? $label : null, + 'low_stock_pct' => $lowValid ? (int) $lowRaw : 0, + 'critical_stock_pct' => $critValid ? (int) $critRaw : 0, + ]; + + return [$data, $errors]; + } + + /** + * Traduit une violation d'unicite (SQLSTATE 23000, name deja pris) en + * re-affichage 409 du formulaire (coherent avec la convention de conflit du + * back-office). Tout autre code est repropage. + * + * @param array $form + */ + private function onWriteConflict(PDOException $exception, GuardResult $guard, int $id, array $form): Response + { + if ((string) $exception->getCode() === '23000') { + return $this->renderForm($guard, $id, $form, ['name' => 'Cet ingredient existe deja.'], 409); + } + + throw $exception; + } + + private function logFailedPin(DatabaseInterface $db, string $email, int $ingredientId): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => null, + 'rid' => null, + 'code' => 'pin.failed', + 'etype' => 'ingredient', + 'eid' => $ingredientId, + 'summary' => 'Echec PIN inventaire (email tente: ' . $email . ')', + ], + ); + } + + /** + * @param array $values + * @param array $errors + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/ingredients/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouvel') . ' ingredient - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'values' => [ + 'name' => (string) ($values['name'] ?? ''), + 'unit' => (string) ($values['unit'] ?? ''), + 'stock_capacity' => (string) ($values['stock_capacity'] ?? ''), + 'pack_size' => (string) ($values['pack_size'] ?? '1'), + 'pack_label' => (string) ($values['pack_label'] ?? ''), + 'low_stock_pct' => (string) ($values['low_stock_pct'] ?? '10'), + 'critical_stock_pct' => (string) ($values['critical_stock_pct'] ?? '5'), + // Nutrition (lecture seule) : transmise pour que le panneau d'enrichissement + // reflete la valeur importee (Cr 3.a.3). Absente sur create / re-rendu d'erreur. + 'energy_kcal_100g' => (string) ($values['energy_kcal_100g'] ?? ''), + 'nutrition_source' => (string) ($values['nutrition_source'] ?? ''), + 'nutrition_fetched_at' => (string) ($values['nutrition_fetched_at'] ?? ''), + ], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $ingredient + * @param array $values + * @param array $errors + */ + private function renderRestock(GuardResult $guard, int $id, array $ingredient, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/ingredients/restock', [ + 'title' => 'Reapprovisionner - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'ingredient' => $ingredient, + 'values' => ['packs' => (string) ($values['packs'] ?? ''), 'note' => (string) ($values['note'] ?? '')], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $ingredient + * @param array $values + * @param array $errors + */ + private function renderInventory(GuardResult $guard, int $id, array $ingredient, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/ingredients/inventory', [ + 'title' => 'Inventaire - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'ingredient' => $ingredient, + 'values' => ['actual_quantity' => (string) ($values['actual_quantity'] ?? ''), 'note' => (string) ($values['note'] ?? '')], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $ingredient + */ + private function renderDelete(GuardResult $guard, int $id, array $ingredient, ?string $error, ?int $status = null): Response + { + return $this->adminView('admin/ingredients/delete', [ + 'title' => 'Supprimer un ingredient - Wakdo Admin', + 'activeNav' => 'stock', + 'ingredientId' => $id, + 'name' => (string) ($ingredient['name'] ?? ''), + 'error' => $error, + ], $guard, $status ?? ($error !== null ? 422 : 200)); + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'stock'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Controllers/KitchenController.php b/src/app/Controllers/KitchenController.php new file mode 100644 index 0000000..df605b7 --- /dev/null +++ b/src/app/Controllers/KitchenController.php @@ -0,0 +1,52 @@ + $params + */ + public function display(array $params = []): Response + { + $guard = $this->guard('order.read'); + if ($guard instanceof Response) { + return $guard; + } + + $sources = $this->orderQuery()->visibleSources($guard->roleId ?? 0); + + return $this->adminView('admin/kitchen/display', [ + 'title' => 'Cuisine - Wakdo Admin', + 'activeNav' => 'kitchen', + 'orders' => $this->orderQuery()->paidQueue($sources), + 'canDeliver' => $this->may($guard, 'order.deliver'), + ], $guard); + } + + protected function orderQuery(): OrderQueryRepository + { + return new OrderQueryRepository($this->db()); + } + + private function may(GuardResult $guard, string $permission): bool + { + return $this->authorizer()->can($guard->roleId ?? 0, $permission); + } +} diff --git a/src/app/Controllers/MeController.php b/src/app/Controllers/MeController.php new file mode 100644 index 0000000..f7901ba --- /dev/null +++ b/src/app/Controllers/MeController.php @@ -0,0 +1,50 @@ + $params + */ + public function show(array $params = []): Response + { + $guard = $this->sessionGuard()->check(); + + if (!$guard->authenticated || $guard->userId === null || $guard->roleId === null) { + return $this->json( + ['data' => null, 'error' => ['code' => 'AUTH_REQUIRED', 'message' => 'Authentification requise']], + 401, + ); + } + + $authorizer = $this->authorizer(); + + return $this->json([ + 'data' => [ + 'user_id' => $guard->userId, + 'role_id' => $guard->roleId, + 'role_code' => $authorizer->roleCode($guard->roleId), + 'permissions' => $authorizer->permissionsFor($guard->roleId), + ], + ]); + } +} diff --git a/src/app/Controllers/MenuController.php b/src/app/Controllers/MenuController.php new file mode 100644 index 0000000..cfd2199 --- /dev/null +++ b/src/app/Controllers/MenuController.php @@ -0,0 +1,535 @@ + hors RG-T13) ; + * - delete (menu.delete) : action sensible -> PIN equipier + audit (RG-T13/T14, + * mlt 8.6), suppression dure seulement si non reference par order_item.menu_id + * (FK RESTRICT -> 409 sinon, proposer la desactivation). + * + * La configuration de slots est soumise en un champ cache `slots_json` (le + * builder vanilla JS la serialise) : Request::formBody() ne retient que les + * scalaires, donc une structure imbriquee passe par du JSON valide cote serveur. + * + * Non `final` : les tests sous-classent pour injecter des doubles. + */ +class MenuController extends AdminController +{ + private const SLOT_TYPES = ['drink', 'side', 'sauce', 'dessert', 'extra']; + + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('menu.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/menus/index', [ + 'title' => 'Menus - Wakdo Admin', + 'activeNav' => 'menus', + 'menus' => $this->menuRepository()->all(), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard('menu.create'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], [], []); + } + + /** + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard('menu.create'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + [$data, $slots, $errors] = $this->validate($form); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $slots, $errors, 422); + } + + $this->menuRepository()->create($data, $slots); + $this->setFlash('Menu cree.'); + + return $this->redirect('/admin/menus'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard('menu.update'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $menu = $this->menuRepository()->find($id); + if ($menu === null) { + return $this->notFound($guard); + } + + $slots = $this->menuRepository()->slotsWithOptions($id); + + return $this->renderForm($guard, $id, $menu, $this->slotsToForm($slots), []); + } + + /** + * @param array $params + */ + public function update(array $params): Response + { + $guard = $this->guard('menu.update'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + if ($this->menuRepository()->find($id) === null) { + return $this->notFound($guard); + } + + [$data, $slots, $errors] = $this->validate($form); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form, $slots, $errors, 422); + } + + $this->menuRepository()->update($id, $data, $slots); + $this->setFlash('Menu mis a jour.'); + + return $this->redirect('/admin/menus'); + } + + /** + * @param array $params + */ + public function toggle(array $params): Response + { + $guard = $this->guard('menu.update'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $menu = $this->menuRepository()->find($id); + if ($menu === null) { + return $this->notFound($guard); + } + + $this->menuRepository()->setActive($id, (int) ($menu['is_available'] ?? 0) !== 1); + $this->setFlash('Disponibilite du menu mise a jour.'); + + return $this->redirect('/admin/menus'); + } + + /** + * @param array $params + */ + public function confirmDelete(array $params): Response + { + $guard = $this->guard('menu.delete'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $menu = $this->menuRepository()->find($id); + if ($menu === null) { + return $this->notFound($guard); + } + + return $this->renderDelete($guard, $id, $menu, null); + } + + /** + * @param array $params + */ + public function destroy(array $params): Response + { + $guard = $this->guard('menu.delete'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $menu = $this->menuRepository()->find($id); + if ($menu === null) { + return $this->notFound($guard); + } + + // RG-T22 : verrou de throttle PIN par utilisateur AGISSANT (session), evalue + // AVANT la verification ; un acteur verrouille recoit le meme 422 generique, + // on paie un leurre de timing et on n'ecrit pas de pin.failed sous verrou. + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderDelete($guard, $id, $menu, 'Email ou PIN invalide (requis pour supprimer).'); + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + // RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans + // UNE meme transaction (pas d'etat partiel si crash entre les deux). + $email = trim($form['pin_email'] ?? ''); + $this->db()->transaction(function (DatabaseInterface $db) use ($email, $id, $actorId): void { + $this->logFailedPin($db, $email, $id); + $this->pinThrottle()->recordFailureWithin($db, $actorId); + }); + + return $this->renderDelete($guard, $id, $menu, 'Email ou PIN invalide (requis pour supprimer).'); + } + + $name = (string) ($menu['name'] ?? ''); + + // FK order_item.menu_id RESTRICT -> PDOException 23000 -> 409 Conflit (catch). + // menu_slot / menu_slot_option sont CASCADE (supprimes avec le menu). + try { + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $name): void { + $deleted = (new MenuRepository($db))->delete($id); + if ($deleted === 1) { + $this->writeAudit($db, 'menu.delete', $actor['id'], $actor['role_id'], $id, 'Suppression menu: ' . $name); + } + }); + } catch (PDOException $exception) { + if ((string) $exception->getCode() === '23000') { + return $this->renderDelete($guard, $id, $menu, 'Menu reference par des commandes : suppression impossible. Desactivez-le plutot.', 409); + } + + throw $exception; + } + + // PIN valide + suppression effective : reset du compteur de l'acteur de + // SESSION (RG-T22, cle = $actorId, pas l'acteur resolu par le PIN). + $this->pinThrottle()->reset($actorId); + + $this->setFlash('Menu supprime.'); + + return $this->redirect('/admin/menus'); + } + + protected function menuRepository(): MenuRepository + { + return new MenuRepository($this->db()); + } + + protected function productRepository(): ProductRepository + { + return new ProductRepository($this->db()); + } + + protected function categoryRepository(): CategoryRepository + { + return new CategoryRepository($this->db()); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); + } + + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } + + /** + * Validation serveur (RG-T18) + allowlist (RG-T16). Renvoie [donnees menu, + * slots normalises, erreurs]. Les slots viennent du champ cache slots_json. + * + * @param array $form + * @return array{0: array{category_id:int, burger_product_id:int, name:string, price_normal_cents:int, price_maxi_cents:int, is_available:int, display_order:int}, 1: list}>, 2: array} + */ + private function validate(array $form): array + { + $errors = []; + + $categoryRaw = trim($form['category_id'] ?? ''); + $categoryId = ctype_digit($categoryRaw) ? (int) $categoryRaw : 0; + if ($categoryId === 0 || !$this->menuRepository()->categoryExists($categoryId)) { + $errors['category_id'] = 'Categorie requise et valide.'; + } + + $burgerRaw = trim($form['burger_product_id'] ?? ''); + $burgerId = ctype_digit($burgerRaw) ? (int) $burgerRaw : 0; + if ($burgerId === 0 || !$this->menuRepository()->productExists($burgerId)) { + $errors['burger_product_id'] = 'Le produit burger de base est requis et doit exister.'; + } + + $name = trim($form['name'] ?? ''); + if ($name === '' || mb_strlen($name) > 120) { + $errors['name'] = 'Le nom est requis (120 caracteres max).'; + } + + $priceNormal = $this->parsePrice($form['price_normal_cents'] ?? ''); + if ($priceNormal === null) { + $errors['price_normal_cents'] = 'Le prix Normal (centimes) doit etre un entier strictement positif.'; + } + + $priceMaxi = $this->parsePrice($form['price_maxi_cents'] ?? ''); + if ($priceMaxi === null) { + $errors['price_maxi_cents'] = 'Le prix Maxi (centimes) doit etre un entier strictement positif.'; + } + + $orderRaw = trim($form['display_order'] ?? '0'); + $displayOrder = ctype_digit($orderRaw) && (int) $orderRaw <= 65535 ? (int) $orderRaw : -1; + if ($displayOrder < 0) { + $errors['display_order'] = 'L\'ordre d\'affichage doit etre un entier entre 0 et 65535.'; + } + + $slots = $this->parseSlots($form['slots_json'] ?? '', $errors); + + $data = [ + 'category_id' => $categoryId, + 'burger_product_id' => $burgerId, + 'name' => $name, + 'price_normal_cents' => $priceNormal ?? 0, + 'price_maxi_cents' => $priceMaxi ?? 0, + 'is_available' => isset($form['is_available']) ? 1 : 0, + 'display_order' => $displayOrder < 0 ? 0 : $displayOrder, + ]; + + return [$data, $slots, $errors]; + } + + /** + * Decode + valide la configuration de slots soumise en JSON. Precondition + * mlt 8.4 : >=1 slot avec >=1 option ; chaque option doit exister. + * + * @param array $errors + * @return list}> + */ + private function parseSlots(string $json, array &$errors): array + { + if (trim($json) === '') { + $errors['slots'] = 'Au moins un slot avec au moins une option est requis.'; + + return []; + } + + /** @var mixed $decoded */ + $decoded = json_decode($json, true); + if (!is_array($decoded) || $decoded === []) { + $errors['slots'] = 'Configuration de slots invalide.'; + + return []; + } + + $slots = []; + $order = 0; + foreach ($decoded as $raw) { + if (!is_array($raw)) { + continue; + } + + $slotName = is_string($raw['name'] ?? null) ? trim($raw['name']) : ''; + $slotType = is_string($raw['slot_type'] ?? null) ? $raw['slot_type'] : ''; + $required = !empty($raw['is_required']) ? 1 : 0; + + $optionIds = []; + foreach (is_array($raw['options'] ?? null) ? $raw['options'] : [] as $opt) { + $pid = is_numeric($opt) ? (int) $opt : 0; + if ($pid > 0 && $this->menuRepository()->productExists($pid)) { + $optionIds[] = $pid; + } + } + $optionIds = array_values(array_unique($optionIds)); + + if ($slotName === '' || mb_strlen($slotName) > 80) { + $errors['slots'] = 'Chaque slot doit avoir un nom (80 caracteres max).'; + continue; + } + if (!in_array($slotType, self::SLOT_TYPES, true)) { + $errors['slots'] = 'Type de slot invalide.'; + continue; + } + if ($optionIds === []) { + $errors['slots'] = 'Chaque slot doit proposer au moins une option valide.'; + continue; + } + + $slots[] = [ + 'name' => $slotName, + 'slot_type' => $slotType, + 'is_required' => $required, + 'display_order' => $order++, + 'options' => $optionIds, + ]; + } + + if ($slots === [] && !isset($errors['slots'])) { + $errors['slots'] = 'Au moins un slot avec au moins une option est requis.'; + } + + return $slots; + } + + private function parsePrice(string $raw): ?int + { + $raw = trim($raw); + + return ctype_digit($raw) && (int) $raw > 0 && (int) $raw <= 4294967295 ? (int) $raw : null; + } + + /** + * Transforme les slots charges (repository) en structure JSON pour pre-remplir + * le builder a l'edition. + * + * @param list}> $slots + * @return list}> + */ + private function slotsToForm(array $slots): array + { + return array_map(static fn (array $s): array => [ + 'name' => $s['name'], + 'slot_type' => $s['slot_type'], + 'is_required' => $s['is_required'], + 'options' => $s['option_product_ids'], + ], $slots); + } + + /** + * @param array $values valeurs du menu (re-rendu) ou row trouvee + * @param list> $slots slots pre-remplis (structure JSON) + * @param array $errors + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $slots, array $errors, int $status = 200): Response + { + return $this->adminView('admin/menus/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' menu - Wakdo Admin', + 'activeNav' => 'menus', + 'menuId' => $id, + 'categories' => $this->categoryRepository()->all(), + 'products' => $this->productRepository()->all(), + 'slotTypes' => self::SLOT_TYPES, + 'values' => [ + 'category_id' => (string) ($values['category_id'] ?? ''), + 'burger_product_id' => (string) ($values['burger_product_id'] ?? ''), + 'name' => (string) ($values['name'] ?? ''), + 'price_normal_cents' => (string) ($values['price_normal_cents'] ?? ''), + 'price_maxi_cents' => (string) ($values['price_maxi_cents'] ?? ''), + 'is_available' => $errors === [] ? ((int) ($values['is_available'] ?? 1) === 1) : array_key_exists('is_available', $values), + 'display_order' => (string) ($values['display_order'] ?? '0'), + ], + 'slotsJson' => json_encode($slots, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]', + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $menu + */ + private function renderDelete(GuardResult $guard, int $id, array $menu, ?string $error, ?int $status = null): Response + { + return $this->adminView('admin/menus/delete', [ + 'title' => 'Supprimer un menu - Wakdo Admin', + 'activeNav' => 'menus', + 'menuId' => $id, + 'name' => (string) ($menu['name'] ?? ''), + 'error' => $error, + ], $guard, $status ?? ($error !== null ? 422 : 200)); + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'menus'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } + + /** + * Trace une tentative de PIN echouee sur une action sensible (RG-T14), acteur + * inconnu (PIN non resolu). Recoit le $db de la transaction (atomicite RG-T08). + */ + private function logFailedPin(DatabaseInterface $db, string $email, int $menuId): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => null, + 'rid' => null, + 'code' => 'pin.failed', + 'etype' => 'menu', + 'eid' => $menuId, + 'summary' => 'Echec PIN action sensible (email tente: ' . $email . ')', + ], + ); + } + + private function writeAudit(DatabaseInterface $db, string $action, int $userId, int $roleId, int $entityId, string $summary): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + ['uid' => $userId, 'rid' => $roleId, 'code' => $action, 'etype' => 'menu', 'eid' => $entityId, 'summary' => $summary], + ); + } +} diff --git a/src/app/Controllers/OrderAdminController.php b/src/app/Controllers/OrderAdminController.php new file mode 100644 index 0000000..dd348d7 --- /dev/null +++ b/src/app/Controllers/OrderAdminController.php @@ -0,0 +1,259 @@ + + * delivered (DELIVER_ORDER, geste unique, order.deliver), NON PIN-gated. + * GET/POST /admin/orders/{number}/cancel : annulation (CANCEL_ORDER, mlt 7.1, + * order.cancel) avec PIN equipier + audit + restock conditionnel (RG-T13/T14). La + * file cuisine (KitchenController) est traitee ailleurs. + * + * Non `final` : les tests sous-classent (seam db()/orderQuery()/orders()/pin*()). + */ +class OrderAdminController extends AdminController +{ + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('order.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/orders/index', [ + 'title' => 'Commandes - Wakdo Admin', + 'activeNav' => 'orders', + 'orders' => $this->orderQuery()->recent(50), + // RG-T03 : adapte l'affichage (bouton Annuler) sans remplacer la garde + // par-action de cancel(). manager n'a PAS order.cancel (decision D5). + 'canCancel' => $this->may($guard, 'order.cancel'), + ], $guard); + } + + /** + * Remise au client : paid -> delivered (mlt 6.1). POST + CSRF, garde order.deliver. + * Pas de PIN (geste routinier). Issue affichee en flash, retour a la liste. + * + * @param array $params + */ + public function deliver(array $params = []): Response + { + $guard = $this->guard('order.deliver'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + try { + $this->orders()->deliver((string) ($params['number'] ?? '')); + $this->setFlash('Commande remise (livree).'); + } catch (OrderValidationException $exception) { + $this->setFlash( + $exception->getMessage() === 'ORDER_NOT_FOUND' + ? 'Commande introuvable.' + : 'Transition invalide : la commande n\'est pas au statut paye.', + ); + } + + return $this->redirect('/admin/orders'); + } + + /** + * Page de confirmation d'annulation (CANCEL_ORDER, mlt 7.1). Garde order.cancel. + * Affiche numero/statut/total + le formulaire PIN equipier (modele RG-T13). La + * commande est chargee en lecture seule (OrderRepository::findByNumber) ; statut + * terminal (delivered/cancelled) -> message bloquant, pas de formulaire. + * + * @param array $params + */ + public function confirmCancel(array $params): Response + { + $guard = $this->guard('order.cancel'); + if ($guard instanceof Response) { + return $guard; + } + + $order = $this->orders()->findByNumber((string) ($params['number'] ?? '')); + if ($order === null) { + return $this->notFound($guard); + } + + return $this->renderCancel($guard, $order, null); + } + + /** + * Annulation effective (CANCEL_ORDER, mlt 7.1). POST + CSRF + garde order.cancel, + * puis flux PIN equipier IDENTIQUE a IngredientController::inventory (RG-T13/T22) : + * verrou throttle par utilisateur AGISSANT evalue AVANT la verification (leurre de + * timing, message generique) ; sur echec PIN -> pin.failed + increment throttle dans + * UNE transaction. Sur PIN OK -> OrderRepository::cancel (transition + restock + * conditionnel + audit dans sa propre transaction), reset du throttle, flash. + * + * @param array $params + */ + public function cancel(array $params): Response + { + $guard = $this->guard('order.cancel'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $number = (string) ($params['number'] ?? ''); + $order = $this->orders()->findByNumber($number); + if ($order === null) { + return $this->notFound($guard); + } + + // RG-T22 : verrou du throttle par utilisateur AGISSANT (session), evalue AVANT + // la verification ; sous verrou, leurre de timing + message generique, pas de + // nouvelle ligne pin.failed (les echecs ayant arme le verrou sont deja audites). + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderCancel($guard, $order, 'Email ou PIN invalide (requis pour annuler).', 422); + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + // RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans UNE + // transaction. pin.failed est un evenement securite (pas l'effet metier). + $email = trim($form['pin_email'] ?? ''); + $entityId = (int) ($order['id'] ?? 0); + $this->db()->transaction(function (DatabaseInterface $db) use ($email, $entityId, $actorId): void { + $this->logFailedPin($db, $email, $entityId); + $this->pinThrottle()->recordFailureWithin($db, $actorId); + }); + + return $this->renderCancel($guard, $order, 'Email ou PIN invalide (requis pour annuler).', 422); + } + + try { + $this->orders()->cancel($number, (int) $actor['id'], (int) $actor['role_id']); + // PIN valide : reinitialise le compteur de l'acteur de SESSION (RG-T22, cle + // = $actorId), surtout pas $actor['id'] (l'equipier resolu par le PIN). + $this->pinThrottle()->reset($actorId); + $this->setFlash('Commande annulee.'); + } catch (OrderValidationException $exception) { + $this->setFlash(match ($exception->getMessage()) { + 'ORDER_NOT_FOUND' => 'Commande introuvable.', + 'CANNOT_CANCEL_IN_STATE' => 'Annulation impossible : la commande est livree ou deja annulee.', + default => 'Transition invalide : la commande a change d\'etat.', + }); + } + + return $this->redirect('/admin/orders'); + } + + protected function orderQuery(): OrderQueryRepository + { + return new OrderQueryRepository($this->db()); + } + + protected function orders(): OrderRepository + { + $db = $this->db(); + + return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db)); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); + } + + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } + + /** + * RG-T03 : la permission est-elle detenue par le role de la session courante ? + * Sert a adapter l'affichage (bouton Annuler) sans remplacer la garde par-action. + */ + private function may(GuardResult $guard, string $permission): bool + { + return $guard->roleId !== null && $this->authorizer()->can($guard->roleId, $permission); + } + + /** + * Trace une tentative de PIN echouee sur l'annulation (RG-T14) : rend le + * brute-force d'attribution detectable. Acteur inconnu (PIN non resolu). + */ + private function logFailedPin(DatabaseInterface $db, string $email, int $orderId): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => null, + 'rid' => null, + 'code' => 'pin.failed', + 'etype' => 'customer_order', + 'eid' => $orderId, + 'summary' => 'Echec PIN annulation (email tente: ' . $email . ')', + ], + ); + } + + /** + * @param array{id:int, order_number:string, total_ttc_cents:int, status:string} $order + */ + private function renderCancel(GuardResult $guard, array $order, ?string $error, int $status = 200): Response + { + return $this->adminView('admin/orders/cancel', [ + 'title' => 'Annuler une commande - Wakdo Admin', + 'activeNav' => 'orders', + 'order' => $order, + 'error' => $error, + ], $guard, $status); + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'orders'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Controllers/OrderController.php b/src/app/Controllers/OrderController.php new file mode 100644 index 0000000..af214e8 --- /dev/null +++ b/src/app/Controllers/OrderController.php @@ -0,0 +1,149 @@ + paid + decrement stock (RG-T20). + * + * Les erreurs metier (OrderValidationException) sont mappees par code : + * ORDER_NOT_FOUND -> 404, INVALID_TRANSITION -> 409, le reste (reference / + * disponibilite / selection / modificateur) -> 422. Enveloppe standard + * {data} / {data:null, error:{code, message}}. + * + * Non `final` a dessein : les tests sous-classent pour injecter un acces BDD double + * (FakeOrderDatabase) via le hook protege db(). + */ +class OrderController extends Controller +{ + /** + * @param array $params + */ + public function create(array $params = []): Response + { + try { + $order = $this->orders()->createPending($this->request->json()); + } catch (OrderValidationException $exception) { + return $this->orderError($exception); + } + + return $this->json(['data' => $this->present($order)], 201); + } + + /** + * @param array $params + */ + public function pay(array $params = []): Response + { + try { + $order = $this->orders()->pay((string) ($params['number'] ?? '')); + } catch (OrderValidationException $exception) { + return $this->orderError($exception); + } + + return $this->json(['data' => $this->present($order)]); + } + + /** + * Lecture publique du statut d'une commande par son numero (suivi borne apres + * encaissement). Anonyme, lecture seule ; 404 si le numero est inconnu. + * + * @param array $params + */ + public function show(array $params = []): Response + { + $order = $this->orders()->findByNumber((string) ($params['number'] ?? '')); + if ($order === null) { + return $this->json( + ['data' => null, 'error' => ['code' => 'ORDER_NOT_FOUND', 'message' => $this->messageFor('ORDER_NOT_FOUND')]], + 404, + ); + } + + return $this->json(['data' => $this->present($order)]); + } + + /** + * Fabrique le repository de commande sur l'acces BDD courant. Hook de test + * (sous-classe -> double) : redefinir db() suffit a injecter une base factice. + */ + protected function orders(): OrderRepository + { + $db = $this->db(); + + return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db)); + } + + /** + * Acces BDD comme DatabaseInterface (seam de test). Database l'implemente. + */ + protected function db(): DatabaseInterface + { + return $this->database; + } + + /** + * @param array{id:int, order_number:string, total_ttc_cents:int, status:string} $order + * @return array{id:int, order_number:string, status:string, total_ttc_cents:int} + */ + private function present(array $order): array + { + return [ + 'id' => $order['id'], + 'order_number' => $order['order_number'], + 'status' => $order['status'], + 'total_ttc_cents' => $order['total_ttc_cents'], + ]; + } + + private function orderError(OrderValidationException $exception): Response + { + $code = $exception->getMessage(); + $status = match ($code) { + 'ORDER_NOT_FOUND' => 404, + 'INVALID_TRANSITION' => 409, + default => 422, + }; + + return $this->json( + ['data' => null, 'error' => ['code' => $code, 'message' => $this->messageFor($code)]], + $status, + ); + } + + /** + * Message lisible par code metier. Reste cote serveur : la borne affiche un + * libelle generique, ce texte sert au diagnostic / aux logs. + */ + private function messageFor(string $code): string + { + return match ($code) { + 'ORDER_NOT_FOUND' => 'Commande introuvable.', + 'INVALID_TRANSITION' => 'Transition de statut invalide.', + 'EMPTY_ORDER' => 'La commande est vide.', + 'INVALID_SERVICE_MODE' => 'Mode de service invalide.', + 'INVALID_SERVICE_TAG' => 'Numero de chevalet invalide.', + 'INVALID_ITEM_TYPE' => 'Type d\'article invalide.', + 'PRODUCT_UNAVAILABLE' => 'Produit indisponible.', + 'MENU_UNAVAILABLE' => 'Menu indisponible.', + 'INVALID_SELECTION' => 'Choix invalide pour ce menu.', + 'INVALID_MODIFIER' => 'Modification d\'ingredient invalide.', + 'INGREDIENT_NOT_REMOVABLE' => 'Cet ingredient ne peut pas etre retire.', + 'INGREDIENT_NOT_ADDABLE' => 'Cet ingredient ne peut pas etre ajoute.', + default => 'Requete invalide.', + }; + } +} diff --git a/src/app/Controllers/PasswordResetController.php b/src/app/Controllers/PasswordResetController.php new file mode 100644 index 0000000..efa0580 --- /dev/null +++ b/src/app/Controllers/PasswordResetController.php @@ -0,0 +1,150 @@ + $params + */ + public function showRequest(array $params = []): Response + { + return $this->view('auth/forgot', [ + 'title' => 'Mot de passe oublie - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'notice' => null, + ]); + } + + /** + * @param array $params + */ + public function submitRequest(array $params = []): Response + { + $form = $this->request->formBody(); + + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->view('auth/forgot', [ + 'title' => 'Mot de passe oublie - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'notice' => null, + ], 403); + } + + $email = trim($form['email'] ?? ''); + + // Reponse neutre quoi qu'il arrive (existence, validite, meme panne base). + if ($email !== '' && strlen($email) <= 254) { + try { + $this->resetService()->requestReset($email, $this->baseUrl()); + } catch (Throwable $exception) { + error_log('[wakdo][auth] reset request failure: ' . $exception->getMessage()); + } + } + + return $this->view('auth/forgot', [ + 'title' => 'Mot de passe oublie - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'notice' => self::NEUTRAL_NOTICE, + ]); + } + + /** + * @param array $params + */ + public function showConfirm(array $params = []): Response + { + return $this->renderConfirm($this->request->query('token') ?? '', null); + } + + /** + * @param array $params + */ + public function submitConfirm(array $params = []): Response + { + $form = $this->request->formBody(); + $token = $form['token'] ?? ''; + + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->renderConfirm($token, 'Session expiree, merci de reessayer.', 403); + } + + $password = $form['password'] ?? ''; + $confirm = $form['password_confirm'] ?? ''; + + if ($password !== $confirm) { + return $this->renderConfirm($token, 'Les mots de passe ne correspondent pas.'); + } + + try { + $result = $this->resetService()->confirmReset($token, $password); + } catch (Throwable $exception) { + error_log('[wakdo][auth] reset confirm failure: ' . $exception->getMessage()); + + return $this->renderConfirm($token, self::INVALID_LINK); + } + + if ($result->success && $result->redirectTo !== null) { + return $this->redirect($result->redirectTo); + } + + return $this->renderConfirm($token, $result->error ?? self::INVALID_LINK); + } + + protected function sessionManager(): SessionManager + { + return new SessionManager($this->config); + } + + protected function resetService(): PasswordResetService + { + return new PasswordResetService( + $this->database, + $this->config, + new PasswordHasher($this->config), + new LogMailer(), + ); + } + + private function baseUrl(): string + { + return $this->config->get('APP_URL_ADMIN', '') ?? ''; + } + + private function redirect(string $location, int $status = 302): Response + { + return Response::make('', $status, ['Location' => $location]); + } + + private function renderConfirm(string $token, ?string $error, int $status = 200): Response + { + return $this->view('auth/reset', [ + 'title' => 'Nouveau mot de passe - Wakdo Admin', + 'csrfToken' => Csrf::token($this->sessionManager()), + 'token' => $token, + 'error' => $error, + ], $status); + } +} diff --git a/src/app/Controllers/PrivacyController.php b/src/app/Controllers/PrivacyController.php new file mode 100644 index 0000000..7c066de --- /dev/null +++ b/src/app/Controllers/PrivacyController.php @@ -0,0 +1,41 @@ + $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard(); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/privacy', [ + 'title' => 'Traitement des donnees personnelles - Wakdo Admin', + 'activeNav' => '', + ], $guard); + } +} diff --git a/src/app/Controllers/ProductController.php b/src/app/Controllers/ProductController.php new file mode 100644 index 0000000..766f1fd --- /dev/null +++ b/src/app/Controllers/ProductController.php @@ -0,0 +1,641 @@ + 409 sinon). + * Le PIN suit le modele "identifiant equipier + PIN" : email + PIN resolus en un + * acting_user_id ecrit dans audit_log, dans la meme transaction que l'effet (RG-T08). + * + * Non `final` : les tests sous-classent pour injecter des doubles. + */ +class ProductController extends AdminController +{ + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('product.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/products/index', [ + 'title' => 'Produits - Wakdo Admin', + 'activeNav' => 'products', + 'products' => $this->productRepository()->all(), + // Rupture AUTOMATIQUE par le stock (RG-T21), distincte du retrait manuel + // (is_available=0) : la vue signale les deux differemment. + 'autoUnavailable' => $this->productRepository()->autoUnavailableIds(), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard('product.create'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], []); + } + + /** + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard('product.create'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + [$data, $errors] = $this->validate($form); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $errors, 422); + } + + $this->productRepository()->create($data); + $this->setFlash('Produit cree.'); + + return $this->redirect('/admin/products'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard('product.update'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + return $this->renderForm($guard, $id, $product, []); + } + + /** + * @param array $params + */ + public function update(array $params): Response + { + $guard = $this->guard('product.update'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $current = $this->productRepository()->find($id); + if ($current === null) { + return $this->notFound($guard); + } + + [$data, $errors] = $this->validate($form); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form, $errors, 422); + } + + // RG-T13/8.2 : seul un changement de prix ou de TVA est une action sensible. + $priceChanged = $data['price_cents'] !== (int) ($current['price_cents'] ?? 0); + $vatChanged = $data['vat_rate'] !== (int) ($current['vat_rate'] ?? 0); + + if (!$priceChanged && !$vatChanged) { + $this->productRepository()->update($id, $data); + $this->setFlash('Produit mis a jour.'); + + return $this->redirect('/admin/products'); + } + + // Changement sensible : exige email + PIN (modele equipier + PIN, RG-T13). + // RG-T22 : verrou de throttle PIN par UTILISATEUR AGISSANT (session), evalue + // AVANT la verification argon2id. Un acteur verrouille recoit le MEME 422 + // generique ; on paie un leurre de timing (parite avec le chemin mauvais-PIN) + // et on n'ecrit PAS de nouvelle ligne pin.failed (les echecs ayant arme le + // verrou sont deja audites : borne l'amplification de l'audit append-only). + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderForm($guard, $id, $form, ['pin' => 'Email ou PIN invalide (requis pour modifier prix/TVA).'], 422); + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + // RG-T08 : la trace pin.failed (RG-T14) et l'increment du throttle + // (RG-T22) sont ecrits dans UNE meme transaction (pas d'etat partiel + // si crash entre les deux ecritures). + $email = trim($form['pin_email'] ?? ''); + $this->db()->transaction(function (DatabaseInterface $db) use ($email, $id, $actorId): void { + $this->logFailedPin($db, $email, $id); + $this->pinThrottle()->recordFailureWithin($db, $actorId); + }); + + return $this->renderForm($guard, $id, $form, ['pin' => 'Email ou PIN invalide (requis pour modifier prix/TVA).'], 422); + } + + $summary = $this->changeSummary($current, $data, $priceChanged, $vatChanged); + + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $data, $actor, $summary): void { + (new ProductRepository($db))->update($id, $data); + $this->writeAudit($db, 'product.update', $actor['id'], $actor['role_id'], $id, $summary); + }); + + // PIN valide : reinitialise le compteur de throttle de l'acteur de SESSION + // (RG-T22), apres l'effet reussi. Cle = $actorId ($guard->userId), la meme + // qu'a l'increment ; surtout PAS $actor['id'] (l'equipier resolu par le PIN, + // un autre individu) sinon le compteur de l'agissant ne serait jamais purge. + $this->pinThrottle()->reset($actorId); + + $this->setFlash('Produit mis a jour (changement de prix/TVA trace).'); + + return $this->redirect('/admin/products'); + } + + /** + * @param array $params + */ + public function confirmDelete(array $params): Response + { + $guard = $this->guard('product.delete'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + return $this->renderDelete($guard, $id, $product, null); + } + + /** + * @param array $params + */ + public function destroy(array $params): Response + { + $guard = $this->guard('product.delete'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + // RG-T22 : meme garde que update() (verrou par utilisateur agissant, AVANT + // la verification, leurre de timing, pas de pin.failed sous verrou actif). + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderDelete($guard, $id, $product, 'Email ou PIN invalide (requis pour supprimer).'); + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + // RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans + // UNE meme transaction (pas d'etat partiel si crash entre les deux). + $email = trim($form['pin_email'] ?? ''); + $this->db()->transaction(function (DatabaseInterface $db) use ($email, $id, $actorId): void { + $this->logFailedPin($db, $email, $id); + $this->pinThrottle()->recordFailureWithin($db, $actorId); + }); + + return $this->renderDelete($guard, $id, $product, 'Email ou PIN invalide (requis pour supprimer).'); + } + + $name = (string) ($product['name'] ?? ''); + + // Dette #27 : product_ingredient (FK product_id CASCADE) sera emporte par la + // suppression. On compte AVANT (lecture hors transaction) pour tracer le + // nombre de lignes de recette cascade-supprimees dans le resume d'audit : + // aucune perte hors-trace dans le journal append-only. + $cascaded = $this->productRepository()->compositionCount($id); + $summary = 'Suppression produit: ' . $name + . ' (' . $cascaded . ' ligne(s) de recette cascade-supprimee(s))'; + + // FK RESTRICT (order_item / menu / menu_slot_option / order_item_selection) + // -> PDOException 23000 -> 409 Conflit (catch ci-dessous). product_ingredient + // est CASCADE (recette possedee par le produit) : supprimee avec lui, jamais + // bloquante (cf. docblock ProductRepository). + try { + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, $summary): void { + $deleted = (new ProductRepository($db))->delete($id); + if ($deleted === 1) { + $this->writeAudit($db, 'product.delete', $actor['id'], $actor['role_id'], $id, $summary); + } + }); + } catch (PDOException $exception) { + if ((string) $exception->getCode() === '23000') { + return $this->renderDelete($guard, $id, $product, 'Produit reference par des commandes ou menus : suppression impossible. Masquez-le plutot.', 409); + } + + throw $exception; + } + + // PIN valide et suppression effective : reinitialise le compteur de l'acteur + // de session (RG-T22, cle = $actorId). Apres le try/catch : non atteint si la + // FK a bloque (409), ce qui est benin (l'acteur n'est pas un attaquant). + $this->pinThrottle()->reset($actorId); + + $this->setFlash('Produit supprime.'); + + return $this->redirect('/admin/products'); + } + + /** + * Editeur de recette (PR-B, mlt domaine recettes). Compose product_ingredient : + * la commandabilite est gardee par `ingredient.manage` (composition du produit), + * DISTINCTE de product.create/update/delete (CRUD produit). Aucun PIN : editer + * une recette n'est pas une action sensible RG-T13. + * + * @param array $params + */ + public function recipeForm(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + return $this->renderRecipe($guard, $id, $product, []); + } + + /** + * @param array $params + */ + public function saveRecipe(array $params): Response + { + $guard = $this->guard('ingredient.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $product = $this->productRepository()->find($id); + if ($product === null) { + return $this->notFound($guard); + } + + $errors = []; + $lines = $this->parseComposition($form['composition_json'] ?? '', $errors); + if ($errors !== []) { + return $this->renderRecipe($guard, $id, $product, $errors, 422); + } + + // Composition vide autorisee : un produit peut n'avoir aucune recette + // definie (setComposition purge alors la table sans rien reinserer). + $this->productRepository()->setComposition($id, $lines); + $this->setFlash('Recette mise a jour.'); + + return $this->redirect('/admin/products'); + } + + protected function productRepository(): ProductRepository + { + return new ProductRepository($this->db()); + } + + protected function ingredientRepository(): IngredientRepository + { + return new IngredientRepository($this->db()); + } + + protected function categoryRepository(): CategoryRepository + { + return new CategoryRepository($this->db()); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); + } + + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } + + /** + * Validation serveur (RG-T18) + allowlist (RG-T16). Renvoie [donnees, erreurs]. + * + * @param array $form + * @return array{0: array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}, 1: array} + */ + private function validate(array $form): array + { + $errors = []; + + $categoryRaw = trim($form['category_id'] ?? ''); + $categoryId = ctype_digit($categoryRaw) ? (int) $categoryRaw : 0; + if ($categoryId === 0 || !$this->productRepository()->categoryExists($categoryId)) { + $errors['category_id'] = 'Categorie requise et valide.'; + } + + $name = trim($form['name'] ?? ''); + if ($name === '' || mb_strlen($name) > 120) { + $errors['name'] = 'Le nom est requis (120 caracteres max).'; + } + + $priceRaw = trim($form['price_cents'] ?? ''); + $priceValid = ctype_digit($priceRaw) && (int) $priceRaw > 0 && (int) $priceRaw <= 4294967295; + if (!$priceValid) { + $errors['price_cents'] = 'Le prix (en centimes) doit etre un entier strictement positif.'; + } + + $vat = ctype_digit(trim($form['vat_rate'] ?? '')) ? (int) trim($form['vat_rate'] ?? '') : 0; + if ($vat !== 55 && $vat !== 100) { + $errors['vat_rate'] = 'La TVA doit valoir 55 (5,5%) ou 100 (10%).'; + } + + $image = trim($form['image_path'] ?? ''); + if ($image !== '' && mb_strlen($image) > 255) { + $errors['image_path'] = 'Chemin image trop long (255 max).'; + } + + $orderRaw = trim($form['display_order'] ?? '0'); + if (!ctype_digit($orderRaw) || (int) $orderRaw > 65535) { + $errors['display_order'] = 'L ordre d affichage doit etre un entier entre 0 et 65535.'; + } + + $description = trim($form['description'] ?? ''); + + $data = [ + 'category_id' => $categoryId, + 'name' => $name, + 'description' => $description !== '' ? $description : null, + 'price_cents' => $priceValid ? (int) $priceRaw : 0, + 'vat_rate' => ($vat === 55 || $vat === 100) ? $vat : 100, + 'image_path' => $image !== '' ? $image : null, + 'is_available' => ($form['is_available'] ?? '') !== '' ? 1 : 0, + 'display_order' => (ctype_digit($orderRaw) && (int) $orderRaw <= 65535) ? (int) $orderRaw : 0, + ]; + + return [$data, $errors]; + } + + /** + * @param array $current + * @param array{price_cents: int, vat_rate: int} $data + */ + private function changeSummary(array $current, array $data, bool $priceChanged, bool $vatChanged): string + { + $parts = []; + if ($priceChanged) { + $parts[] = sprintf('price_cents %d -> %d', (int) ($current['price_cents'] ?? 0), $data['price_cents']); + } + if ($vatChanged) { + $parts[] = sprintf('vat_rate %d -> %d', (int) ($current['vat_rate'] ?? 0), $data['vat_rate']); + } + + return implode(', ', $parts); + } + + /** + * Trace une tentative de PIN echouee sur une action sensible (RG-T14) : rend + * le brute-force d'attribution detectable/alertable (un pic de pin.failed pour + * un email cible est visible en revue). Acteur inconnu (PIN non resolu). + * + * NB : cette ligne d'audit n'est PAS le verrou. Le throttle degressif (par + * utilisateur agissant) est porte par PinThrottle / RG-T22 ; il ecrit une + * nouvelle ligne pin.failed UNIQUEMENT hors verrou actif (sous verrou, les + * echecs ayant arme le verrou sont deja audites), ce qui borne l'amplification + * de l'audit append-only (RG-T14). + */ + private function logFailedPin(DatabaseInterface $db, string $email, int $productId): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => null, + 'rid' => null, + 'code' => 'pin.failed', + 'etype' => 'product', + 'eid' => $productId, + 'summary' => 'Echec PIN action sensible (email tente: ' . $email . ')', + ], + ); + } + + private function writeAudit(DatabaseInterface $db, string $action, int $userId, int $roleId, int $entityId, string $summary): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + ['uid' => $userId, 'rid' => $roleId, 'code' => $action, 'etype' => 'product', 'eid' => $entityId, 'summary' => $summary], + ); + } + + /** + * Decode + valide la composition soumise en JSON (champ cache composition_json), + * RG-T18 (revalidation serveur) + RG-T16 (allowlist). Un ingredient inconnu est + * FILTRE (jamais une erreur bloquante) ; la PK composite impose un ingredient au + * plus une fois (dedup). Les bornes refletent les CHECK de table : quantity_normal + * >= 1, quantity_maxi >= quantity_normal, extra_price_cents >= 0. Composition vide + * = aucune ligne, sans erreur. + * + * @param array $errors + * @return list + */ + private function parseComposition(string $json, array &$errors): array + { + $json = trim($json); + if ($json === '' || $json === '[]') { + return []; + } + + /** @var mixed $decoded */ + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + $errors['composition'] = 'Composition invalide.'; + + return []; + } + + $lines = []; + $seen = []; + foreach ($decoded as $raw) { + if (!is_array($raw)) { + continue; + } + + $ingredientId = is_numeric($raw['ingredient_id'] ?? null) ? (int) $raw['ingredient_id'] : 0; + if ($ingredientId <= 0 || !$this->productRepository()->ingredientExists($ingredientId)) { + continue; // ingredient inconnu : filtre (allowlist), pas une erreur + } + if (isset($seen[$ingredientId])) { + continue; // PK composite (product_id, ingredient_id) : un seul par ingredient + } + + $qn = is_numeric($raw['quantity_normal'] ?? null) ? (int) $raw['quantity_normal'] : 0; + $qm = is_numeric($raw['quantity_maxi'] ?? null) ? (int) $raw['quantity_maxi'] : 0; + $extra = is_numeric($raw['extra_price_cents'] ?? null) ? (int) $raw['extra_price_cents'] : -1; + + if ($qn < 1 || $qn > 65535) { + $errors['composition'] = 'La quantite normale doit etre un entier >= 1.'; + continue; + } + if ($qm < $qn || $qm > 65535) { + $errors['composition'] = 'La quantite maxi doit etre >= la quantite normale.'; + continue; + } + if ($extra < 0 || $extra > 4294967295) { + $errors['composition'] = 'Le supplement (en centimes) doit etre un entier >= 0.'; + continue; + } + + $seen[$ingredientId] = true; + $lines[] = [ + 'ingredient_id' => $ingredientId, + 'quantity_normal' => $qn, + 'quantity_maxi' => $qm, + 'is_removable' => empty($raw['is_removable']) ? 0 : 1, + 'is_addable' => empty($raw['is_addable']) ? 0 : 1, + 'extra_price_cents' => $extra, + ]; + } + + return $lines; + } + + /** + * @param array $product + * @param array $errors + */ + private function renderRecipe(GuardResult $guard, int $id, array $product, array $errors, int $status = 200): Response + { + return $this->adminView('admin/products/recipe', [ + 'title' => 'Recette - ' . (string) ($product['name'] ?? '') . ' - Wakdo Admin', + 'activeNav' => 'products', + 'productId' => $id, + 'productName' => (string) ($product['name'] ?? ''), + 'ingredients' => $this->ingredientRepository()->all(), + 'composition' => $this->productRepository()->composition($id), + 'errors' => $errors, + 'csrfToken' => Csrf::token($this->sessionManager()), + ], $guard, $status); + } + + /** + * @param array $values + * @param array $errors + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/products/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' produit - Wakdo Admin', + 'activeNav' => 'products', + 'productId' => $id, + 'categories' => $this->categoryRepository()->all(), + 'values' => [ + 'category_id' => (string) ($values['category_id'] ?? ''), + 'name' => (string) ($values['name'] ?? ''), + 'description' => (string) ($values['description'] ?? ''), + 'price_cents' => (string) ($values['price_cents'] ?? ''), + 'vat_rate' => (string) ($values['vat_rate'] ?? '100'), + 'image_path' => (string) ($values['image_path'] ?? ''), + // Defaut coche a la creation (errors vide + values vide) ; sur un + // re-rendu POST (erreurs), refleter la presence reelle du champ + // (case decochee = absente = non cochee), pas le defaut a 1. + 'is_available' => $errors === [] ? ((int) ($values['is_available'] ?? 1) === 1) : array_key_exists('is_available', $values), + 'display_order' => (string) ($values['display_order'] ?? '0'), + ], + 'errors' => $errors, + ], $guard, $status); + } + + /** + * @param array $product + */ + private function renderDelete(GuardResult $guard, int $id, array $product, ?string $error, ?int $status = null): Response + { + return $this->adminView('admin/products/delete', [ + 'title' => 'Supprimer un produit - Wakdo Admin', + 'activeNav' => 'products', + 'productId' => $id, + 'name' => (string) ($product['name'] ?? ''), + 'error' => $error, + ], $guard, $status ?? ($error !== null ? 422 : 200)); + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'products'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Controllers/ProfileController.php b/src/app/Controllers/ProfileController.php new file mode 100644 index 0000000..2b88ea8 --- /dev/null +++ b/src/app/Controllers/ProfileController.php @@ -0,0 +1,116 @@ + $params + */ + public function showPin(array $params = []): Response + { + $guard = $this->guard(); + if ($guard instanceof Response) { + return $guard; + } + + $userId = $guard->userId; + if ($userId === null) { + return Response::make('', 302, ['Location' => '/login']); + } + + return $this->adminView('admin/profile/pin', [ + 'title' => 'Mon PIN - Wakdo Admin', + 'activeNav' => '', + 'pinIsSet' => $this->userRepository()->pinIsSet($userId), + 'error' => null, + ], $guard); + } + + /** + * @param array $params + */ + public function updatePin(array $params = []): Response + { + $guard = $this->guard(); + if ($guard instanceof Response) { + return $guard; + } + + $userId = $guard->userId; + if ($userId === null) { + return Response::make('', 302, ['Location' => '/login']); + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } + + $pin = $form['pin'] ?? ''; + $confirm = $form['pin_confirm'] ?? ''; + $error = null; + + if (!$this->pinVerifier()->meetsLengthPolicy($pin)) { + $error = 'Le PIN doit etre uniquement numerique et respecter la longueur requise.'; + } elseif ($pin !== $confirm) { + $error = 'Les PIN ne correspondent pas.'; + } + + if ($error !== null) { + return $this->renderPinForm($guard, $userId, $error, 422); + } + + // Gate sur 1 ligne affectee : une cible inexistante (0 ligne) ne doit pas + // produire un faux "PIN enregistre" (defense en profondeur). + if ($this->userRepository()->setPinHash($userId, $this->passwordHasher()->hash($pin)) !== 1) { + return $this->renderPinForm($guard, $userId, 'Echec de l enregistrement du PIN.', 500); + } + + $this->setFlash('PIN enregistre.'); + + return Response::make('', 302, ['Location' => '/admin/profile/pin']); + } + + private function renderPinForm(GuardResult $guard, int $userId, ?string $error, int $status): Response + { + return $this->adminView('admin/profile/pin', [ + 'title' => 'Mon PIN - Wakdo Admin', + 'activeNav' => '', + 'pinIsSet' => $this->userRepository()->pinIsSet($userId), + 'error' => $error, + ], $guard, $status); + } + + protected function userRepository(): UserRepository + { + return new UserRepository($this->database); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->database, $this->config, $this->passwordHasher()); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } +} diff --git a/src/app/Controllers/RoleController.php b/src/app/Controllers/RoleController.php new file mode 100644 index 0000000..7886bd4 --- /dev/null +++ b/src/app/Controllers/RoleController.php @@ -0,0 +1,459 @@ +`, + * `source_`) et non en tableaux `name[]` : Request::formBody ne conserve que + * les scalaires (pas de JS requis, pas de champ JSON cache). + * + * Non `final` : les tests sous-classent (seam db()/sessionManager()). + */ +class RoleController extends AdminController +{ + private const ENTITY = 'role'; + private const ADMIN_CODE = 'admin'; + + /** @var list ENUM role_visible_source.source / customer_order.source */ + private const SOURCES = ['kiosk', 'counter', 'drive']; + + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('role.manage'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/roles/index', [ + 'title' => 'Roles et permissions - Wakdo Admin', + 'activeNav' => 'roles', + 'roles' => $this->roleRepository()->allRoles(), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard('role.manage'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], [], [], []); + } + + /** + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard('role.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + [$data, $errors] = $this->validate($form, true); + $permIds = $this->selectedPermissionIds($form); + $sources = $this->selectedSources($form); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $errors, $permIds, $sources, 422); + } + if ($this->roleRepository()->codeExists((string) $data['code'])) { + return $this->renderForm($guard, 0, $form, ['code' => 'Ce code de role existe deja.'], $permIds, $sources, 409); + } + + [$actor, $errorMsg] = $this->resolvePin($guard, $form, 0); + if ($actor === null) { + return $this->renderForm($guard, 0, $form, ['pin' => $errorMsg], $permIds, $sources, 422); + } + + $addedCodes = $this->codesForIds($permIds); + try { + $this->db()->transaction(function (DatabaseInterface $db) use ($data, $permIds, $sources, $actor, $addedCodes): void { + $repo = new RoleRepository($db); + $newId = $repo->createRole([ + 'code' => (string) $data['code'], + 'label' => (string) $data['label'], + 'description' => $data['description'], + 'default_route' => $data['default_route'], + 'order_source' => $data['order_source'], + ]); + $repo->replacePermissions($db, $newId, $permIds); + $repo->replaceVisibleSources($db, $newId, $sources); + $this->writeAudit($db, $actor['id'], $actor['role_id'], $newId, 'Creation role ' . (string) $data['code'], ['added' => $addedCodes, 'removed' => []]); + }); + } catch (PDOException $exception) { + if ((string) $exception->getCode() === '23000') { + return $this->renderForm($guard, 0, $form, ['code' => 'Ce code de role existe deja.'], $permIds, $sources, 409); + } + + throw $exception; + } + + $this->pinThrottle()->reset($guard->userId ?? 0); + $this->setFlash('Role cree.'); + + return $this->redirect('/admin/roles'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard('role.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $role = $this->roleRepository()->findRole($id); + if ($role === null) { + return $this->notFound($guard); + } + + return $this->renderForm( + $guard, + $id, + $role, + [], + $this->roleRepository()->permissionIdsFor($id), + $this->roleRepository()->visibleSources($id), + ); + } + + /** + * @param array $params + */ + public function update(array $params): Response + { + $guard = $this->guard('role.manage'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $current = $this->roleRepository()->findRole($id); + if ($current === null) { + return $this->notFound($guard); + } + + [$data, $errors] = $this->validate($form, false); + $permIds = $this->selectedPermissionIds($form); + $sources = $this->selectedSources($form); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form + ['code' => $current['code']], $errors, $permIds, $sources, 422); + } + + $isActive = isset($form['is_active']) ? 1 : 0; + $newCodes = $this->codesForIds($permIds); + + // Garde-fou anti-lockout : le role admin garde role.manage ET reste actif. + if ((string) ($current['code'] ?? '') === self::ADMIN_CODE) { + if (!in_array('role.manage', $newCodes, true) || $isActive === 0) { + return $this->renderForm($guard, $id, $form + ['code' => $current['code']], ['permissions' => 'Le role administrateur doit conserver role.manage et rester actif.'], $permIds, $sources, 422); + } + } + + [$actor, $errorMsg] = $this->resolvePin($guard, $form, $id); + if ($actor === null) { + return $this->renderForm($guard, $id, $form + ['code' => $current['code']], ['pin' => $errorMsg], $permIds, $sources, 422); + } + + // Diff de permissions (RG-6), calcule AVANT la reecriture. + $currentCodes = $this->roleRepository()->permissionCodesFor($id); + $added = array_values(array_diff($newCodes, $currentCodes)); + $removed = array_values(array_diff($currentCodes, $newCodes)); + + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $data, $isActive, $permIds, $sources, $actor, $added, $removed): void { + $repo = new RoleRepository($db); + $repo->updateRole($id, [ + 'label' => (string) $data['label'], + 'description' => $data['description'], + 'default_route' => $data['default_route'], + 'order_source' => $data['order_source'], + 'is_active' => $isActive, + ]); + $repo->replacePermissions($db, $id, $permIds); + $repo->replaceVisibleSources($db, $id, $sources); + $this->writeAudit($db, $actor['id'], $actor['role_id'], $id, 'Mise a jour RBAC role ' . (string) ($data['code'] ?? ''), ['added' => $added, 'removed' => $removed]); + }); + + $this->pinThrottle()->reset($guard->userId ?? 0); + $this->setFlash('Role mis a jour.'); + + return $this->redirect('/admin/roles'); + } + + // --- Helpers --- + + protected function roleRepository(): RoleRepository + { + return new RoleRepository($this->db()); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); + } + + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } + + /** + * @param array $form + * @return list + */ + private function selectedPermissionIds(array $form): array + { + $ids = []; + foreach ($this->roleRepository()->allPermissions() as $p) { + $pid = (int) ($p['id'] ?? 0); + if ($pid > 0 && ($form['perm_' . $pid] ?? '') !== '') { + $ids[] = $pid; + } + } + + return $ids; + } + + /** + * @param array $form + * @return list + */ + private function selectedSources(array $form): array + { + $out = []; + foreach (self::SOURCES as $source) { + if (($form['source_' . $source] ?? '') !== '') { + $out[] = $source; + } + } + + return $out; + } + + /** + * Codes de permission correspondant a une liste d'ids (via le catalogue). + * + * @param list $ids + * @return list + */ + private function codesForIds(array $ids): array + { + $map = []; + foreach ($this->roleRepository()->allPermissions() as $p) { + $map[(int) ($p['id'] ?? 0)] = (string) ($p['code'] ?? ''); + } + $codes = []; + foreach ($ids as $id) { + if (isset($map[$id]) && $map[$id] !== '') { + $codes[] = $map[$id]; + } + } + + return $codes; + } + + /** + * Porte du PIN sensible (RG-T13 + throttle RG-T22), identique a UserController. + * + * @param array $form + * @return array{0: array|null, 1: string} + */ + private function resolvePin(GuardResult $guard, array $form, int $entityId): array + { + $generic = 'Email ou PIN invalide (requis pour cette action).'; + $actorId = $guard->userId ?? 0; + + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return [null, $generic]; + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + $email = trim($form['pin_email'] ?? ''); + $this->db()->transaction(function (DatabaseInterface $db) use ($email, $entityId, $actorId): void { + $this->logFailedPin($db, $email, $entityId); + $this->pinThrottle()->recordFailureWithin($db, $actorId); + }); + + return [null, $generic]; + } + + return [$actor, '']; + } + + /** + * Validation serveur (RG-T18). `code` requis + immuable a la creation seulement. + * + * @param array $form + * @return array{0: array{code: ?string, label: string, description: ?string, default_route: ?string, order_source: ?string}, 1: array} + */ + private function validate(array $form, bool $isCreate): array + { + $errors = []; + + $code = null; + if ($isCreate) { + $code = trim($form['code'] ?? ''); + if ($code === '' || mb_strlen($code) > 40 || preg_match('/^[a-z][a-z0-9_]{1,39}$/', $code) !== 1) { + $errors['code'] = 'Code requis : minuscules/chiffres/_ , commence par une lettre (40 max).'; + } + } + + $label = trim($form['label'] ?? ''); + if ($label === '' || mb_strlen($label) > 80) { + $errors['label'] = 'Le libelle est requis (80 caracteres max).'; + } + + $route = trim($form['default_route'] ?? ''); + if (mb_strlen($route) > 120) { + $errors['default_route'] = 'Route par defaut trop longue (120 max).'; + } + + $source = trim($form['order_source'] ?? ''); + if ($source !== '' && !in_array($source, self::SOURCES, true)) { + $errors['order_source'] = 'Source de commande invalide.'; + } + + $description = trim($form['description'] ?? ''); + + $data = [ + 'code' => $code, + 'label' => $label, + 'description' => $description !== '' ? $description : null, + 'default_route' => $route !== '' ? $route : null, + 'order_source' => $source !== '' ? $source : null, + ]; + + return [$data, $errors]; + } + + private function logFailedPin(DatabaseInterface $db, string $email, int $entityId): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => null, + 'rid' => null, + 'code' => 'pin.failed', + 'etype' => self::ENTITY, + 'eid' => $entityId > 0 ? $entityId : null, + 'summary' => 'Echec PIN gestion RBAC (email tente: ' . $email . ')', + ], + ); + } + + /** + * @param array $details + */ + private function writeAudit(DatabaseInterface $db, int $userId, int $roleId, int $entityId, string $summary, array $details): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary, details) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary, :details)', + [ + 'uid' => $userId, + 'rid' => $roleId, + 'code' => 'role.manage', + 'etype' => self::ENTITY, + 'eid' => $entityId, + 'summary' => $summary, + 'details' => (string) json_encode($details), + ], + ); + } + + /** + * @param array $values + * @param array $errors + * @param list $selectedPermIds + * @param list $selectedSources + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $errors, array $selectedPermIds, array $selectedSources, int $status = 200): Response + { + return $this->adminView('admin/roles/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' role - Wakdo Admin', + 'activeNav' => 'roles', + 'roleId' => $id, + 'isAdminRole' => (string) ($values['code'] ?? '') === self::ADMIN_CODE, + 'permissions' => $this->roleRepository()->allPermissions(), + 'sources' => self::SOURCES, + 'selectedPerms' => $selectedPermIds, + 'selectedSources' => $selectedSources, + 'values' => [ + 'code' => (string) ($values['code'] ?? ''), + 'label' => (string) ($values['label'] ?? ''), + 'description' => (string) ($values['description'] ?? ''), + 'default_route' => (string) ($values['default_route'] ?? ''), + 'order_source' => (string) ($values['order_source'] ?? ''), + 'is_active' => $id === 0 ? true : ((int) ($values['is_active'] ?? 1) === 1), + ], + 'errors' => $errors, + 'csrfToken' => Csrf::token($this->sessionManager()), + ], $guard, $status); + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'roles'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Controllers/StatsController.php b/src/app/Controllers/StatsController.php new file mode 100644 index 0000000..706bca1 --- /dev/null +++ b/src/app/Controllers/StatsController.php @@ -0,0 +1,50 @@ + $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('stats.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/stats/index', [ + 'title' => 'Statistiques - Wakdo Admin', + 'activeNav' => 'stats', + 'counts' => $this->statsRepository()->counts(), + 'stock' => $this->statsRepository()->stockHealth(), + 'sales' => $this->orderQuery()->salesKpis(), + ], $guard); + } + + protected function statsRepository(): StatsRepository + { + return new StatsRepository($this->db()); + } + + protected function orderQuery(): OrderQueryRepository + { + return new OrderQueryRepository($this->db()); + } +} diff --git a/src/app/Controllers/UserController.php b/src/app/Controllers/UserController.php new file mode 100644 index 0000000..258ee2a --- /dev/null +++ b/src/app/Controllers/UserController.php @@ -0,0 +1,683 @@ + 409 (convention PR-0). + * + * Non `final` : les tests sous-classent (seam db()/sessionManager()). + */ +class UserController extends AdminController +{ + private const ENTITY = 'user'; + + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('user.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/users/index', [ + 'title' => 'Utilisateurs - Wakdo Admin', + 'activeNav' => 'users', + 'users' => $this->userRepository()->all(), + 'currentId' => $guard->userId ?? 0, + 'canCreate' => $this->may($guard, 'user.create'), + 'canUpdate' => $this->may($guard, 'user.update'), + 'canDeactiv' => $this->may($guard, 'user.deactivate'), + ], $guard); + } + + /** + * @param array $params + */ + public function create(array $params = []): Response + { + $guard = $this->guard('user.create'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->renderForm($guard, 0, [], []); + } + + /** + * @param array $params + */ + public function store(array $params = []): Response + { + $guard = $this->guard('user.create'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + [$data, $errors] = $this->validate($form, false); + if ($errors !== []) { + return $this->renderForm($guard, 0, $form, $errors, 422); + } + if ($this->userRepository()->emailExists($data['email'])) { + return $this->renderForm($guard, 0, $form, ['email' => 'Cet email est deja utilise.'], 409); + } + + [$actor, $errorMsg] = $this->resolvePin($guard, $form, 0); + if ($actor === null) { + return $this->renderForm($guard, 0, $form, ['pin' => $errorMsg], 422); + } + + $hash = $this->passwordHasher()->hash((string) $data['password']); + try { + $this->db()->transaction(function (DatabaseInterface $db) use ($data, $hash, $actor): void { + $newId = (new UserRepository($db))->create([ + 'email' => $data['email'], + 'password_hash' => $hash, + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'role_id' => $data['role_id'], + ]); + $this->writeAudit($db, 'user.create', $actor['id'], $actor['role_id'], $newId, 'Creation utilisateur', ['role_id' => $data['role_id']]); + }); + } catch (PDOException $exception) { + if ((string) $exception->getCode() === '23000') { + return $this->renderForm($guard, 0, $form, ['email' => 'Cet email est deja utilise.'], 409); + } + + throw $exception; + } + + $this->pinThrottle()->reset($guard->userId ?? 0); + $this->setFlash('Utilisateur cree.'); + + return $this->redirect('/admin/users'); + } + + /** + * @param array $params + */ + public function edit(array $params): Response + { + $guard = $this->guard('user.update'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $user = $this->userRepository()->find($id); + if ($user === null) { + return $this->notFound($guard); + } + + return $this->renderForm($guard, $id, $user, []); + } + + /** + * @param array $params + */ + public function update(array $params): Response + { + $guard = $this->guard('user.update'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $current = $this->userRepository()->find($id); + if ($current === null) { + return $this->notFound($guard); + } + + [$data, $errors] = $this->validate($form, true); + if ($errors !== []) { + return $this->renderForm($guard, $id, $form, $errors, 422); + } + if ($this->userRepository()->emailExists($data['email'], $id)) { + return $this->renderForm($guard, $id, $form, ['email' => 'Cet email est deja utilise.'], 409); + } + + $isActive = isset($form['is_active']) ? 1 : 0; + + // Anti-lockout : on ne retire pas le statut d'admin actif au DERNIER admin + // actif (desactivation OU changement de role) -> sinon back-office inaccessible. + if ($this->isLastActiveAdmin($current) && ($isActive === 0 || $data['role_id'] !== (int) ($current['role_id'] ?? 0))) { + return $this->renderForm($guard, $id, $form, ['role_id' => 'Impossible de retirer le dernier administrateur actif.'], 422); + } + + [$actor, $errorMsg] = $this->resolvePin($guard, $form, $id); + if ($actor === null) { + return $this->renderForm($guard, $id, $form, ['pin' => $errorMsg], 422); + } + + $changed = $this->changedFields($current, $data, $isActive); + $newHash = $data['password'] !== null ? $this->passwordHasher()->hash((string) $data['password']) : null; + + try { + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $data, $isActive, $newHash, $actor, $changed): void { + $repo = new UserRepository($db); + $repo->update($id, [ + 'email' => $data['email'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'role_id' => $data['role_id'], + 'is_active' => $isActive, + ]); + if ($newHash !== null) { + $repo->setPasswordHash($id, $newHash); + } + $this->writeAudit($db, 'user.update', $actor['id'], $actor['role_id'], $id, 'Mise a jour utilisateur', ['fields' => $changed]); + }); + } catch (PDOException $exception) { + if ((string) $exception->getCode() === '23000') { + return $this->renderForm($guard, $id, $form, ['email' => 'Cet email est deja utilise.'], 409); + } + + throw $exception; + } + + $this->pinThrottle()->reset($guard->userId ?? 0); + $this->setFlash('Utilisateur mis a jour.'); + + return $this->redirect('/admin/users'); + } + + /** + * @param array $params + */ + public function confirmDeactivate(array $params): Response + { + $guard = $this->guard('user.deactivate'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $user = $this->userRepository()->find($id); + if ($user === null) { + return $this->notFound($guard); + } + + return $this->renderConfirm($guard, 'deactivate', $id, $user, null); + } + + /** + * @param array $params + */ + public function deactivate(array $params): Response + { + $guard = $this->guard('user.deactivate'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $user = $this->userRepository()->find($id); + if ($user === null) { + return $this->notFound($guard); + } + + // mlt 10.3 PRE-2 : pas d'auto-desactivation (on ne se coupe pas l'acces). + if ($id === ($guard->userId ?? 0)) { + return $this->renderConfirm($guard, 'deactivate', $id, $user, 'Vous ne pouvez pas desactiver votre propre compte.', 403); + } + if ($this->isLastActiveAdmin($user)) { + return $this->renderConfirm($guard, 'deactivate', $id, $user, 'Impossible de desactiver le dernier administrateur actif.', 422); + } + + [$actor, $errorMsg] = $this->resolvePin($guard, $form, $id); + if ($actor === null) { + return $this->renderConfirm($guard, 'deactivate', $id, $user, $errorMsg, 422); + } + + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor): void { + (new UserRepository($db))->deactivate($id); + $this->writeAudit($db, 'user.deactivate', $actor['id'], $actor['role_id'], $id, 'Desactivation utilisateur', null); + }); + + $this->pinThrottle()->reset($guard->userId ?? 0); + $this->setFlash('Utilisateur desactive.'); + + return $this->redirect('/admin/users'); + } + + /** + * @param array $params + */ + public function confirmResetPin(array $params): Response + { + $guard = $this->guard('user.update'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $user = $this->userRepository()->find($id); + if ($user === null) { + return $this->notFound($guard); + } + + return $this->renderConfirm($guard, 'reset-pin', $id, $user, null); + } + + /** + * @param array $params + */ + public function resetPin(array $params): Response + { + $guard = $this->guard('user.update'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $user = $this->userRepository()->find($id); + if ($user === null) { + return $this->notFound($guard); + } + + [$actor, $errorMsg] = $this->resolvePin($guard, $form, $id); + if ($actor === null) { + return $this->renderConfirm($guard, 'reset-pin', $id, $user, $errorMsg, 422); + } + + // Met le PIN a NULL : l'equipier le redefinit en self-service. L'admin + // n'a jamais connaissance du PIN d'autrui. + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor): void { + (new UserRepository($db))->clearPin($id); + $this->writeAudit($db, 'user.update', $actor['id'], $actor['role_id'], $id, 'Reinitialisation du PIN', ['fields' => ['pin_hash']]); + }); + + $this->pinThrottle()->reset($guard->userId ?? 0); + $this->setFlash('PIN reinitialise : l\'equipier doit le redefinir.'); + + return $this->redirect('/admin/users'); + } + + /** + * @param array $params + */ + public function confirmErase(array $params): Response + { + $guard = $this->guard('user.update'); + if ($guard instanceof Response) { + return $guard; + } + + $id = (int) ($params['id'] ?? 0); + $user = $this->userRepository()->find($id); + if ($user === null) { + return $this->notFound($guard); + } + + return $this->renderConfirm($guard, 'erase', $id, $user, null); + } + + /** + * Effacement RGPD (mlt 10.5) : anonymise la ligne (tombstone), preserve les FK. + * + * @param array $params + */ + public function erase(array $params): Response + { + $guard = $this->guard('user.update'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $id = (int) ($params['id'] ?? 0); + $user = $this->userRepository()->find($id); + if ($user === null) { + return $this->notFound($guard); + } + + // PRE-3 : deja anonymise -> 409. + if (($user['anonymized_at'] ?? null) !== null) { + return $this->renderConfirm($guard, 'erase', $id, $user, 'Ce compte est deja anonymise.', 409); + } + if ($id === ($guard->userId ?? 0)) { + return $this->renderConfirm($guard, 'erase', $id, $user, 'Vous ne pouvez pas anonymiser votre propre compte.', 403); + } + if ($this->isLastActiveAdmin($user)) { + return $this->renderConfirm($guard, 'erase', $id, $user, 'Impossible d\'anonymiser le dernier administrateur actif.', 422); + } + + [$actor, $errorMsg] = $this->resolvePin($guard, $form, $id); + if ($actor === null) { + return $this->renderConfirm($guard, 'erase', $id, $user, $errorMsg, 422); + } + + $erased = 0; + $this->db()->transaction(function (DatabaseInterface $db) use ($id, $actor, &$erased): void { + $erased = (new UserRepository($db))->anonymise($id); + if ($erased === 1) { + $this->writeAudit($db, 'user.erase_pii', $actor['id'], $actor['role_id'], $id, 'Anonymisation RGPD (droit a l effacement)', null); + } + }); + + // Course : anonymise entre la lecture et l'effacement -> 0 ligne (409). + if ($erased !== 1) { + return $this->renderConfirm($guard, 'erase', $id, $user, 'Ce compte est deja anonymise.', 409); + } + + $this->pinThrottle()->reset($guard->userId ?? 0); + $this->setFlash('Compte anonymise (RGPD).'); + + return $this->redirect('/admin/users'); + } + + // --- Helpers --- + + protected function userRepository(): UserRepository + { + return new UserRepository($this->db()); + } + + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); + } + + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } + + private function may(GuardResult $guard, string $permission): bool + { + return $guard->roleId !== null && $this->authorizer()->can($guard->roleId, $permission); + } + + /** + * Le compte cible est-il le dernier administrateur ACTIF ? (actif + role admin + * + un seul admin actif au total). Garde anti-lockout du back-office. + * + * @param array $user + */ + private function isLastActiveAdmin(array $user): bool + { + return (int) ($user['is_active'] ?? 0) === 1 + && $this->userRepository()->isAdmin((int) ($user['id'] ?? 0)) + && $this->userRepository()->activeAdminCount() === 1; + } + + /** + * Porte du PIN d'action sensible (RG-T13 + throttle RG-T22), mutualisee par + * toutes les mutations. Verrou evalue AVANT la verification (leurre de timing) ; + * sur echec hors verrou, ecrit pin.failed + increment du throttle dans UNE + * transaction (RG-T08/RG-T14). Retourne [acteur resolu, null] au succes, sinon + * [null, message generique]. La reinitialisation du compteur (succes) est + * laissee a l'appelant, apres l'effet. + * + * @param array $form + * @return array{0: array|null, 1: string} + */ + private function resolvePin(GuardResult $guard, array $form, int $entityId): array + { + $generic = 'Email ou PIN invalide (requis pour cette action).'; + $actorId = $guard->userId ?? 0; + + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return [null, $generic]; + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + $email = trim($form['pin_email'] ?? ''); + $this->db()->transaction(function (DatabaseInterface $db) use ($email, $entityId, $actorId): void { + $this->logFailedPin($db, $email, $entityId); + $this->pinThrottle()->recordFailureWithin($db, $actorId); + }); + + return [null, $generic]; + } + + return [$actor, '']; + } + + /** + * Validation serveur (RG-T18) + normalisation. Mot de passe requis a la creation + * (>= 8), optionnel a l'edition (re-hache seulement si fourni, mlt 10.2 RG-1/2). + * + * @param array $form + * @return array{0: array{email: string, first_name: string, last_name: string, role_id: int, password: ?string}, 1: array} + */ + private function validate(array $form, bool $isUpdate): array + { + $errors = []; + + $email = trim($form['email'] ?? ''); + if ($email === '' || mb_strlen($email) > 254 || filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + $errors['email'] = 'Email valide requis (254 caracteres max).'; + } + + $first = trim($form['first_name'] ?? ''); + if ($first === '' || mb_strlen($first) > 60) { + $errors['first_name'] = 'Le prenom est requis (60 caracteres max).'; + } + + $last = trim($form['last_name'] ?? ''); + if ($last === '' || mb_strlen($last) > 60) { + $errors['last_name'] = 'Le nom est requis (60 caracteres max).'; + } + + $roleRaw = trim($form['role_id'] ?? ''); + $roleId = ctype_digit($roleRaw) ? (int) $roleRaw : 0; + if ($roleId === 0 || !$this->userRepository()->activeRoleExists($roleId)) { + $errors['role_id'] = 'Role requis et actif.'; + } + + $password = (string) ($form['password'] ?? ''); + if (!$isUpdate && mb_strlen($password) < 8) { + $errors['password'] = 'Mot de passe requis (8 caracteres min).'; + } elseif ($isUpdate && $password !== '' && mb_strlen($password) < 8) { + $errors['password'] = 'Le nouveau mot de passe doit faire 8 caracteres min.'; + } + + $data = [ + 'email' => $email, + 'first_name' => $first, + 'last_name' => $last, + 'role_id' => $roleId, + 'password' => $password !== '' ? $password : null, + ]; + + return [$data, $errors]; + } + + /** + * Noms des champs modifies (pas les valeurs, pas de PII) pour le `details` + * d'audit (RG-T14). + * + * @param array $current + * @param array{email: string, first_name: string, last_name: string, role_id: int, password: ?string} $data + * @return list + */ + private function changedFields(array $current, array $data, int $isActive): array + { + $changed = []; + if ($data['email'] !== (string) ($current['email'] ?? '')) { + $changed[] = 'email'; + } + if ($data['first_name'] !== (string) ($current['first_name'] ?? '')) { + $changed[] = 'first_name'; + } + if ($data['last_name'] !== (string) ($current['last_name'] ?? '')) { + $changed[] = 'last_name'; + } + if ($data['role_id'] !== (int) ($current['role_id'] ?? 0)) { + $changed[] = 'role_id'; + } + if ($isActive !== (int) ($current['is_active'] ?? 0)) { + $changed[] = 'is_active'; + } + if ($data['password'] !== null) { + $changed[] = 'password_hash'; + } + + return $changed; + } + + private function logFailedPin(DatabaseInterface $db, string $email, int $entityId): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => null, + 'rid' => null, + 'code' => 'pin.failed', + 'etype' => self::ENTITY, + 'eid' => $entityId > 0 ? $entityId : null, + 'summary' => 'Echec PIN gestion utilisateur (email tente: ' . $email . ')', + ], + ); + } + + /** + * @param array|null $details + */ + private function writeAudit(DatabaseInterface $db, string $action, int $userId, int $roleId, int $entityId, string $summary, ?array $details): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary, details) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary, :details)', + [ + 'uid' => $userId, + 'rid' => $roleId, + 'code' => $action, + 'etype' => self::ENTITY, + 'eid' => $entityId, + 'summary' => $summary, + 'details' => $details !== null ? (string) json_encode($details) : null, + ], + ); + } + + /** + * @param array $values + * @param array $errors + */ + private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response + { + return $this->adminView('admin/users/form', [ + 'title' => ($id !== 0 ? 'Modifier' : 'Nouvel') . ' utilisateur - Wakdo Admin', + 'activeNav' => 'users', + 'userId' => $id, + 'roles' => $this->rolesForSelect(), + 'values' => [ + 'email' => (string) ($values['email'] ?? ''), + 'first_name' => (string) ($values['first_name'] ?? ''), + 'last_name' => (string) ($values['last_name'] ?? ''), + 'role_id' => (string) ($values['role_id'] ?? ''), + // Defaut actif a la creation ; sur re-rendu refleter la presence du champ. + 'is_active' => $id === 0 ? true : ((int) ($values['is_active'] ?? 1) === 1), + ], + 'errors' => $errors, + 'csrfToken' => Csrf::token($this->sessionManager()), + ], $guard, $status); + } + + /** + * @param array $user + */ + private function renderConfirm(GuardResult $guard, string $kind, int $id, array $user, ?string $error, ?int $status = null): Response + { + return $this->adminView('admin/users/confirm', [ + 'title' => 'Confirmation - Wakdo Admin', + 'activeNav' => 'users', + 'kind' => $kind, + 'userId' => $id, + 'userLabel' => trim(((string) ($user['first_name'] ?? '')) . ' ' . ((string) ($user['last_name'] ?? ''))) ?: (string) ($user['email'] ?? ''), + 'error' => $error, + 'csrfToken' => Csrf::token($this->sessionManager()), + ], $guard, $status ?? ($error !== null ? 422 : 200)); + } + + /** + * Roles actifs pour le select (id + label), via une lecture directe (pas de + * repo dedie avant le lot RBAC). + * + * @return list + */ + private function rolesForSelect(): array + { + $rows = $this->db()->fetchAll('SELECT id, label FROM role WHERE is_active = 1 ORDER BY label'); + + return array_map(static fn (array $r): array => [ + 'id' => (int) ($r['id'] ?? 0), + 'label' => (string) ($r['label'] ?? ''), + ], $rows); + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'users'], $guard, 404); + } + + private function redirect(string $location): Response + { + return Response::make('', 302, ['Location' => $location]); + } + + private function invalidCsrf(): Response + { + return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']); + } +} diff --git a/src/app/Core/Autoloader.php b/src/app/Core/Autoloader.php new file mode 100644 index 0000000..dc1ecf8 --- /dev/null +++ b/src/app/Core/Autoloader.php @@ -0,0 +1,43 @@ + {src/app}/Core/Router.php + */ +final class Autoloader +{ + private const PREFIX = 'App\\'; + + /** + * Enregistre l'autoloader aupres de la pile SPL. + * + * La racine src/app/ est calculee depuis l'emplacement de ce fichier + * (src/app/Core/Autoloader.php) : dirname(__DIR__) remonte de Core/ a src/app/. + * Aucun chemin code en dur, donc portable host/conteneur. + */ + public static function register(): void + { + $root = dirname(__DIR__); + + spl_autoload_register(static function (string $class) use ($root): void { + if (!str_starts_with($class, self::PREFIX)) { + return; + } + + $relative = substr($class, strlen(self::PREFIX)); + $path = $root . DIRECTORY_SEPARATOR + . str_replace('\\', DIRECTORY_SEPARATOR, $relative) + . '.php'; + + if (is_file($path)) { + require $path; + } + }); + } +} diff --git a/src/app/Core/Config.php b/src/app/Core/Config.php new file mode 100644 index 0000000..550a2e9 --- /dev/null +++ b/src/app/Core/Config.php @@ -0,0 +1,80 @@ +get($key); + + if ($value === null) { + throw new RuntimeException(sprintf('Missing required configuration: %s', $key)); + } + + return $value; + } + + public function int(string $key, int $default = 0): int + { + $value = $this->get($key); + + return $value === null ? $default : (int) $value; + } + + /** + * Interprete les conventions usuelles de booleen textuel d'environnement. + */ + public function bool(string $key, bool $default = false): bool + { + $value = $this->get($key); + + if ($value === null) { + return $default; + } + + return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + } + + public function appEnv(): string + { + return $this->get('APP_ENV', 'production') ?? 'production'; + } + + public function isDebug(): bool + { + return $this->bool('APP_DEBUG', false); + } + + public function timezone(): string + { + return $this->get('APP_TIMEZONE', 'UTC') ?? 'UTC'; + } +} diff --git a/src/app/Core/Controller.php b/src/app/Core/Controller.php new file mode 100644 index 0000000..dc05746 --- /dev/null +++ b/src/app/Core/Controller.php @@ -0,0 +1,78 @@ + AuthenticatedController -> AdminController -> + * DashboardController (et les futurs CRUD), ou directement HomeController / HealthController. + * + * Recoit ses dependances par constructeur : la requete courante, la config et + * l'acces BDD, injectes par le Router. + */ +abstract class Controller +{ + public function __construct( + protected readonly Request $request, + protected readonly Config $config, + protected readonly Database $database, + ) { + } + + /** + * @param array $data + */ + protected function json(array $data, int $status = 200): Response + { + return (new Response())->json($data, $status); + } + + /** + * Rend une vue PHP sous src/app/Views/.php avec ses donnees extraites. + * + * Le rendu est bufferise puis injecte dans le layout via la variable + * $content, ce qui permet aux vues de rester de simples fragments. + * + * @param array $data + */ + protected function view(string $name, array $data = [], int $status = 200): Response + { + $content = $this->render($name, $data); + $html = $this->render($this->layoutName(), $data + ['content' => $content]); + + return (new Response())->html($html, $status); + } + + /** + * Gabarit enveloppant les vues. Defaut : le layout minimal. Les controleurs + * back-office surchargent ce hook pour rendre dans le shell admin. + */ + protected function layoutName(): string + { + return 'layout'; + } + + /** + * @param array $data + */ + private function render(string $name, array $data): string + { + $file = dirname(__DIR__) . '/Views/' . $name . '.php'; + + if (!is_file($file)) { + throw new RuntimeException(sprintf('View not found: %s', $name)); + } + + // Les cles deviennent des variables locales a la vue ; le buffering + // capture le HTML produit sans l'emettre directement. + extract($data, EXTR_SKIP); + ob_start(); + require $file; + + return (string) ob_get_clean(); + } +} diff --git a/src/app/Core/Cors.php b/src/app/Core/Cors.php new file mode 100644 index 0000000..d6c0cf0 --- /dev/null +++ b/src/app/Core/Cors.php @@ -0,0 +1,99 @@ + le navigateur bloque. + * + * Decouple de Config (recoit l'origine en chaine) -> testable sans environnement ; + * le front controller lit CORS_ALLOWED_ORIGIN et l'injecte. + */ +final class Cors +{ + private const ALLOW_METHODS = 'GET, POST, OPTIONS'; + private const ALLOW_HEADERS = 'Content-Type'; + private const MAX_AGE = '600'; + + public function __construct(private readonly string $allowedOrigin) + { + } + + /** + * Repond a une requete preliminaire (preflight) : OPTIONS sur /api/ depuis + * l'origine autorisee -> 204 avec les en-tetes CORS, court-circuitant le routeur + * (qui n'a pas de route OPTIONS). Renvoie null si ce n'est pas un preflight a + * traiter ici : le flux normal de dispatch continue. + */ + public function preflightResponse(Request $request): ?Response + { + if ($request->method() !== 'OPTIONS' || !$this->isAllowed($request)) { + return null; + } + + $response = (new Response())->setStatus(204); + $this->putHeaders($response, true); + + return $response; + } + + /** + * Pose les en-tetes CORS sur une reponse effective (GET/POST), y compris une + * reponse d'erreur (le navigateur a besoin de l'en-tete pour lire le corps d'une + * 4xx), si la requete vient de l'origine autorisee vers /api/. No-op sinon. + */ + public function applyTo(Request $request, Response $response): void + { + if (!$this->isAllowed($request)) { + return; + } + + $this->putHeaders($response, false); + } + + /** + * Origine exacte configuree ET requete /api/ ET Origin de la requete identique. + * Comparaison stricte par egalite (pas de prefixe, pas de joker). + */ + private function isAllowed(Request $request): bool + { + if ($this->allowedOrigin === '') { + return false; + } + + if (!str_starts_with($request->path(), '/api/')) { + return false; + } + + return $request->header('origin') === $this->allowedOrigin; + } + + private function putHeaders(Response $response, bool $preflight): void + { + $response->setHeader('Access-Control-Allow-Origin', $this->allowedOrigin); + $response->setHeader('Vary', 'Origin'); + + if ($preflight) { + $response->setHeader('Access-Control-Allow-Methods', self::ALLOW_METHODS); + $response->setHeader('Access-Control-Allow-Headers', self::ALLOW_HEADERS); + $response->setHeader('Access-Control-Max-Age', self::MAX_AGE); + } + } +} diff --git a/src/app/Core/Database.php b/src/app/Core/Database.php new file mode 100644 index 0000000..4ab5c67 --- /dev/null +++ b/src/app/Core/Database.php @@ -0,0 +1,121 @@ +pdo === null) { + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', + $this->config->required('DB_HOST'), + $this->config->int('DB_PORT', 3306), + $this->config->required('DB_NAME'), + ); + + $this->pdo = new PDO( + $dsn, + $this->config->required('DB_USER'), + $this->config->required('DB_PASSWORD'), + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + // Vraies requetes preparees cote serveur (pas d'emulation) : + // le SQL et les valeurs voyagent separement, fermant l'injection. + PDO::ATTR_EMULATE_PREPARES => false, + ], + ); + } + + return $this->pdo; + } + + /** + * Prepare puis execute une requete avec ses parametres lies. + * + * @param array $params + */ + public function query(string $sql, array $params = []): PDOStatement + { + $statement = $this->pdo()->prepare($sql); + $statement->execute($params); + + return $statement; + } + + /** + * @param array $params + * @return array|null + */ + public function fetch(string $sql, array $params = []): ?array + { + $row = $this->query($sql, $params)->fetch(); + + return $row === false ? null : $row; + } + + /** + * @param array $params + * @return array> + */ + public function fetchAll(string $sql, array $params = []): array + { + return $this->query($sql, $params)->fetchAll(); + } + + /** + * Execute une ecriture et renvoie le nombre de lignes affectees. + * + * @param array $params + */ + public function execute(string $sql, array $params = []): int + { + return $this->query($sql, $params)->rowCount(); + } + + /** + * Execute $fn dans une transaction atomique (RG-T08) : begin -> $fn -> commit. + * Tout Throwable declenche un rollback complet puis est repropage : jamais + * d'ecriture partielle, jamais d'echec silencieux. Le retour est void (et non + * mixed) pour rester strictement type sous PHPStan ; $fn ecrit via le $this + * qui lui est passe (memes requetes preparees, meme connexion). + * + * @param callable(DatabaseInterface): void $fn + */ + public function transaction(callable $fn): void + { + $pdo = $this->pdo(); + $pdo->beginTransaction(); + + try { + $fn($this); + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } + } +} diff --git a/src/app/Core/DatabaseInterface.php b/src/app/Core/DatabaseInterface.php new file mode 100644 index 0000000..b2b7710 --- /dev/null +++ b/src/app/Core/DatabaseInterface.php @@ -0,0 +1,43 @@ + $params + * @return array|null + */ + public function fetch(string $sql, array $params = []): ?array; + + /** + * @param array $params + * @return array> + */ + public function fetchAll(string $sql, array $params = []): array; + + /** + * @param array $params + */ + public function execute(string $sql, array $params = []): int; + + /** + * Execute $fn dans une transaction atomique : commit si succes, rollback + * complet sur tout Throwable (puis repropagation). + * + * @param callable(DatabaseInterface): void $fn + */ + public function transaction(callable $fn): void; +} diff --git a/src/app/Core/Request.php b/src/app/Core/Request.php new file mode 100644 index 0000000..eb5e1cf --- /dev/null +++ b/src/app/Core/Request.php @@ -0,0 +1,214 @@ + $query + * @param array $headers + */ + public function __construct( + private readonly string $method, + private readonly string $path, + private readonly array $query, + private readonly array $headers, + private readonly string $rawBody, + // Adresse de la connexion TCP entrante (le proxy Traefik en frontal). + // Defaut vide pour conserver la compatibilite des appels a 5 arguments + // (tests existants). clientIp() s'en sert comme repli derriere X-Forwarded-For. + private readonly string $remoteAddr = '', + ) { + } + + public static function fromGlobals(): self + { + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + + // REQUEST_URI inclut la query string ; on isole le chemin seul. + $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); + $path = parse_url($uri, PHP_URL_PATH); + $path = is_string($path) ? $path : '/'; + $path = self::normalizePath($path); + + /** @var array $query */ + $query = $_GET; + + return new self( + $method, + $path, + $query, + self::extractHeaders(), + (string) file_get_contents('php://input'), + (string) ($_SERVER['REMOTE_ADDR'] ?? ''), + ); + } + + /** + * Garde un slash de tete et retire le slash de fin (sauf racine) pour + * que "/api/health/" et "/api/health" matchent la meme route. + */ + private static function normalizePath(string $path): string + { + if ($path === '') { + return '/'; + } + + if ($path[0] !== '/') { + $path = '/' . $path; + } + + if ($path !== '/' && str_ends_with($path, '/')) { + $path = rtrim($path, '/'); + } + + return $path; + } + + /** + * @return array + */ + private static function extractHeaders(): array + { + $headers = []; + + foreach ($_SERVER as $key => $value) { + if (str_starts_with($key, 'HTTP_')) { + $name = str_replace('_', '-', substr($key, 5)); + $headers[strtolower($name)] = (string) $value; + } + } + + // Content-Type / Content-Length ne sont pas prefixes HTTP_ par PHP. + if (isset($_SERVER['CONTENT_TYPE'])) { + $headers['content-type'] = (string) $_SERVER['CONTENT_TYPE']; + } + if (isset($_SERVER['CONTENT_LENGTH'])) { + $headers['content-length'] = (string) $_SERVER['CONTENT_LENGTH']; + } + + return $headers; + } + + public function method(): string + { + return $this->method; + } + + public function path(): string + { + return $this->path; + } + + public function query(string $key, ?string $default = null): ?string + { + return $this->query[$key] ?? $default; + } + + /** + * @return array + */ + public function allQuery(): array + { + return $this->query; + } + + public function header(string $name, ?string $default = null): ?string + { + return $this->headers[strtolower($name)] ?? $default; + } + + public function rawBody(): string + { + return $this->rawBody; + } + + /** + * Decode le corps JSON ; renvoie un tableau vide si le corps est vide ou + * invalide, pour laisser la validation metier decider (pas de fatale ici). + * + * @return array + */ + public function json(): array + { + if ($this->rawBody === '') { + return []; + } + + $decoded = json_decode($this->rawBody, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * Decode un corps application/x-www-form-urlencoded en map cle => valeur. + * Symetrique de json() : renvoie [] si le content-type n'est pas un + * formulaire urlencode, pour laisser la validation metier decider (pas de + * fatale ici). Le back-office se connecte par formulaire POST, pas par JSON. + * + * @return array + */ + public function formBody(): array + { + $contentType = $this->header('content-type') ?? ''; + + if (!str_starts_with($contentType, 'application/x-www-form-urlencoded')) { + return []; + } + + parse_str($this->rawBody, $parsed); + + // parse_str peut produire des valeurs tableau (cle[]=...) ; on ne retient + // que les scalaires convertis en chaine pour tenir le contrat strict + // array (et neutraliser une cle de type "champ[]"). + $form = []; + foreach ($parsed as $key => $value) { + if (is_scalar($value)) { + $form[(string) $key] = (string) $value; + } + } + + return $form; + } + + /** + * IP client reelle derriere le reverse proxy Traefik. REMOTE_ADDR est ici + * toujours l'adresse du proxy, donc on lit X-Forwarded-For et on retient le + * DERNIER hop : c'est celui ajoute par Traefik (proxy de confiance), tandis + * que les entrees de gauche sont fournies par le client et donc falsifiables. + * La valeur est validee par FILTER_VALIDATE_IP et bornee a 45 caracteres + * (taille de login_throttle.ip_address). Repli sur REMOTE_ADDR si l'en-tete + * est absent ou invalide ; sentinelle 0.0.0.0 en dernier recours. + * + * Hypothese de deploiement : un unique proxy de confiance (Traefik) est + * toujours en frontal. Sans lui, X-Forwarded-For serait falsifiable ; le + * verrou par compte (failed_login_attempts) reste alors le garde-fou. + */ + public function clientIp(): string + { + $forwarded = $this->header('x-forwarded-for'); + + if ($forwarded !== null && $forwarded !== '') { + $hops = explode(',', $forwarded); + $candidate = trim((string) end($hops)); + + if (filter_var($candidate, FILTER_VALIDATE_IP) !== false) { + return substr($candidate, 0, 45); + } + } + + if ($this->remoteAddr !== '' && filter_var($this->remoteAddr, FILTER_VALIDATE_IP) !== false) { + return substr($this->remoteAddr, 0, 45); + } + + return '0.0.0.0'; + } +} diff --git a/src/app/Core/Response.php b/src/app/Core/Response.php new file mode 100644 index 0000000..49a11ea --- /dev/null +++ b/src/app/Core/Response.php @@ -0,0 +1,114 @@ + */ + private array $headers = []; + + private string $body = ''; + + public function __construct(private int $status = 200) + { + } + + public function setStatus(int $status): self + { + $this->status = $status; + + return $this; + } + + public function status(): int + { + return $this->status; + } + + public function setHeader(string $name, string $value): self + { + $this->headers[$name] = $value; + + return $this; + } + + public function setBody(string $body): self + { + $this->body = $body; + + return $this; + } + + public function body(): string + { + return $this->body; + } + + public function header(string $name): ?string + { + return $this->headers[$name] ?? null; + } + + /** + * @return array + */ + public function headers(): array + { + return $this->headers; + } + + /** + * @param array $headers + */ + public static function make(string $body, int $status, array $headers): self + { + $response = new self($status); + $response->body = $body; + + foreach ($headers as $name => $value) { + $response->setHeader($name, $value); + } + + return $response; + } + + /** + * @param array $data + */ + public function json(array $data, int $status = 200): self + { + $this->status = $status; + $this->setHeader('Content-Type', 'application/json; charset=utf-8'); + $this->body = (string) json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return $this; + } + + public function html(string $body, int $status = 200): self + { + $this->status = $status; + $this->setHeader('Content-Type', 'text/html; charset=utf-8'); + $this->body = $body; + + return $this; + } + + public function send(): void + { + http_response_code($this->status); + + foreach ($this->headers as $name => $value) { + header($name . ': ' . $value); + } + + echo $this->body; + } +} diff --git a/src/app/Core/Router.php b/src/app/Core/Router.php new file mode 100644 index 0000000..8daf935 --- /dev/null +++ b/src/app/Core/Router.php @@ -0,0 +1,104 @@ + + */ + private array $routes = []; + + public function __construct( + private readonly Config $config, + private readonly Database $database, + ) { + } + + /** + * @param array{0: class-string, 1: string} $handler [ControllerClass::class, 'action'] + */ + public function add(string $method, string $pattern, array $handler): self + { + $this->routes[] = [ + 'method' => strtoupper($method), + 'regex' => $this->compile($pattern), + 'handler' => $handler, + ]; + + return $this; + } + + /** + * Traduit "/api/orders/{number}" en une regex ancree avec groupes nommes. + * Les segments litteraux sont echappes pour neutraliser tout metacaractere. + */ + private function compile(string $pattern): string + { + $regex = preg_replace_callback( + '/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', + static fn (array $m): string => '(?P<' . $m[1] . '>[^/]+)', + $pattern, + ); + + // preg_quote n'est pas applicable globalement (il echapperait les groupes + // generes) ; les patterns sont des litteraux de route controles, donc on + // se contente de figer les delimiteurs avec un delimiteur improbable. + return '#^' . $regex . '$#'; + } + + /** + * Resout la requete : instancie le controleur et appelle l'action avec les + * parametres de route extraits, ou renvoie une reponse 404 / 405. + */ + public function dispatch(Request $request): Response + { + $pathMatched = false; + + foreach ($this->routes as $route) { + if (preg_match($route['regex'], $request->path(), $matches) !== 1) { + continue; + } + + $pathMatched = true; + + if ($route['method'] !== $request->method()) { + continue; + } + + $params = array_filter( + $matches, + static fn (int|string $key): bool => is_string($key), + ARRAY_FILTER_USE_KEY, + ); + + [$controllerClass, $action] = $route['handler']; + + /** @var Controller $controller */ + $controller = new $controllerClass($request, $this->config, $this->database); + + return $controller->$action($params); + } + + if ($pathMatched) { + return (new Response())->json( + ['data' => null, 'error' => ['code' => 'METHOD_NOT_ALLOWED', 'message' => 'Method not allowed']], + 405, + ); + } + + return (new Response())->json( + ['data' => null, 'error' => ['code' => 'NOT_FOUND', 'message' => 'Resource not found']], + 404, + ); + } +} diff --git a/src/app/Order/OrderQueryRepository.php b/src/app/Order/OrderQueryRepository.php new file mode 100644 index 0000000..e6bf285 --- /dev/null +++ b/src/app/Order/OrderQueryRepository.php @@ -0,0 +1,131 @@ + double sans base). + */ +class OrderQueryRepository +{ + public function __construct(private readonly DatabaseInterface $db) + { + } + + /** + * Commandes les plus recentes (tous statuts confondus), pour la liste admin. + * Triees de la plus recente a la plus ancienne. $limit borne [1, 200] et + * interpole comme ENTIER (pas de bind : LIMIT n'accepte pas de parametre lie + * avec ATTR_EMULATE_PREPARES=false). + * + * @return list> + */ + public function recent(int $limit = 50): array + { + $limit = max(1, min(200, $limit)); + + return $this->db->fetchAll( + 'SELECT order_number, source, service_mode, service_tag, status, total_ttc_cents, created_at, paid_at ' + . 'FROM customer_order ORDER BY created_at DESC, id DESC LIMIT ' . $limit, + ); + } + + /** + * Sources de commande visibles par un role (role_visible_source, dictionary 3.16). + * Liste vide en base = vue globale (admin / manager voient tout) : on renvoie alors + * les trois sources. Sert a filtrer la file de preparation par canal. + * + * @return list + */ + public function visibleSources(int $roleId): array + { + $rows = $this->db->fetchAll( + 'SELECT source FROM role_visible_source WHERE role_id = :r', + ['r' => $roleId], + ); + $sources = array_values(array_filter(array_map( + static fn (array $r): string => (string) ($r['source'] ?? ''), + $rows, + ))); + + return $sources === [] ? ['kiosk', 'counter', 'drive'] : $sources; + } + + /** + * File de preparation (KDS) : commandes au statut `paid`, triees par paid_at + * CROISSANT (la plus ancienne d'abord, RG-T12), filtrees par les sources visibles. + * Les sources viennent d'une allowlist (role_visible_source) et sont liees comme + * parametres. Liste de sources vide -> file vide (pas de canal visible). + * + * @param list $sources + * @return list> + */ + public function paidQueue(array $sources): array + { + if ($sources === []) { + return []; + } + + $placeholders = []; + $params = []; + foreach (array_values($sources) as $i => $source) { + $key = 's' . $i; + $placeholders[] = ':' . $key; + $params[$key] = $source; + } + + return $this->db->fetchAll( + 'SELECT order_number, source, service_mode, service_tag, total_ttc_cents, paid_at ' + . 'FROM customer_order WHERE status = \'paid\' AND source IN (' . implode(', ', $placeholders) . ') ' + . 'ORDER BY paid_at ASC, id ASC', + $params, + ); + } + + /** + * KPIs de vente : CA encaisse (statuts paid + delivered), nombre de commandes + * encaissees, panier moyen, CA et nombre du JOUR, total de commandes, et la + * repartition par statut. Le CA exclut les commandes pending_payment (non + * encaissees) et cancelled. + * + * @return array{revenue_cents:int, paid_count:int, avg_basket_cents:int, revenue_today_cents:int, paid_count_today:int, total_orders:int, by_status:array} + */ + public function salesKpis(): array + { + $t = $this->db->fetch( + "SELECT + COALESCE(SUM(CASE WHEN status IN ('paid','delivered') THEN total_ttc_cents ELSE 0 END), 0) AS revenue, + COALESCE(SUM(status IN ('paid','delivered')), 0) AS paid_count, + COALESCE(SUM(CASE WHEN status IN ('paid','delivered') AND created_at >= CURDATE() THEN total_ttc_cents ELSE 0 END), 0) AS revenue_today, + COALESCE(SUM(status IN ('paid','delivered') AND created_at >= CURDATE()), 0) AS paid_count_today, + COUNT(*) AS total_orders + FROM customer_order", + ) ?? []; + + $revenue = (int) ($t['revenue'] ?? 0); + $paid = (int) ($t['paid_count'] ?? 0); + + $byStatus = []; + foreach ($this->db->fetchAll('SELECT status, COUNT(*) AS n FROM customer_order GROUP BY status') as $r) { + $byStatus[(string) ($r['status'] ?? '')] = (int) ($r['n'] ?? 0); + } + + return [ + 'revenue_cents' => $revenue, + 'paid_count' => $paid, + 'avg_basket_cents' => $paid > 0 ? intdiv($revenue, $paid) : 0, + 'revenue_today_cents' => (int) ($t['revenue_today'] ?? 0), + 'paid_count_today' => (int) ($t['paid_count_today'] ?? 0), + 'total_orders' => (int) ($t['total_orders'] ?? 0), + 'by_status' => $byStatus, + ]; + } +} diff --git a/src/app/Order/OrderRepository.php b/src/app/Order/OrderRepository.php new file mode 100644 index 0000000..3cfc3e1 --- /dev/null +++ b/src/app/Order/OrderRepository.php @@ -0,0 +1,769 @@ +db->fetch( + 'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE idempotency_key = :k', + ['k' => $key], + ); + if ($row === null) { + return null; + } + + return [ + 'id' => (int) $row['id'], + 'order_number' => (string) $row['order_number'], + 'total_ttc_cents' => (int) $row['total_ttc_cents'], + 'status' => (string) $row['status'], + ]; + } + + /** + * Recherche une commande par son numero (prefixe canal K/C/D + id). Lecture + * publique du statut cote borne (suivi apres encaissement). Renvoie null si le + * numero est inconnu. Lecture seule : ne sert que des champs non sensibles + * (la commande kiosk est anonyme, pas de PII). + * + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string}|null + */ + public function findByNumber(string $number): ?array + { + if ($number === '') { + return null; + } + $row = $this->db->fetch( + 'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE order_number = :n', + ['n' => $number], + ); + if ($row === null) { + return null; + } + + return [ + 'id' => (int) $row['id'], + 'order_number' => (string) $row['order_number'], + 'total_ttc_cents' => (int) $row['total_ttc_cents'], + 'status' => (string) $row['status'], + ]; + } + + /** + * Cree une commande borne en pending_payment. Idempotent sur idempotency_key. + * + * Tolerant sur la forme d'entree (corps JSON decode tel quel) : chaque cle est + * relue defensivement et la validation metier leve OrderValidationException. + * + * @param array $req + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException si une reference est invalide / indisponible. + */ + public function createPending(array $req): array + { + $key = trim((string) ($req['idempotency_key'] ?? '')); + $existing = $this->findByIdempotencyKey($key); + if ($existing !== null) { + return $existing; + } + + // Borne anonyme : source 'kiosk', prefixe 'K', aucun acteur (acting_user_id NULL). + // Aucune contrainte croisee de service_mode (mlt RG-6 : kiosk n'implique rien). + return $this->persist($req, 'kiosk', 'K', null); + } + + /** + * Cree une commande comptoir/drive (CREATE_COUNTER_ORDER, mlt 4.1). Meme logique + * de creation que le kiosk (resolution + INSERT pending_payment), MAIS la commande + * est immediatement encaissee (paid + decrement de stock, RG-T20) : POST-1 exige + * status='paid', paid_at et acting_user_id poses des la creation. Aucun PIN : la + * permission order.create suffit (la creation n'est pas dans l'ensemble sensible). + * + * - source auto-tagguee depuis le role de l'equipier (counter / drive, RG-1) ; + * - service_mode choisi par l'equipier (dine_in / takeaway / drive) ; + * - RG-T09 : source 'drive' impose service_mode='drive' (verifie avant l'INSERT) ; + * - acting_user_id + stock_movement.user_id = id de l'equipier authentifie (RG-5). + * + * Numero : prefixe 'C' (counter) / 'D' (drive) + id, coherent avec le 'K'+id du + * kiosk (decision projet, diverge du C-AAAA-MM-JJ-NNN de la spec RG-3 : plus + * simple, pas de compteur sequentiel par jour ni de service_day a tenir). + * + * @param array $req + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException source invalide, RG-T09, reference indisponible. + */ + public function createStaffOrder(array $req, int $actingUserId, string $source): array + { + if (!in_array($source, ['counter', 'drive'], true)) { + throw new OrderValidationException('INVALID_SOURCE'); + } + + // RG-T09 / RG-2 (4.1) : la contrainte croisee drive est verifiee AVANT l'INSERT. + // service_mode est valide par persist() (in [dine_in, takeaway, drive]) ; on + // n'ajoute ici que le resserrement specifique au canal drive. + if ($source === 'drive' && (string) ($req['service_mode'] ?? '') !== 'drive') { + throw new OrderValidationException('INVALID_SERVICE_MODE'); + } + + $prefix = $source === 'drive' ? 'D' : 'C'; + $created = $this->persist($req, $source, $prefix, $actingUserId); + + // POST-1 : encaissement immediat (paid + paid_at + decrement stock RG-T20 avec + // user_id=equipier). pay() est idempotent et porte l'acteur dans acting_user_id + // (COALESCE) et stock_movement.user_id. Le numero (prefixe canal + id) sert de + // cle de transition. + return $this->pay($created['order_number'], $actingUserId); + } + + /** + * Corps partage de la creation (resolution des lignes + transaction d'INSERT + * customer_order / order_item / selections / modifiers) en pending_payment. La + * source, le prefixe de numero et l'acteur sont des PARAMETRES : c'est la seule + * difference structurelle entre le kiosk (kiosk / 'K' / NULL) et le comptoir-drive + * (counter|drive / 'C'|'D' / id equipier). Le calcul de prix (RG-T16) et les + * snapshots (RG-T05) sont identiques quelle que soit l'origine. + * + * @param array $req + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException si une reference est invalide / indisponible. + */ + private function persist(array $req, string $source, string $prefix, ?int $actingUserId): array + { + $key = trim((string) ($req['idempotency_key'] ?? '')); + + $serviceMode = (string) ($req['service_mode'] ?? ''); + if (!in_array($serviceMode, ['dine_in', 'takeaway', 'drive'], true)) { + throw new OrderValidationException('INVALID_SERVICE_MODE'); + } + $serviceTag = $serviceMode === 'dine_in' ? trim((string) ($req['service_tag'] ?? '')) : ''; + if ($serviceTag !== '' && mb_strlen($serviceTag) > 20) { + throw new OrderValidationException('INVALID_SERVICE_TAG'); + } + + $items = isset($req['items']) && is_array($req['items']) ? $req['items'] : []; + if ($items === []) { + throw new OrderValidationException('EMPTY_ORDER'); + } + + // Resolution + calcul (lecture seule) AVANT la transaction d'ecriture. + $lines = array_map(fn (array $item): array => $this->resolveLine($item), $items); + + $totalTtc = 0; + $totalHt = 0; + foreach ($lines as $l) { + $totalTtc += $l['unit_ttc'] * $l['quantity']; + $totalHt += $l['unit_ht'] * $l['quantity']; + } + $totalVat = $totalTtc - $totalHt; + if ($totalTtc <= 0) { + throw new OrderValidationException('EMPTY_ORDER'); + } + + $result = ['id' => 0, 'order_number' => '', 'total_ttc_cents' => $totalTtc, 'status' => 'pending_payment']; + + $this->db->transaction(function (DatabaseInterface $db) use ($key, $source, $prefix, $actingUserId, $serviceMode, $serviceTag, $lines, $totalTtc, $totalHt, $totalVat, &$result): void { + $db->execute( + 'INSERT INTO customer_order ' + . '(order_number, idempotency_key, source, service_mode, service_tag, status, ' + . ' acting_user_id, total_ht_cents, total_vat_cents, total_ttc_cents) ' + . "VALUES ('', :idem, :source, :mode, :tag, 'pending_payment', :acting, :ht, :vat, :ttc)", + [ + 'idem' => $key !== '' ? $key : null, + 'source' => $source, + 'mode' => $serviceMode, + 'tag' => $serviceTag !== '' ? $serviceTag : null, + 'acting' => $actingUserId, + 'ht' => $totalHt, + 'vat' => $totalVat, + 'ttc' => $totalTtc, + ], + ); + $orderId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + $orderNumber = $prefix . $orderId; + $db->execute( + 'UPDATE customer_order SET order_number = :num WHERE id = :id', + ['num' => $orderNumber, 'id' => $orderId], + ); + + foreach ($lines as $l) { + $db->execute( + 'INSERT INTO order_item ' + . '(order_id, item_type, product_id, menu_id, format, label_snapshot, ' + . ' unit_price_cents_snapshot, vat_rate_snapshot, quantity) ' + . 'VALUES (:oid, :type, :pid, :mid, :fmt, :label, :price, :vat, :qty)', + [ + 'oid' => $orderId, + 'type' => $l['item_type'], + 'pid' => $l['product_id'], + 'mid' => $l['menu_id'], + 'fmt' => $l['format'], + 'label' => $l['label'], + 'price' => $l['unit_ttc'], + 'vat' => $l['vat_rate'], + 'qty' => $l['quantity'], + ], + ); + $itemId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); + + foreach ($l['selections'] as $sel) { + $db->execute( + 'INSERT INTO order_item_selection (order_item_id, menu_slot_id, product_id, label_snapshot) ' + . 'VALUES (:oiid, :slot, :pid, :label)', + ['oiid' => $itemId, 'slot' => $sel['menu_slot_id'], 'pid' => $sel['product_id'], 'label' => $sel['label']], + ); + } + foreach ($l['modifiers'] as $mod) { + $db->execute( + 'INSERT INTO order_item_modifier (order_item_id, ingredient_id, action, extra_price_cents) ' + . 'VALUES (:oiid, :ing, :act, :extra)', + ['oiid' => $itemId, 'ing' => $mod['ingredient_id'], 'act' => $mod['action'], 'extra' => $mod['extra_price_cents']], + ); + } + } + + $result['id'] = $orderId; + $result['order_number'] = $orderNumber; + }); + + return $result; + } + + /** + * Encaisse une commande pending_payment : transition -> paid ET decrement de + * stock atomique (RG-5 etapes 5-6, RG-T11 / RG-T20) dans UNE transaction. + * + * Idempotent : une commande deja `paid` est renvoyee telle quelle sans + * re-decrementer ; `delivered` / `cancelled` -> INVALID_TRANSITION ; numero + * inconnu -> ORDER_NOT_FOUND. La transition est gardee par `status = + * 'pending_payment'` dans l'UPDATE : sous une course concurrente, seul le + * premier appel decremente (l'autre voit 0 ligne affectee et sort idempotent). + * + * Decrement (RG-5 etape 5) : par ingredient consomme, units = + * (format maxi ? quantity_maxi : quantity_normal) * order_item.quantity, ajuste + * par les modificateurs de la ligne (remove => pas de decrement pour cet + * ingredient ; add => portion de base + supplement). Les unites sont AGREGEES + * par ingredient sur toute la commande : un seul UPDATE auto-verrouillant et une + * seule ligne stock_movement(sale) par ingredient affecte (POST-4). Les UPDATE + * sont ordonnes par ingredient_id (ordre de verrou stable -> pas de deadlock + * entre commandes concurrentes). stock_quantity est signe (survente possible, + * RG-T20) : le decrement ne se conditionne a aucun plancher. + * + * NB : inerte tant que les recettes (product_ingredient) ne sont pas seedees — + * la transition `paid` s'applique, mais aucun mouvement de stock n'est produit + * faute de composition. La logique s'active des que les recettes existent. + * + * @param int|null $actingUserId acteur comptoir/drive (stock_movement.user_id + + * customer_order.acting_user_id) ; NULL pour le kiosk. + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException + */ + public function pay(string $orderNumber, ?int $actingUserId = null): array + { + $order = $this->db->fetch( + 'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE order_number = :n', + ['n' => $orderNumber], + ); + if ($order === null) { + throw new OrderValidationException('ORDER_NOT_FOUND'); + } + + $result = [ + 'id' => (int) $order['id'], + 'order_number' => (string) $order['order_number'], + 'total_ttc_cents' => (int) $order['total_ttc_cents'], + 'status' => 'paid', + ]; + + $status = (string) $order['status']; + if ($status === 'paid') { + return $result; // idempotent : deja encaissee, pas de re-decrement. + } + if ($status !== 'pending_payment') { + throw new OrderValidationException('INVALID_TRANSITION'); // delivered / cancelled. + } + + $orderId = (int) $order['id']; + $this->db->transaction(function (DatabaseInterface $db) use ($orderId, $actingUserId): void { + $affected = $db->execute( + 'UPDATE customer_order SET status = \'paid\', paid_at = NOW(), ' + . 'acting_user_id = COALESCE(:uid, acting_user_id), updated_at = NOW() ' + . 'WHERE id = :id AND status = \'pending_payment\'', + ['uid' => $actingUserId, 'id' => $orderId], + ); + if ($affected === 0) { + // Course perdue : un autre appel a deja transite. S'il a abouti a + // `paid`, il a fait le decrement -> on sort idempotent ; sinon la + // transition est invalide (statut terminal). + $current = (string) ($db->fetch('SELECT status FROM customer_order WHERE id = :id', ['id' => $orderId])['status'] ?? ''); + if ($current === 'paid') { + return; + } + throw new OrderValidationException('INVALID_TRANSITION'); + } + + foreach ($this->consumption($db, $orderId) as $ingredientId => $units) { + $db->execute( + 'UPDATE ingredient SET stock_quantity = stock_quantity - :u WHERE id = :id', + ['u' => $units, 'id' => $ingredientId], + ); + $db->execute( + 'INSERT INTO stock_movement (ingredient_id, movement_type, delta, order_id, user_id, note) ' + . 'VALUES (:ing, \'sale\', :delta, :oid, :uid, NULL)', + ['ing' => $ingredientId, 'delta' => -$units, 'oid' => $orderId, 'uid' => $actingUserId], + ); + } + }); + + return $result; + } + + /** + * Transition paid -> delivered (DELIVER_ORDER, geste unique de remise, mlt 6.1). + * NON PIN-gated : operation routiniere, hors ensemble sensible RG-T13. Idempotente + * (une commande deja delivered est renvoyee sans erreur). 404 si inconnue ; + * INVALID_TRANSITION si la commande n'est pas au statut paid (pending / cancelled). + * + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException + */ + public function deliver(string $orderNumber): array + { + $order = $this->db->fetch( + 'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE order_number = :n', + ['n' => $orderNumber], + ); + if ($order === null) { + throw new OrderValidationException('ORDER_NOT_FOUND'); + } + + $result = [ + 'id' => (int) $order['id'], + 'order_number' => (string) $order['order_number'], + 'total_ttc_cents' => (int) $order['total_ttc_cents'], + 'status' => 'delivered', + ]; + + $status = (string) $order['status']; + if ($status === 'delivered') { + return $result; // idempotent : remise deja actee. + } + if ($status !== 'paid') { + throw new OrderValidationException('INVALID_TRANSITION'); // pending_payment / cancelled. + } + + $affected = $this->db->execute( + 'UPDATE customer_order SET status = \'delivered\', delivered_at = NOW(), ' + . 'updated_at = NOW() WHERE id = :id AND status = \'paid\'', + ['id' => (int) $order['id']], + ); + if ($affected === 0) { + // Course perdue : un autre appel a deja transite. Idempotent si delivered. + $current = (string) ($this->db->fetch('SELECT status FROM customer_order WHERE id = :id', ['id' => (int) $order['id']])['status'] ?? ''); + if ($current === 'delivered') { + return $result; + } + throw new OrderValidationException('INVALID_TRANSITION'); + } + + return $result; + } + + /** + * Annulation d'une commande (CANCEL_ORDER, mlt 7.1). Transition gardee + * pending_payment|paid -> cancelled, re-credit de stock CONDITIONNEL et ecriture + * audit_log dans UNE transaction (RG-T07/T08/T11/T14). + * + * Le re-credit n'a lieu que si la commande etait `paid` AVANT l'annulation : une + * commande `pending_payment` n'avait jamais decremente le stock (le decrement est + * pose a la transition `paid`, cf. pay()), il n'y a donc rien a re-crediter. Le + * re-credit reutilise consumption() (memes unites que le decrement de pay()), + * inversees (delta positif) ; un ingredient entierement retire (modifieur remove) + * n'a pas ete decremente -> consumption() ne le retourne pas -> pas de re-credit. + * + * Concurrence (RG-T07/RG-T20) : le statut est relu A L'INTERIEUR de la transaction + * via l'UPDATE garde par `status IN ('pending_payment','paid')` ; 0 ligne affectee + * = course perdue (un autre appel a deja transite) -> INVALID_TRANSITION. Le + * re-credit se base sur le pre-status lu en entree (coherent : seul l'appel qui a + * remporte la garde poursuit, et il n'y a pas de SELECT FOR UPDATE — RG-T20). + * + * @param int|null $actingUserId equipier resolu par PIN (audit_log.actor_user_id + + * stock_movement.user_id) ; le controleur le fournit. + * @param int|null $actingRoleId role de l'equipier resolu par PIN (audit_log.actor_role_id). + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException + */ + public function cancel(string $orderNumber, ?int $actingUserId, ?int $actingRoleId): array + { + $order = $this->db->fetch( + 'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE order_number = :n', + ['n' => $orderNumber], + ); + if ($order === null) { + throw new OrderValidationException('ORDER_NOT_FOUND'); + } + + $preStatus = (string) $order['status']; + if (!in_array($preStatus, ['pending_payment', 'paid'], true)) { + throw new OrderValidationException('CANNOT_CANCEL_IN_STATE'); // delivered / cancelled (statut terminal). + } + + $result = [ + 'id' => (int) $order['id'], + 'order_number' => (string) $order['order_number'], + 'total_ttc_cents' => (int) $order['total_ttc_cents'], + 'status' => 'cancelled', + ]; + + $orderId = (int) $order['id']; + $totalTtc = (int) $order['total_ttc_cents']; + $this->db->transaction(function (DatabaseInterface $db) use ($orderId, $preStatus, $totalTtc, $actingUserId, $actingRoleId): void { + $affected = $db->execute( + 'UPDATE customer_order SET status = \'cancelled\', cancelled_at = NOW(), updated_at = NOW() ' + . 'WHERE id = :id AND status IN (\'pending_payment\', \'paid\')', + ['id' => $orderId], + ); + if ($affected === 0) { + // Course perdue : la garde RG-T07 n'a affecte aucune ligne (un autre + // appel a deja transite vers un statut terminal). Pas d'issue idempotente + // pour l'annulation (a la difference de pay/deliver) : on signale la + // transition invalide et la transaction est annulee (aucun re-credit). + throw new OrderValidationException('INVALID_TRANSITION'); + } + + // RG-3 : re-credit CONDITIONNEL. On le decide sur l'EXISTENCE de mouvements + // 'sale' pour cette commande (poses au decrement de pay()), PAS sur le + // pre-status lu hors transaction : insensible a la course + // pending_payment -> paid -> cancel (sinon un pay() concurrent gagnant + // laisserait le stock decremente sans re-credit, derive silencieuse). De + // fait idempotent : sans mouvement 'sale', rien a re-crediter. Memes unites + // que pay() (consumption), inversees (delta positif). + $restocked = $this->hasSaleMovements($db, $orderId); + if ($restocked) { + foreach ($this->consumption($db, $orderId) as $ingredientId => $units) { + $db->execute( + 'UPDATE ingredient SET stock_quantity = stock_quantity + :u WHERE id = :id', + ['u' => $units, 'id' => $ingredientId], + ); + $db->execute( + 'INSERT INTO stock_movement (ingredient_id, movement_type, delta, order_id, user_id, note) ' + . 'VALUES (:ing, \'cancellation\', :delta, :oid, :uid, NULL)', + ['ing' => $ingredientId, 'delta' => $units, 'oid' => $orderId, 'uid' => $actingUserId], + ); + } + } + + // RG-6/RG-T14 : trace d'audit immuable dans la meme transaction que l'effet. + $recredit = $restocked ? $totalTtc : 0; + $summary = 'Annulation depuis ' . $preStatus . ', re-credit ' . $recredit . 'c'; + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => $actingUserId, + 'rid' => $actingRoleId, + 'code' => 'order.cancel', + 'etype' => 'customer_order', + 'eid' => $orderId, + 'summary' => $summary, + ], + ); + }); + + return $result; + } + + /** + * Vrai si la commande porte au moins un mouvement de stock `sale` (donc elle a + * deja ete encaissee/decrementee par pay()). Sert a decider le re-credit a + * l'annulation independamment du statut observe hors transaction (anti-course). + */ + private function hasSaleMovements(DatabaseInterface $db, int $orderId): bool + { + return $db->fetch( + 'SELECT 1 AS x FROM stock_movement WHERE order_id = :oid AND movement_type = \'sale\' LIMIT 1', + ['oid' => $orderId], + ) !== null; + } + + /** + * Unites de stock a decrementer, AGREGEES par ingredient_id sur toute la + * commande (lecture des lignes persistees + recettes des produits supports). + * Cle = ingredient_id, triee croissant (ordre de verrou stable). Un ingredient + * dont l'unite agregee retombe a 0 (entierement retire) n'est PAS retourne : + * aucun mouvement n'est alors produit. Voir pay() pour la regle de calcul. + * + * @return array + */ + private function consumption(DatabaseInterface $db, int $orderId): array + { + $items = $db->fetchAll( + 'SELECT id, item_type, product_id, menu_id, format, quantity FROM order_item WHERE order_id = :oid', + ['oid' => $orderId], + ); + + /** @var array $units */ + $units = []; + foreach ($items as $item) { + $itemId = (int) $item['id']; + $quantity = max(1, (int) $item['quantity']); + $maxi = ((string) $item['format']) === 'maxi'; + + // Produit(s) dont la recette est consommee : le produit pour une ligne + // produit ; le burger + chaque selection pour une ligne menu. + $productIds = []; + if ((string) $item['item_type'] === 'product') { + $productIds[] = (int) $item['product_id']; + } else { + $menu = $this->menus->find((int) $item['menu_id']); + if ($menu !== null) { + $productIds[] = (int) $menu['burger_product_id']; + } + foreach ($db->fetchAll('SELECT product_id FROM order_item_selection WHERE order_item_id = :oiid', ['oiid' => $itemId]) as $sel) { + $productIds[] = (int) $sel['product_id']; + } + } + + // Modificateurs de la ligne (ingredient_id => action). Ils s'appliquent a + // toute recette de la ligne contenant l'ingredient ; en pratique ils + // ciblent le produit support (burger), dont les ingredients ne recoupent + // pas ceux des selections (boisson / accompagnement). + $actions = []; + foreach ($db->fetchAll('SELECT ingredient_id, action FROM order_item_modifier WHERE order_item_id = :oiid', ['oiid' => $itemId]) as $mod) { + $actions[(int) $mod['ingredient_id']] = (string) $mod['action']; + } + + foreach ($productIds as $productId) { + foreach ($this->products->composition($productId) as $row) { + $ingredientId = (int) $row['ingredient_id']; + $perUnit = $maxi ? (int) $row['quantity_maxi'] : (int) $row['quantity_normal']; + $base = $perUnit * $quantity; + $consumed = match ($actions[$ingredientId] ?? null) { + 'remove' => 0, + 'add' => $base * 2, // portion de base + supplement (RG-5). + default => $base, + }; + if ($consumed > 0) { + $units[$ingredientId] = ($units[$ingredientId] ?? 0) + $consumed; + } + } + } + } + + ksort($units); + + return $units; + } + + /** + * Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix. + * + * @param array $item + * @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, modifiers:list} + */ + private function resolveLine(array $item): array + { + $type = (string) ($item['type'] ?? ''); + $quantity = max(1, (int) ($item['quantity'] ?? 1)); + $format = ($item['format'] ?? 'normal') === 'maxi' ? 'maxi' : 'normal'; + + if ($type === 'product') { + $product = $this->products->find((int) ($item['product_id'] ?? 0)); + if ($product === null || (int) ($product['is_available'] ?? 0) !== 1) { + throw new OrderValidationException('PRODUCT_UNAVAILABLE'); + } + $unitBase = (int) $product['price_cents']; + $vat = (int) $product['vat_rate']; + $modifiers = $this->resolveModifiers($item, (int) $product['id']); + $unitTtc = $unitBase + $this->modifiersExtra($modifiers); + + return $this->line('product', (int) $product['id'], null, 'normal', (string) $product['name'], $unitTtc, $vat, $quantity, [], $modifiers); + } + + if ($type === 'menu') { + $menu = $this->menus->find((int) ($item['menu_id'] ?? 0)); + if ($menu === null || (int) ($menu['is_available'] ?? 0) !== 1) { + 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']; + $selections = $this->resolveSelections($item, (int) $menu['id'], $format); + $modifiers = $this->resolveModifiers($item, (int) $menu['burger_product_id']); + $unitTtc = $unitBase + $this->modifiersExtra($modifiers); + + return $this->line('menu', null, (int) $menu['id'], $format, (string) $menu['name'], $unitTtc, $vat, $quantity, $selections, $modifiers); + } + + throw new OrderValidationException('INVALID_ITEM_TYPE'); + } + + /** + * @param list $modifiers + */ + private function modifiersExtra(array $modifiers): int + { + $extra = 0; + foreach ($modifiers as $m) { + if ($m['action'] === 'add') { + $extra += $m['extra_price_cents']; + } + } + + return $extra; + } + + /** + * Valide chaque selection contre les options du slot (la selection BASE, telle + * qu'envoyee par la borne), puis applique la regle de variante Maxi : si le + * menu est au format 'maxi' ET que le produit choisi a une variante Maxi + * (maxi_variant_product_id non nul, ex. Moyenne Frite -> Grande Frite), c'est + * l'id ET le label de la VARIANTE qui sont persistes dans order_item_selection. + * Ainsi consumption() decremente le stock de la Grande variante et le snapshot + * de libelle reflete "Grande Frite". La validation porte toujours sur le + * produit de base : la borne ne propose que les accompagnements standard, la + * substitution est une mecanique serveur invisible. + * + * @param array $item + * @return list + */ + private function resolveSelections(array $item, int $menuId, string $format): array + { + $slots = $this->menus->slotsWithOptions($menuId); + /** @var array> $optionsBySlot */ + $optionsBySlot = []; + foreach ($slots as $s) { + $optionsBySlot[(int) $s['id']] = array_map('intval', $s['option_product_ids']); + } + + $out = []; + $raw = isset($item['selections']) && is_array($item['selections']) ? $item['selections'] : []; + foreach ($raw as $sel) { + $slotId = (int) ($sel['menu_slot_id'] ?? 0); + $pid = (int) ($sel['product_id'] ?? 0); + if (!isset($optionsBySlot[$slotId]) || !in_array($pid, $optionsBySlot[$slotId], true)) { + throw new OrderValidationException('INVALID_SELECTION'); + } + $product = $this->products->find($pid); + + // Substitution Maxi : seuls les produits dotes d'une variante (les + // accompagnements standard) sont permutes ; les autres slots (boisson, + // sauce) n'ont pas de variante et restent inchanges, sans garde sur le + // slot_type. find() relit la variante (id + label) pour son snapshot. + $variantId = $product !== null ? (int) ($product['maxi_variant_product_id'] ?? 0) : 0; + if ($format === 'maxi' && $variantId > 0) { + $variant = $this->products->find($variantId); + if ($variant !== null) { + $out[] = ['menu_slot_id' => $slotId, 'product_id' => $variantId, 'label' => (string) $variant['name']]; + continue; + } + } + + $out[] = ['menu_slot_id' => $slotId, 'product_id' => $pid, 'label' => $product !== null ? (string) $product['name'] : '']; + } + + return $out; + } + + /** + * @param array $item + * @return list + */ + private function resolveModifiers(array $item, int $productId): array + { + $raw = isset($item['modifiers']) && is_array($item['modifiers']) ? $item['modifiers'] : []; + if ($raw === []) { + return []; + } + // Recette du produit support : valide l'ingredient + figes l'extra_price (add). + $recipe = []; + foreach ($this->products->composition($productId) as $ing) { + $recipe[(int) $ing['ingredient_id']] = $ing; + } + + $out = []; + foreach ($raw as $mod) { + $ingId = (int) ($mod['ingredient_id'] ?? 0); + $action = ($mod['action'] ?? '') === 'add' ? 'add' : 'remove'; + if (!isset($recipe[$ingId])) { + throw new OrderValidationException('INVALID_MODIFIER'); + } + $row = $recipe[$ingId]; + if ($action === 'remove' && (int) ($row['is_removable'] ?? 0) !== 1) { + throw new OrderValidationException('INGREDIENT_NOT_REMOVABLE'); + } + if ($action === 'add' && (int) ($row['is_addable'] ?? 0) !== 1) { + throw new OrderValidationException('INGREDIENT_NOT_ADDABLE'); + } + $out[] = [ + 'ingredient_id' => $ingId, + 'action' => $action, + 'extra_price_cents' => $action === 'add' ? (int) ($row['extra_price_cents'] ?? 0) : 0, + ]; + } + + return $out; + } + + /** + * @param list $selections + * @param list $modifiers + * @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, modifiers:list} + */ + private function line(string $type, ?int $productId, ?int $menuId, string $format, string $label, int $unitTtc, int $vat, int $quantity, array $selections, array $modifiers): array + { + $unitHt = (int) round($unitTtc * 1000 / (1000 + $vat)); + + return [ + 'item_type' => $type, + 'product_id' => $productId, + 'menu_id' => $menuId, + 'format' => $format, + 'label' => $label, + 'unit_ttc' => $unitTtc, + 'unit_ht' => $unitHt, + 'vat_rate' => $vat, + 'quantity' => $quantity, + 'selections' => $selections, + 'modifiers' => $modifiers, + ]; + } +} diff --git a/src/app/Order/OrderValidationException.php b/src/app/Order/OrderValidationException.php new file mode 100644 index 0000000..e216893 --- /dev/null +++ b/src/app/Order/OrderValidationException.php @@ -0,0 +1,14 @@ +getMessage()`) sert de + * code d'erreur API ; le controleur le traduit en reponse HTTP 422. + */ +final class OrderValidationException extends \RuntimeException +{ +} diff --git a/src/app/Views/admin/categories/form.php b/src/app/Views/admin/categories/form.php new file mode 100644 index 0000000..3b65a2f --- /dev/null +++ b/src/app/Views/admin/categories/form.php @@ -0,0 +1,64 @@ + $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($categoryId ?? 0); +$action = $id !== 0 ? '/admin/categories/' . $id : '/admin/categories'; + +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +?> + + +
+ + +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+
diff --git a/src/app/Views/admin/categories/index.php b/src/app/Views/admin/categories/index.php new file mode 100644 index 0000000..1d0de06 --- /dev/null +++ b/src/app/Views/admin/categories/index.php @@ -0,0 +1,73 @@ +> $categories + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +/** @var array> $rows */ +$rows = isset($categories) && is_array($categories) ? $categories : []; + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
LibelleReferenceOrdreStatut
Aucune categorie.
+ + Visible + + Masquee + + + Modifier +
+ + +
+
+
+
diff --git a/src/app/Views/admin/counter/index.php b/src/app/Views/admin/counter/index.php new file mode 100644 index 0000000..025bda8 --- /dev/null +++ b/src/app/Views/admin/counter/index.php @@ -0,0 +1,80 @@ +> $orders + * @var string $channelTitle + * @var string $newPath + */ + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR'; + +$modeLabel = static fn (string $m): string => match ($m) { + 'dine_in' => 'Sur place', + 'takeaway' => 'A emporter', + 'drive' => 'Drive', + default => $m, +}; + +$statusLabel = static fn (string $s): string => match ($s) { + 'pending_payment' => 'En attente', + 'paid' => 'Payee', + 'delivered' => 'Livree', + 'cancelled' => 'Annulee', + default => $s, +}; + +$statusPill = static fn (string $s): string => match ($s) { + 'paid', 'delivered' => 'pill-success', + 'cancelled' => 'pill-danger', + default => 'pill-warning', +}; + +/** @var list> $rows */ +$rows = isset($orders) && is_array($orders) ? $orders : []; +$heading = isset($channelTitle) && is_string($channelTitle) ? $channelTitle : 'Commandes'; +$createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orders/new'; +?> + +
+ +

commande(s) recente(s)

+ + +

Aucune commande pour ce canal.

+ + + + + + + + + + + + + + + + + + + + + + + +
NumeroModeStatutTotalDate
+ +
diff --git a/src/app/Views/admin/counter/new.php b/src/app/Views/admin/counter/new.php new file mode 100644 index 0000000..9b7771a --- /dev/null +++ b/src/app/Views/admin/counter/new.php @@ -0,0 +1,214 @@ +` reste present comme repli sans JS (3a). + * + * Partage par les deux canaux ; la source/landing viennent du controleur. Au canal + * drive, service_mode est verrouille a 'drive' (RG-T09). Echappement RG-T15. + * + * @var list> $products + * @var list> $menus menus + slots (option_product_ids) + * @var string $source 'counter' | 'drive' + * @var string $serviceMode valeur preselectionnee / reaffichee + * @var string $landing retour a la liste du canal + * @var string|null $error + * @var string $csrfToken + */ + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR'; + +// Donnees pour counter-order.js, passees en attributs data-* (CSP 'self' : pas de +// script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut. +$attr = static fn (mixed $data): string => htmlspecialchars( + (string) json_encode($data, JSON_UNESCAPED_UNICODE), + ENT_QUOTES, + 'UTF-8', +); + +$csrf = $esc($csrfToken ?? ''); +$chan = isset($source) && $source === 'drive' ? 'drive' : 'counter'; +$action = $chan === 'drive' ? '/drive/orders' : '/counter/orders'; +$backTo = isset($landing) && is_string($landing) ? $landing : '/counter/orders'; +$mode = isset($serviceMode) && is_string($serviceMode) ? $serviceMode : ($chan === 'drive' ? 'drive' : 'dine_in'); +$errorMessage = isset($error) && is_string($error) ? $error : null; + +/** @var list> $productRows */ +$productRows = isset($products) && is_array($products) ? $products : []; +/** @var list> $menuRows */ +$menuRows = isset($menus) && is_array($menus) ? $menus : []; + +// Projection compacte pour le JS : seules les cles utiles a la composition. Les +// prix sont passes pour l'affichage local (le serveur reste seul juge, RG-T16). +// modifiers : ingredients retirables / ajoutables proposables (le client les affiche +// en cases a cocher ; resolveModifiers revalide chacun cote serveur). +$jsModifiers = static fn (mixed $rows): array => array_map( + static fn (array $r): array => [ + 'ingredient_id' => (int) ($r['ingredient_id'] ?? 0), + 'name' => (string) ($r['name'] ?? ''), + 'is_removable' => (int) ($r['is_removable'] ?? 0), + 'is_addable' => (int) ($r['is_addable'] ?? 0), + 'extra_price_cents' => (int) ($r['extra_price_cents'] ?? 0), + ], + is_array($rows) ? $rows : [], +); +$jsProducts = array_map( + static fn (array $p): array => [ + 'id' => (int) ($p['id'] ?? 0), + 'name' => (string) ($p['name'] ?? ''), + 'price' => (int) ($p['price_cents'] ?? 0), + 'modifiers' => $jsModifiers($p['modifiers'] ?? null), + ], + $productRows, +); +$jsMenus = array_map( + static function (array $m) use ($jsModifiers): array { + /** @var list> $slots */ + $slots = isset($m['slots']) && is_array($m['slots']) ? $m['slots'] : []; + + return [ + 'id' => (int) ($m['id'] ?? 0), + 'name' => (string) ($m['name'] ?? ''), + 'price_normal' => (int) ($m['price_normal_cents'] ?? 0), + 'price_maxi' => (int) ($m['price_maxi_cents'] ?? 0), + // Modificateurs du burger support : la selection d'un menu cible le burger + // (resolveModifiers cote serveur le resout sur burger_product_id). + 'burger_modifiers' => $jsModifiers($m['burger_modifiers'] ?? null), + 'slots' => array_map( + static fn (array $s): array => [ + 'id' => (int) ($s['id'] ?? 0), + 'name' => (string) ($s['name'] ?? ''), + 'slot_type' => (string) ($s['slot_type'] ?? ''), + 'is_required' => (int) ($s['is_required'] ?? 0), + 'display_order' => (int) ($s['display_order'] ?? 0), + 'option_product_ids' => array_map('intval', is_array($s['option_product_ids'] ?? null) ? $s['option_product_ids'] : []), + ], + $slots, + ), + ]; + }, + $menuRows, +); + +// RG-T09 : au drive, le seul mode possible est 'drive'. Le comptoir choisit librement. +$modeOptions = $chan === 'drive' + ? ['drive' => 'Drive'] + : ['dine_in' => 'Sur place', 'takeaway' => 'A emporter']; +?> + + + + + + +
+ + + +
+ + +
+ +
+ Produits + +

Aucun produit commandable pour le moment.

+ + + + + + + + + + + + + + + + + + + + + +
ProduitPrixQuantitePersonnaliser
+ + + + + +
+ +
+ +
+ Menus + +

Aucun menu commandable pour le moment.

+ + + +
+ +
+ Panier +
    +
  • Panier vide.
  • +
+
+ +
+ + Annuler +
+
+ + + + + diff --git a/src/app/Views/admin/dashboard.php b/src/app/Views/admin/dashboard.php new file mode 100644 index 0000000..a7c7ef8 --- /dev/null +++ b/src/app/Views/admin/dashboard.php @@ -0,0 +1,67 @@ +> $counts + * @var array{bands:array} $stock + */ + +$name = htmlspecialchars($currentUserName ?? 'Utilisateur', ENT_QUOTES, 'UTF-8'); + +$kpi = isset($counts) && is_array($counts) ? $counts : []; +$stk = isset($stock) && is_array($stock) ? $stock : []; + +$nProducts = (int) ($kpi['products']['available'] ?? 0); +$nCategories = (int) ($kpi['categories']['total'] ?? 0); +$nMenus = (int) ($kpi['menus']['total'] ?? 0); +$nCritical = (int) ($stk['bands']['critical'] ?? 0); +?> + + +
+
+
+ + En vente +
+
+
Produits actifs
+
+ +
+
+ + Classees +
+
+
Categories
+
+ +
+
+ + Proposes +
+
+
Menus
+
+ +
+
+ + 0 ? 'A recommander' : 'OK' ?> +
+
+
Stock critique
+
+
diff --git a/src/app/Views/admin/forbidden.php b/src/app/Views/admin/forbidden.php new file mode 100644 index 0000000..b9bfa91 --- /dev/null +++ b/src/app/Views/admin/forbidden.php @@ -0,0 +1,19 @@ + + + +
+

Retour au tableau de bord

+
diff --git a/src/app/Views/admin/ingredients/delete.php b/src/app/Views/admin/ingredients/delete.php new file mode 100644 index 0000000..30d06bf --- /dev/null +++ b/src/app/Views/admin/ingredients/delete.php @@ -0,0 +1,45 @@ + proposer la + * desactivation. CSRF cache. + * + * @var int $ingredientId + * @var string $name + * @var string|null $error + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +$ingredientName = htmlspecialchars((string) ($name ?? ''), ENT_QUOTES, 'UTF-8'); +$errorMessage = isset($error) && is_string($error) ? $error : null; +?> + + +
+ +

+ + +
+ + +

Un ingredient deja utilise (recette ou mouvement de stock) ne peut pas etre supprime : desactivez-le a la place.

+ +
+ + Annuler +
+
+
diff --git a/src/app/Views/admin/ingredients/form.php b/src/app/Views/admin/ingredients/form.php new file mode 100644 index 0000000..6b677cf --- /dev/null +++ b/src/app/Views/admin/ingredients/form.php @@ -0,0 +1,106 @@ + $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +$action = $id !== 0 ? '/admin/ingredients/' . $id : '/admin/ingredients'; + +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +?> + + +
+ + +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ + +

Le stock initial est a 0 : etablissez-le ensuite via un reapprovisionnement ou un inventaire (chaque mouvement est trace).

+ + +
+ + Annuler +
+
+ + + +
+

Valeur nutritionnelle

+ +

Apport energetique : kcal / 100 g + (source : , importe le )

+ +

Aucune donnee nutritionnelle importee pour le moment.

+ + +
+ + +
+
+ diff --git a/src/app/Views/admin/ingredients/index.php b/src/app/Views/admin/ingredients/index.php new file mode 100644 index 0000000..3204d7c --- /dev/null +++ b/src/app/Views/admin/ingredients/index.php @@ -0,0 +1,109 @@ +> $ingredients + * @var bool $canManage + * @var bool $canRestock + * @var bool $canCount + * @var string $csrfToken + */ + +/** @var array> $rows */ +$rows = isset($ingredients) && is_array($ingredients) ? $ingredients : []; +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$manage = (bool) ($canManage ?? false); +$restock = (bool) ($canRestock ?? false); +$count = (bool) ($canCount ?? false); + +$bandLabel = static fn (string $band): string => match ($band) { + 'critical' => 'pill pill-danger', + 'low' => 'pill pill-warning', + default => 'pill pill-success', +}; +$bandText = static fn (string $band): string => match ($band) { + 'critical' => 'Critique', + 'low' => 'Alerte', + default => 'Normal', +}; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
IngredientUniteStockNiveauStatut
Aucun ingredient.
+ + / (%) + + + Actif + + Inactif + + + Mouvements + + Reappro + + + Inventaire + + + Modifier +
+ + +
+ Supprimer + +
+
+
diff --git a/src/app/Views/admin/ingredients/inventory.php b/src/app/Views/admin/ingredients/inventory.php new file mode 100644 index 0000000..bdad7ce --- /dev/null +++ b/src/app/Views/admin/ingredients/inventory.php @@ -0,0 +1,72 @@ + $ingredient + * @var array $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +/** @var array $ing */ +$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : []; +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +?> + + +
+ + +

Saisissez le comptage physique reel. L'ecart avec le theorique est enregistre et impute a l'equipier (action tracee).

+ +
+ + +

+
+ +
+ + +

+
+ +
+ Confirmation par PIN equipier +
+ + +
+
+ + +
+

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/ingredients/movements.php b/src/app/Views/admin/ingredients/movements.php new file mode 100644 index 0000000..a06884e --- /dev/null +++ b/src/app/Views/admin/ingredients/movements.php @@ -0,0 +1,79 @@ + $ingredient + * @var array> $movements + * @var bool $showActor + * @var array $actorNames + */ + +/** @var array $ing */ +$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : []; +/** @var array> $rows */ +$rows = isset($movements) && is_array($movements) ? $movements : []; +/** @var array $names */ +$names = isset($actorNames) && is_array($actorNames) ? $actorNames : []; +$withActor = (bool) ($showActor ?? false); + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$typeText = static fn (string $t): string => match ($t) { + 'restock' => 'Reappro', + 'inventory_correction' => 'Inventaire', + 'sale' => 'Vente', + 'cancellation' => 'Annulation', + default => $t, +}; +$colspan = $withActor ? 5 : 4; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateTypeVariationNoteAuteur
Aucun mouvement.
0 ? '+' . $delta : (string) $delta ?> 0 ? $esc($names[$uid] ?? ('#' . $uid)) : '-' ?>
+
+
diff --git a/src/app/Views/admin/ingredients/restock.php b/src/app/Views/admin/ingredients/restock.php new file mode 100644 index 0000000..e044758 --- /dev/null +++ b/src/app/Views/admin/ingredients/restock.php @@ -0,0 +1,59 @@ + stock += N * pack_size. CSRF cache. + * + * @var int $ingredientId + * @var array $ingredient + * @var array $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($ingredientId ?? 0); +/** @var array $ing */ +$ing = isset($ingredient) && is_array($ingredient) ? $ingredient : []; +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +$packSize = (int) ($ing['pack_size'] ?? 1); +$packLabel = (string) ($ing['pack_label'] ?? ''); +?> + + +
+ + +

Un pack = unite(s). Le stock augmente de N x taille de pack.

+ +
+ + +

+
+ +
+ + +

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/kitchen/display.php b/src/app/Views/admin/kitchen/display.php new file mode 100644 index 0000000..0b1363c --- /dev/null +++ b/src/app/Views/admin/kitchen/display.php @@ -0,0 +1,59 @@ +> $orders + * @var bool $canDeliver + * @var string $csrfToken + */ + +$esc = static fn ($v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$csrf = $esc($csrfToken ?? ''); +$rows = isset($orders) && is_array($orders) ? $orders : []; +$can = !empty($canDeliver); + +$sourceLabel = static fn (string $s): string => ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive'][$s] ?? $s; +$modeLabel = static fn (string $m): string => $m === 'dine_in' ? 'Sur place' : ($m === 'drive' ? 'Drive' : 'A emporter'); +?> + + + +

Aucune commande en attente de preparation.

+ +
+ +
+
+ + +
+
+

Mode :

+ +

Table :

+ +

Payee a :

+
+ + + +
+ +
+ diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php new file mode 100644 index 0000000..5707473 --- /dev/null +++ b/src/app/Views/admin/layout.php @@ -0,0 +1,165 @@ + $permissions + * @var string $csrfToken + * @var string $activeNav + * @var string|null $flash + */ + +$pageTitle = htmlspecialchars($title ?? 'Wakdo Admin', ENT_QUOTES, 'UTF-8'); +$userName = htmlspecialchars($currentUserName ?? 'Utilisateur', ENT_QUOTES, 'UTF-8'); +$userRole = htmlspecialchars($currentUserRole ?? '', ENT_QUOTES, 'UTF-8'); +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$active = is_string($activeNav ?? null) ? $activeNav : ''; + +/** @var list $perms */ +$perms = isset($permissions) && is_array($permissions) ? $permissions : []; +$can = static fn (string $code): bool => in_array($code, $perms, true); + +// Initiales pour l'avatar (2 lettres max), derivees du nom affiche. Fonctions +// multibyte (UTF-8) : un prenom a initiale accentuee (frequent en francais) doit +// produire une lettre valide, pas un octet de tete isole qui viderait l'echappement. +$initials = ''; +foreach (preg_split('/\s+/', trim((string) ($currentUserName ?? ''))) ?: [] as $word) { + if ($word !== '' && mb_strlen($initials, 'UTF-8') < 2) { + $initials .= mb_strtoupper(mb_substr($word, 0, 1, 'UTF-8'), 'UTF-8'); + } +} +$initials = $initials !== '' ? $initials : 'U'; + +/** + * @param string $code cle de nav active + * @param string $current + */ +$navClass = static function (string $code, string $current): string { + return $code === $current ? 'sidebar-item active' : 'sidebar-item'; +}; +?> + + + + + + <?= $pageTitle ?> + + + +
+
+
+
+ + +
+
+
+ + + +
+ + +
+ + +
+
+ + + + diff --git a/src/app/Views/admin/menus/delete.php b/src/app/Views/admin/menus/delete.php new file mode 100644 index 0000000..8973af4 --- /dev/null +++ b/src/app/Views/admin/menus/delete.php @@ -0,0 +1,54 @@ + + + +
+ +

+ + +
+ + +

La suppression est tracee (audit) et retire aussi les slots du menu. Renseignez votre email et votre PIN.

+ +
+ + +
+ +
+ + +
+ +
+ + Annuler +
+
+
diff --git a/src/app/Views/admin/menus/form.php b/src/app/Views/admin/menus/form.php new file mode 100644 index 0000000..de4ba78 --- /dev/null +++ b/src/app/Views/admin/menus/form.php @@ -0,0 +1,138 @@ +> $categories + * @var array> $products + * @var list $slotTypes + * @var array $values + * @var string $slotsJson + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($menuId ?? 0); +$action = $id !== 0 ? '/admin/menus/' . $id : '/admin/menus'; + +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +/** @var array> $cats */ +$cats = isset($categories) && is_array($categories) ? $categories : []; +/** @var array> $prods */ +$prods = isset($products) && is_array($products) ? $products : []; +/** @var list $types */ +$types = isset($slotTypes) && is_array($slotTypes) ? $slotTypes : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +$selectedCat = (string) ($vals['category_id'] ?? ''); +$selectedBurger = (string) ($vals['burger_product_id'] ?? ''); +$available = (bool) ($vals['is_available'] ?? true); + +// Donnees pour le builder JS, passees en attributs data-* (CSP 'self' : pas de +// script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut. +$slimProducts = array_map( + static fn (array $p): array => ['id' => (int) ($p['id'] ?? 0), 'name' => (string) ($p['name'] ?? '')], + $prods, +); +$attr = static fn (mixed $data): string => htmlspecialchars( + (string) json_encode($data, JSON_UNESCAPED_UNICODE), + ENT_QUOTES, + 'UTF-8', +); +$slotsData = isset($slotsJson) && is_string($slotsJson) && $slotsJson !== '' ? $slotsJson : '[]'; +?> + + + + diff --git a/src/app/Views/admin/menus/index.php b/src/app/Views/admin/menus/index.php new file mode 100644 index 0000000..60e3fdb --- /dev/null +++ b/src/app/Views/admin/menus/index.php @@ -0,0 +1,76 @@ +> $menus + * @var string $csrfToken + */ + +/** @var array> $rows */ +$rows = isset($menus) && is_array($menus) ? $menus : []; +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' ') . ' EUR'; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NomCategorieBurger de basePrix (Normal – Maxi)Statut
Aucun menu.
+ + Disponible + + Indisponible + + + Modifier +
+ + +
+ Supprimer +
+
+
diff --git a/src/app/Views/admin/not_found.php b/src/app/Views/admin/not_found.php new file mode 100644 index 0000000..5bf525d --- /dev/null +++ b/src/app/Views/admin/not_found.php @@ -0,0 +1,18 @@ + + + +
+

Retour au tableau de bord

+
diff --git a/src/app/Views/admin/orders/cancel.php b/src/app/Views/admin/orders/cancel.php new file mode 100644 index 0000000..a0c282b --- /dev/null +++ b/src/app/Views/admin/orders/cancel.php @@ -0,0 +1,79 @@ + $order + * @var string|null $error + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +/** @var array $o */ +$o = isset($order) && is_array($order) ? $order : []; +$errorMessage = isset($error) && is_string($error) ? $error : null; + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR'; + +$number = (string) ($o['order_number'] ?? ''); +$status = (string) ($o['status'] ?? ''); + +$statusLabel = static fn (string $s): string => match ($s) { + 'pending_payment' => 'En attente', + 'paid' => 'Payee', + 'delivered' => 'Livree', + 'cancelled' => 'Annulee', + default => $s, +}; + +// PRE-3 (7.1) : seuls pending_payment / paid peuvent transiter vers cancelled. +$cancellable = in_array($status, ['pending_payment', 'paid'], true); +?> + + +
+ + + + + +

Cette commande est livree ou deja annulee : elle ne peut plus etre annulee.

+
+ Retour +
+ +
+ + +

L'annulation est tracee (audit) et re-credite le stock si la commande etait payee. Renseignez votre email et votre PIN.

+ +
+ Confirmation par PIN equipier +
+ + +
+
+ + +
+
+ +
+ + Retour +
+
+ +
diff --git a/src/app/Views/admin/orders/index.php b/src/app/Views/admin/orders/index.php new file mode 100644 index 0000000..4bad74b --- /dev/null +++ b/src/app/Views/admin/orders/index.php @@ -0,0 +1,92 @@ +> $orders + * @var bool $canCancel + */ + +$canCancelOrder = isset($canCancel) && $canCancel === true; + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR'; + +$modeLabel = static fn (string $m): string => match ($m) { + 'dine_in' => 'Sur place', + 'takeaway' => 'A emporter', + 'drive' => 'Drive', + default => $m, +}; + +$statusLabel = static fn (string $s): string => match ($s) { + 'pending_payment' => 'En attente', + 'paid' => 'Payee', + 'delivered' => 'Livree', + 'cancelled' => 'Annulee', + default => $s, +}; + +$statusPill = static fn (string $s): string => match ($s) { + 'paid', 'delivered' => 'pill-success', + 'cancelled' => 'pill-danger', + default => 'pill-warning', +}; + +/** @var list> $rows */ +$rows = isset($orders) && is_array($orders) ? $orders : []; +?> + +
+

Commandes

+

commande(s) recente(s)

+ + +

Aucune commande pour le moment.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NumeroModeChevaletStatutTotalDate
+ + Annuler + +
+ +
diff --git a/src/app/Views/admin/privacy.php b/src/app/Views/admin/privacy.php new file mode 100644 index 0000000..8e463ed --- /dev/null +++ b/src/app/Views/admin/privacy.php @@ -0,0 +1,108 @@ + + + +
+

Qui est concerne

+

+ Cette mention concerne les comptes du personnel (administration, + manager, cuisine, comptoir, drive). La borne client est anonyme : + une commande passee en borne ne collecte aucune donnee personnelle (pas de nom, + ni e-mail, ni telephone) ; seul un numero de table facultatif y est saisi. +

+
+ +
+

Responsable du traitement

+

+ Le responsable du traitement est l'exploitant du restaurant Wakdo. + Pour toute question ou pour exercer vos droits, le contact est + l'administrateur du systeme : contact@wakdo.local, ou + l'administration sur place. +

+
+ +
+

Donnees traitees

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DonneeFinaliteBase legale
E-mail, prenom, nomIdentifier le compte et la personne qui se connecteExecution de la relation d'emploi
Mot de passe et PIN (stockes uniquement haches, argon2id)Authentifier la connexion et valider les actions sensibles ; hors journaux et hors affichageExecution de la relation d'emploi (securite des acces)
Role, statut actif, date de derniere connexionDeterminer les actions autorisees (RBAC) et l'etat du compteInteret legitime (gestion des acces)
Journal d'audit des actions sensibles (auteur, action, horodatage)Tracer qui a effectue une action sensible (annulation, changement de prix, gestion des comptes)Interet legitime (tracabilite, prevention de la fraude interne)
Compteurs de tentatives de connexion et adresse IP de connexionLimiter les attaques par force brute sur l'authentificationInteret legitime (securite du systeme)
+
+
+
+ +
+

Conservation et partage

+
    +
  • Donnees de compte (identite, role, statut) : conservees tant que le compte est actif, puis anonymisees a l'effacement.
  • +
  • Journal d'audit : conserve environ 12 mois (interet legitime, tracabilite fiscale), puis purge par une tache planifiee, independamment du cycle de vie du compte.
  • +
  • Compteurs de connexion : reinitialises a la connexion reussie ; non conserves au-dela de leur usage de securite.
  • +
+

+ Les donnees sont hebergees sur l'infrastructure du restaurant et ne sont + partagees avec aucun tiers. Aucune donnee n'est utilisee a des + fins publicitaires ni cedee a des fins commerciales. +

+
+ +
+

Vos droits

+

Vous disposez d'un droit d'acces, de rectification et d'effacement de vos donnees personnelles :

+
    +
  • Acces et rectification : un administrateur peut consulter et corriger les informations de votre compte (rubrique Utilisateurs).
  • +
  • Effacement : a la demande, vos donnees personnelles sont anonymisees ; le compte est conserve sous une forme non identifiante pour preserver l'integrite des historiques, et vos identifiants sont invalides.
  • +
+

+ Pour exercer ces droits, adressez-vous a l'administration du restaurant, qui + traite la demande depuis le back-office. +

+
diff --git a/src/app/Views/admin/products/delete.php b/src/app/Views/admin/products/delete.php new file mode 100644 index 0000000..d844179 --- /dev/null +++ b/src/app/Views/admin/products/delete.php @@ -0,0 +1,52 @@ + + + +
+ +

+ + +
+ + +

La suppression est tracee (audit). Renseignez votre email et votre PIN.

+ +
+ + +
+ +
+ + +
+ +
+ + Annuler +
+
+
diff --git a/src/app/Views/admin/products/form.php b/src/app/Views/admin/products/form.php new file mode 100644 index 0000000..2df115c --- /dev/null +++ b/src/app/Views/admin/products/form.php @@ -0,0 +1,119 @@ +> $categories + * @var array $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($productId ?? 0); +$action = $id !== 0 ? '/admin/products/' . $id : '/admin/products'; + +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +/** @var array> $cats */ +$cats = isset($categories) && is_array($categories) ? $categories : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : ''; +$selectedCat = (string) ($vals['category_id'] ?? ''); +$selectedVat = (string) ($vals['vat_rate'] ?? '100'); +$available = (bool) ($vals['is_available'] ?? true); +?> + + +
+ + +
+ + +

+
+ +
+ + +

+
+ +
+ + +
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ +
+ + +
+ Changement de prix ou de TVA : confirmation par PIN +

Renseignez votre email et votre PIN uniquement si vous modifiez le prix ou la TVA (action tracee).

+
+ + +
+
+ + +
+

+
+ + +
+ + Annuler +
+
diff --git a/src/app/Views/admin/products/index.php b/src/app/Views/admin/products/index.php new file mode 100644 index 0000000..dcfa489 --- /dev/null +++ b/src/app/Views/admin/products/index.php @@ -0,0 +1,77 @@ +> $products + * @var list $autoUnavailable ids en rupture auto (RG-T21) + */ + +/** @var array> $rows */ +$rows = isset($products) && is_array($products) ? $products : []; +/** @var list $autoIds */ +$autoIds = isset($autoUnavailable) && is_array($autoUnavailable) ? array_map('intval', $autoUnavailable) : []; +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', ' ') . ' EUR'; +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NomCategoriePrixTVAStatut
Aucun produit.
+ + Indisponible + + Rupture auto + + Disponible + + + Modifier + Recette + Supprimer +
+
+
diff --git a/src/app/Views/admin/products/recipe.php b/src/app/Views/admin/products/recipe.php new file mode 100644 index 0000000..66c1548 --- /dev/null +++ b/src/app/Views/admin/products/recipe.php @@ -0,0 +1,88 @@ +> $ingredients catalogue pour le picker + * @var array> $composition lignes existantes + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($productId ?? 0); +$name = htmlspecialchars((string) ($productName ?? ''), ENT_QUOTES, 'UTF-8'); +$action = '/admin/products/' . $id . '/recipe'; + +/** @var array> $ings */ +$ings = isset($ingredients) && is_array($ingredients) ? $ingredients : []; +/** @var array> $comp */ +$comp = isset($composition) && is_array($composition) ? $composition : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +$compError = isset($errs['composition']) && is_string($errs['composition']) ? $errs['composition'] : ''; + +// Donnees pour le builder JS, en attributs data-* (CSP 'self'). htmlspecialchars +// rend le JSON sur-able comme valeur d'attribut. +$slimIngredients = array_map( + static fn (array $i): array => [ + 'id' => (int) ($i['id'] ?? 0), + 'name' => (string) ($i['name'] ?? ''), + 'unit' => (string) ($i['unit'] ?? ''), + ], + $ings, +); +$slimComposition = array_map( + static fn (array $c): array => [ + 'ingredient_id' => (int) ($c['ingredient_id'] ?? 0), + 'quantity_normal' => (int) ($c['quantity_normal'] ?? 1), + 'quantity_maxi' => (int) ($c['quantity_maxi'] ?? 1), + 'is_removable' => (int) ($c['is_removable'] ?? 0), + 'is_addable' => (int) ($c['is_addable'] ?? 0), + 'extra_price_cents' => (int) ($c['extra_price_cents'] ?? 0), + ], + $comp, +); +$attr = static fn (mixed $data): string => htmlspecialchars( + (string) json_encode($data, JSON_UNESCAPED_UNICODE), + ENT_QUOTES, + 'UTF-8', +); +?> + + +
+ + +
+ Ingredients +

Un ingredient NON RETIRABLE en rupture critique met le produit en rupture automatique. Un ingredient retirable/optionnel ne bloque pas le produit.

+

+
+ +
+ + + +
+ + Retour +
+
+ diff --git a/src/app/Views/admin/profile/pin.php b/src/app/Views/admin/profile/pin.php new file mode 100644 index 0000000..c854c18 --- /dev/null +++ b/src/app/Views/admin/profile/pin.php @@ -0,0 +1,49 @@ + + + +
+

Statut :

+ + +

+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ +
+
+
diff --git a/src/app/Views/admin/roles/form.php b/src/app/Views/admin/roles/form.php new file mode 100644 index 0000000..d06b82b --- /dev/null +++ b/src/app/Views/admin/roles/form.php @@ -0,0 +1,227 @@ +`, `source_`) : Request::formBody ne garde que les scalaires, + * donc pas de `name[]` ni de JS. Toute soumission exige le PIN equipier (RG-T13). + * Le `code` est editable a la creation, fige a l'edition (immuable). + * + * Presentation humanisee (option a) : les permissions sont regroupees par domaine + * et libellees en francais ICI (la base reste la source des codes) ; canal et page + * d'accueil sont des listes deroulantes. Les NOMS de champs postes sont inchanges. + * + * @var int $roleId + * @var bool $isAdminRole + * @var array> $permissions catalogue {id, code, label} + * @var list $sources enum visibles + * @var list $selectedPerms + * @var list $selectedSources + * @var array $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($roleId ?? 0); +$action = $id !== 0 ? '/admin/roles/' . $id : '/admin/roles'; +$isAdmin = (bool) ($isAdminRole ?? false); +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +/** @var array> $perms */ +$perms = isset($permissions) && is_array($permissions) ? $permissions : []; +/** @var list $selPerms */ +$selPerms = isset($selectedPerms) && is_array($selectedPerms) ? array_map('intval', $selectedPerms) : []; +/** @var list $selSources */ +$selSources = isset($selectedSources) && is_array($selectedSources) ? $selectedSources : []; +/** @var list $srcList */ +$srcList = isset($sources) && is_array($sources) ? $sources : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? htmlspecialchars($errs[$k], ENT_QUOTES, 'UTF-8') : ''; +$selectedSource = (string) ($vals['order_source'] ?? ''); +$active = (bool) ($vals['is_active'] ?? true); + +// --- Correspondances humaines (presentation seule) --- +// Canal de commande : enum technique -> libelle parlant. +$canalLabels = ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive']; +$canalLabel = static fn (string $enum): string => $canalLabels[$enum] ?? $enum; + +// Pages proposees comme page d'accueil (liste deroulante) : chemin -> libelle. +$routeOptions = [ + '/admin/dashboard' => 'Tableau de bord', + '/admin/stats' => 'Statistiques', + '/admin/products' => 'Produits', + '/admin/menus' => 'Menus', + '/admin/ingredients' => 'Stock', + '/admin/categories' => 'Categories', + '/admin/users' => 'Comptes', + '/admin/roles' => 'Roles', +]; +$currentRoute = (string) ($vals['default_route'] ?? ''); +// Toujours pouvoir reselectionner la valeur courante meme si hors liste (ex. seed). +if ($currentRoute !== '' && !isset($routeOptions[$currentRoute])) { + $routeOptions[$currentRoute] = $currentRoute; +} + +// Permissions : code technique -> [groupe, action]. La base reste la source des codes. +$permMap = [ + 'product.read' => ['Produits', 'Voir'], + 'product.create' => ['Produits', 'Creer'], + 'product.update' => ['Produits', 'Modifier'], + 'product.delete' => ['Produits', 'Supprimer'], + 'menu.read' => ['Menus', 'Voir'], + 'menu.create' => ['Menus', 'Creer'], + 'menu.update' => ['Menus', 'Modifier'], + 'menu.delete' => ['Menus', 'Supprimer'], + 'category.manage' => ['Catalogue & recettes', 'Gerer les categories'], + 'ingredient.manage' => ['Catalogue & recettes', 'Gerer les ingredients et recettes'], + 'stock.read' => ['Stock', 'Voir'], + 'stock.count' => ['Stock', "Faire l'inventaire"], + 'stock.manage' => ['Stock', 'Reapprovisionner'], + 'order.read' => ['Commandes', 'Voir'], + 'order.create' => ['Commandes', 'Creer'], + 'order.deliver' => ['Commandes', 'Livrer'], + 'order.cancel' => ['Commandes', 'Annuler'], + 'user.read' => ['Comptes', 'Voir'], + 'user.create' => ['Comptes', 'Creer'], + 'user.update' => ['Comptes', 'Modifier'], + 'user.deactivate' => ['Comptes', 'Desactiver'], + 'role.manage' => ['Roles & statistiques', 'Gerer les roles'], + 'stats.read' => ['Roles & statistiques', 'Voir les statistiques'], +]; +$groupOrder = ['Produits', 'Menus', 'Catalogue & recettes', 'Stock', 'Commandes', 'Comptes', 'Roles & statistiques', 'Autres']; + +// Regroupe le catalogue recu par domaine humain. +$grouped = []; +foreach ($perms as $p) { + $code = (string) ($p['code'] ?? ''); + $map = $permMap[$code] ?? ['Autres', (string) ($p['label'] ?? $code)]; + $grouped[$map[0]][] = [ + 'id' => (int) ($p['id'] ?? 0), + 'action' => $map[1], + 'checked' => in_array((int) ($p['id'] ?? 0), $selPerms, true), + ]; +} +?> + + +
+ + +
+ + +

+
+ +
+ + + +

Identifiant technique (sans espace), non modifiable apres creation.

+

+ + +

Identifiant technique, non modifiable apres creation.

+ +
+ +
+ + +
+ +
+ + +

L'ecran affiche a cette personne quand elle se connecte.

+

+
+ +
+ + +

Les commandes prises par ce role sont rattachees a ce canal.

+

+
+ + +
+ +
+ + +
+ Droits d'acces +

Cochez ce que ce role est autorise a faire.

+

+
+ + +
+

+ + + +
+ +
+
+ +
+ Canaux visibles sur le tableau de bord + + + +
+ +
+ Confirmation par PIN +
+ + +
+
+ + +
+

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/roles/index.php b/src/app/Views/admin/roles/index.php new file mode 100644 index 0000000..3580e63 --- /dev/null +++ b/src/app/Views/admin/roles/index.php @@ -0,0 +1,84 @@ +> $roles + */ + +/** @var array> $rows */ +$rows = isset($roles) && is_array($roles) ? $roles : []; +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); + +$routeLabels = [ + '/admin/dashboard' => 'Tableau de bord', + '/admin/stats' => 'Statistiques', + '/admin/products' => 'Produits', + '/admin/menus' => 'Menus', + '/admin/ingredients' => 'Stock', + '/admin/categories' => 'Categories', + '/admin/users' => 'Comptes', + '/admin/roles' => 'Roles', +]; +$canalLabels = ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive']; +$routeHuman = static fn (string $r): string => $r === '' ? '—' : ($routeLabels[$r] ?? $r); +$canalHuman = static fn (?string $s): string => ($s === null || $s === '') ? '—' : ($canalLabels[$s] ?? $s); +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NomCode internePage d'accueilCanalStatut
Aucun role.
+ + Actif + + Inactif + + + Modifier +
+
+
diff --git a/src/app/Views/admin/stats/index.php b/src/app/Views/admin/stats/index.php new file mode 100644 index 0000000..4d2b42c --- /dev/null +++ b/src/app/Views/admin/stats/index.php @@ -0,0 +1,141 @@ +} $stock + */ + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +/** @var array> $c */ +$c = isset($counts) && is_array($counts) ? $counts : []; +/** @var array $s */ +$s = isset($stock) && is_array($stock) ? $stock : ['active_total' => 0, 'bands' => ['normal' => 0, 'low' => 0, 'critical' => 0], 'alerts' => []]; +$bands = is_array($s['bands'] ?? null) ? $s['bands'] : ['normal' => 0, 'low' => 0, 'critical' => 0]; +/** @var list> $alerts */ +$alerts = is_array($s['alerts'] ?? null) ? $s['alerts'] : []; + +$bandLabel = static fn (string $b): string => match ($b) { + 'critical' => 'Critique', + 'low' => 'Alerte', + default => 'Normal', +}; +$bandPill = static fn (string $b): string => match ($b) { + 'critical' => 'pill-danger', + 'low' => 'pill-warning', + default => 'pill-success', +}; + +/** @var list $cards */ +$cards = [ + ['key' => 'products', 'label' => 'Produits', 'sub' => 'available'], + ['key' => 'menus', 'label' => 'Menus', 'sub' => 'available'], + ['key' => 'categories', 'label' => 'Categories', 'sub' => 'active'], + ['key' => 'ingredients', 'label' => 'Ingredients', 'sub' => 'active'], +]; +?> + + + $salesData */ +$salesData = isset($sales) && is_array($sales) ? $sales : []; +$byStatus = is_array($salesData['by_status'] ?? null) ? $salesData['by_status'] : []; +$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR'; +?> + + +
+
+
+
CA encaisse
+
aujourd'hui
+
+
+
+
Commandes payees
+
aujourd'hui
+
+
+
+
Panier moyen
+
par commande payee
+
+
+
+
Commandes totales
+
en attente
+
+
+ + + +
+ + +
+
+
+
+
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
IngredientStockEtat
Aucun ingredient en alerte ou rupture. Stock sain.
% + +
+
+
diff --git a/src/app/Views/admin/users/confirm.php b/src/app/Views/admin/users/confirm.php new file mode 100644 index 0000000..2cb812e --- /dev/null +++ b/src/app/Views/admin/users/confirm.php @@ -0,0 +1,76 @@ + $kinds */ +$kinds = [ + 'deactivate' => [ + 'path' => '/admin/users/' . $id . '/deactivate', + 'title' => 'Desactiver le compte', + 'message' => 'L\'utilisateur ne pourra plus se connecter. L\'historique reste intact. Reversible (reactivation via Modifier).', + 'button' => 'Desactiver', + ], + 'reset-pin' => [ + 'path' => '/admin/users/' . $id . '/reset-pin', + 'title' => 'Reinitialiser le PIN', + 'message' => 'Le PIN d\'action sensible de cet equipier sera efface. Il devra en redefinir un en self-service.', + 'button' => 'Reinitialiser le PIN', + ], + 'erase' => [ + 'path' => '/admin/users/' . $id . '/erase', + 'title' => 'Anonymiser le compte (RGPD)', + 'message' => 'Les donnees personnelles seront effacees definitivement (droit a l\'effacement). La ligne est conservee anonymisee pour preserver l\'historique. Action IRREVERSIBLE.', + 'button' => 'Anonymiser definitivement', + ], +]; +$c = $kinds[$kind] ?? $kinds['deactivate']; +?> + + +
+ + +

Compte cible :

+

+ +

+ +
+ Re-autorisation (PIN equipier) +
+ + +
+
+ + +
+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/users/form.php b/src/app/Views/admin/users/form.php new file mode 100644 index 0000000..128fe83 --- /dev/null +++ b/src/app/Views/admin/users/form.php @@ -0,0 +1,104 @@ + $roles + * @var array $values + * @var array $errors + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +$id = (int) ($userId ?? 0); +$action = $id !== 0 ? '/admin/users/' . $id : '/admin/users'; +/** @var array $vals */ +$vals = isset($values) && is_array($values) ? $values : []; +/** @var array $errs */ +$errs = isset($errors) && is_array($errors) ? $errors : []; +/** @var list $roleList */ +$roleList = isset($roles) && is_array($roles) ? $roles : []; + +$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8'); +$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? htmlspecialchars($errs[$k], ENT_QUOTES, 'UTF-8') : ''; +$selectedRole = (string) ($vals['role_id'] ?? ''); +$active = (bool) ($vals['is_active'] ?? true); +?> + + +
+ + +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + +

+
+ +
+ + > +

+
+ + +
+ +
+ + +
+ Re-autorisation (PIN equipier) +

La gestion des comptes est une action sensible : confirmez avec votre email et votre PIN.

+
+ + +
+
+ + +
+

+
+ +
+ + Annuler +
+
diff --git a/src/app/Views/admin/users/index.php b/src/app/Views/admin/users/index.php new file mode 100644 index 0000000..daa246e --- /dev/null +++ b/src/app/Views/admin/users/index.php @@ -0,0 +1,93 @@ +> $users + * @var int $currentId id de l'acteur (pas d'auto-desactivation) + * @var bool $canCreate + * @var bool $canUpdate + * @var bool $canDeactiv + */ + +/** @var array> $rows */ +$rows = isset($users) && is_array($users) ? $users : []; +$me = (int) ($currentId ?? 0); +$canCreate = (bool) ($canCreate ?? false); +$canUpdate = (bool) ($canUpdate ?? false); +$canDeactiv = (bool) ($canDeactiv ?? false); +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NomEmailRoleStatut
Aucun utilisateur.
(vous)' : '' ?> + + Anonymise + + Actif + + Inactif + + + + + Modifier + Reset PIN + + + Desactiver + + + Anonymiser + + +
+
+
diff --git a/src/app/Views/auth/forgot.php b/src/app/Views/auth/forgot.php new file mode 100644 index 0000000..4776752 --- /dev/null +++ b/src/app/Views/auth/forgot.php @@ -0,0 +1,41 @@ + +
+ +
diff --git a/src/app/Views/auth/login.php b/src/app/Views/auth/login.php new file mode 100644 index 0000000..73b82e1 --- /dev/null +++ b/src/app/Views/auth/login.php @@ -0,0 +1,52 @@ + +
+ +
diff --git a/src/app/Views/auth/reset.php b/src/app/Views/auth/reset.php new file mode 100644 index 0000000..8253c4f --- /dev/null +++ b/src/app/Views/auth/reset.php @@ -0,0 +1,49 @@ + +
+ +
diff --git a/src/app/Views/layout.php b/src/app/Views/layout.php new file mode 100644 index 0000000..484a20d --- /dev/null +++ b/src/app/Views/layout.php @@ -0,0 +1,27 @@ + + + + + + + + <?= $pageTitle ?> + + + + + + diff --git a/src/public/admin/assets/css/admin.css b/src/public/admin/assets/css/admin.css index 102c027..5334b17 100644 --- a/src/public/admin/assets/css/admin.css +++ b/src/public/admin/assets/css/admin.css @@ -45,8 +45,8 @@ --color-info-text: #1E40AF; /* Layout */ - --sidebar-width: 240px; - --topbar-height: 56px; + --sidebar-width: 264px; + --topbar-height: 72px; /* Typography */ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; @@ -55,6 +55,14 @@ --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; + --radius-xl: 14px; + --radius-card: 20px; + + /* Accent doux + ombres douces (direction A+C) */ + --color-yellow-soft: #FFF3D1; + --color-yellow-ink: #C8920A; + --shadow-card: 0 1px 3px rgba(26,26,26,0.04), 0 6px 20px rgba(26,26,26,0.06); + --shadow-card-hover: 0 2px 6px rgba(26,26,26,0.06), 0 12px 30px rgba(26,26,26,0.10); } html, body { @@ -83,7 +91,7 @@ button { grid-template-columns: var(--sidebar-width) 1fr; grid-template-rows: var(--topbar-height) 1fr; grid-template-areas: - "topbar topbar" + "sidebar topbar" "sidebar content"; height: 100vh; overflow: hidden; @@ -97,7 +105,7 @@ button { border-bottom: 1px solid var(--color-border); display: flex; align-items: center; - padding: 0 20px; + padding: 0 32px; gap: 16px; position: relative; z-index: 100; @@ -180,12 +188,12 @@ button { .topbar-user-btn { display: flex; align-items: center; - gap: 8px; - padding: 6px 10px; - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background: var(--color-white); - font-size: 13px; + gap: 11px; + padding: 6px 8px 6px 12px; + border: none; + border-radius: 999px; + background: transparent; + font-size: 14px; color: var(--color-text); cursor: pointer; transition: background 0.15s; @@ -196,21 +204,23 @@ button { } .topbar-user-avatar { - width: 26px; - height: 26px; + width: 36px; + height: 36px; border-radius: 50%; - background: var(--color-yellow); + background: var(--color-text); display: flex; align-items: center; justify-content: center; - font-size: 11px; - font-weight: 700; - color: var(--color-text); + font-size: 13px; + font-weight: 800; + letter-spacing: 0.3px; + color: var(--color-yellow); flex-shrink: 0; } .topbar-user-name { - font-weight: 500; + font-size: 14px; + font-weight: 700; } .topbar-user-role { @@ -273,47 +283,72 @@ button { border-right: 1px solid var(--color-border); overflow-y: auto; overflow-x: hidden; + padding: 0 14px 18px; +} + +/* Marque en tete de sidebar (logo Wakdo) */ +.sidebar-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 10px 14px; +} + +.sidebar-brand-logo { + height: 34px; + width: auto; + flex-shrink: 0; +} + +.sidebar-brand-name { + font-size: 21px; + font-weight: 800; + letter-spacing: -0.5px; + color: var(--color-text); +} + +.sidebar-brand-name span { + color: var(--color-yellow-ink); } .sidebar-section { - padding: 16px 0 4px; + padding: 12px 0 2px; } .sidebar-section-label { - padding: 0 16px 6px; + padding: 0 12px 8px; font-size: 11px; - font-weight: 600; + font-weight: 700; color: var(--color-text-muted); text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: 0.07em; } .sidebar-item { display: flex; align-items: center; - gap: 10px; - height: 40px; - padding: 0 16px; - font-size: 13px; - font-weight: 500; + gap: 12px; + height: 44px; + padding: 0 14px; + margin: 2px 0; + border-radius: var(--radius-md); + font-size: 15px; + font-weight: 600; color: var(--color-text-sec); cursor: pointer; - transition: background 0.1s, color 0.1s; - border-left: 3px solid transparent; + transition: background 0.15s, color 0.15s; text-decoration: none; } .sidebar-item:hover { background: var(--color-surface); color: var(--color-text); - border-left-color: transparent; } .sidebar-item.active { - background: var(--color-yellow-bg); + background: var(--color-yellow-soft); color: var(--color-text); - font-weight: 600; - border-left-color: var(--color-yellow); + box-shadow: inset 3px 0 0 var(--color-yellow); } .sidebar-item svg { @@ -322,12 +357,8 @@ button { } .sidebar-item-sub { - padding-left: 42px; - font-weight: 400; -} - -.sidebar-item-sub.active { - padding-left: 39px; + padding-left: 40px; + font-weight: 500; } /* --- Content Area --- */ @@ -452,8 +483,9 @@ button { .kpi-card { background: var(--color-white); border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: 20px; + border-radius: var(--radius-card); + padding: 24px; + box-shadow: var(--shadow-card); } .kpi-label { @@ -1046,10 +1078,12 @@ tbody td.mono { .login-card { background: var(--color-white); border: 1px solid var(--color-border); + border-top: 3px solid var(--color-yellow); border-radius: var(--radius-lg); padding: 40px; width: 100%; max-width: 380px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.04); } .login-logo { @@ -1097,6 +1131,26 @@ tbody td.mono { text-decoration: underline; } +/* Alertes (formulaires auth : login, mot de passe oublie, reinitialisation) */ +.alert { + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: 13px; + margin-bottom: 16px; +} + +.alert-error { + background: var(--color-danger-bg); + color: var(--color-danger-text); + border: 1px solid #FECACA; +} + +.alert-info { + background: var(--color-info-bg); + color: var(--color-info-text); + border: 1px solid #BFDBFE; +} + /* --- Misc utilities --- */ .text-muted { color: var(--color-text-muted); @@ -1173,3 +1227,207 @@ tbody td.mono { ::-webkit-scrollbar-thumb:hover { background: var(--color-text-muted); } + +/* ============================================================ + Statistiques — cartes KPI (tableau de bord stats.read) + ============================================================ */ +.stats-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: var(--color-white); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1.25rem 1.5rem; +} + +.stat-card__value { + font-size: 2rem; + font-weight: 700; + color: var(--color-text); + line-height: 1.1; +} + +.stat-card__label { + font-weight: 600; + color: var(--color-text); + margin-top: 0.25rem; +} + +.stat-card__sub { + font-size: 0.875rem; + margin-top: 0.125rem; +} + +/* --- Dashboard (direction A+C) : tuiles + feed d'activite --- */ +.dash-tiles { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 22px; + margin-bottom: 28px; +} + +.tile { + background: var(--color-white); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + padding: 24px; + box-shadow: var(--shadow-card); + transition: box-shadow 0.2s, transform 0.2s; +} +.tile:hover { box-shadow: var(--shadow-card-hover); transform: translateY(-3px); } + +.tile-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; } + +.tile-ico { + width: 54px; height: 54px; + border-radius: 15px; + background: var(--color-yellow); + color: var(--color-text); + display: flex; align-items: center; justify-content: center; +} +.tile.alert .tile-ico { background: var(--color-danger); color: #fff; } + +.tile-tag { + font-size: 12px; font-weight: 700; + color: var(--color-success-text); + background: var(--color-success-bg); + padding: 5px 11px; + border-radius: 999px; +} +.tile.alert .tile-tag { color: var(--color-danger-text); background: var(--color-danger-bg); } + +.tile-value { font-size: 40px; font-weight: 800; letter-spacing: -1.5px; line-height: 1; } +.tile.alert .tile-value { color: var(--color-danger); } + +.tile-label { font-size: 15px; font-weight: 600; color: var(--color-text-sec); margin-top: 8px; } + +.feed-panel { + background: var(--color-white); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + overflow: hidden; +} +.feed-head { + padding: 22px 26px; + display: flex; align-items: center; justify-content: space-between; + border-bottom: 1px solid var(--color-border); +} +.feed-title { font-size: 18px; font-weight: 800; letter-spacing: -0.3px; } +.feed-hint { font-size: 13px; color: var(--color-text-muted); margin-top: 2px; } +.feed-link { + font-size: 14px; font-weight: 700; color: var(--color-text); + text-decoration: none; background: var(--color-page); + padding: 9px 16px; border-radius: 10px; transition: background 0.18s; +} +.feed-link:hover { background: var(--color-yellow-soft); } + +.feed-list { list-style: none; } +.feed-item { + display: flex; align-items: center; gap: 18px; + padding: 18px 26px; + border-top: 1px solid var(--color-border); +} +.feed-item:first-child { border-top: none; } +.feed-item:hover { background: var(--color-surface); } + +.feed-ico { + width: 44px; height: 44px; + border-radius: 13px; + background: var(--color-yellow-soft); + color: var(--color-yellow-ink); + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; +} +.feed-ico.red { background: var(--color-danger-bg); color: var(--color-danger); } +.feed-ico.green { background: var(--color-success-bg); color: var(--color-success-text); } + +.feed-body { flex: 1; } +.feed-text { font-size: 15px; font-weight: 600; color: var(--color-text); } +.feed-text b { font-weight: 800; } +.feed-meta { font-size: 13px; color: var(--color-text-muted); margin-top: 2px; } +.feed-time { font-size: 13px; color: var(--color-text-muted); font-weight: 600; white-space: nowrap; } + +/* --- Modal PIN (re-autorisation au moment de l'action sensible) --- */ +.pin-modal-overlay { + position: fixed; + inset: 0; + background: rgba(26, 26, 26, 0.45); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 24px; +} +.pin-modal-overlay.open { display: flex; } + +.pin-modal { + 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: 400px; + padding: 28px; +} +.pin-modal-head { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 20px; } +.pin-modal-ico { + width: 44px; height: 44px; flex-shrink: 0; + border-radius: 13px; + background: var(--color-yellow-soft); + color: var(--color-yellow-ink); + display: flex; align-items: center; justify-content: center; +} +.pin-modal-title { font-size: 18px; font-weight: 800; letter-spacing: -0.3px; } +.pin-modal-sub { font-size: 13px; color: var(--color-text-muted); margin-top: 3px; } +.pin-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; } + +/* --- Matrice de droits d'acces groupee (formulaire Roles humanise) --- */ +.perm-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; + margin-top: 10px; +} +.perm-group { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 14px 16px; + background: var(--color-surface); +} +.perm-group-title { + font-size: 14px; + font-weight: 700; + margin-bottom: 8px; + color: var(--color-text); +} +.perm-opt { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: var(--color-text-sec); + margin: 3px 14px 3px 0; +} +@media (max-width: 700px) { + .perm-grid { grid-template-columns: 1fr; } +} + +/* Fieldsets de formulaire : pas de bordure native ; la legende = titre de section. */ +.form-card fieldset { + border: none; + padding: 0; + margin: 0; +} +.form-card legend { + font-size: 15px; + font-weight: 700; + color: var(--color-text); + padding: 0; + margin-bottom: 4px; +} diff --git a/src/public/admin/assets/js/counter-order.js b/src/public/admin/assets/js/counter-order.js new file mode 100644 index 0000000..96cfeed --- /dev/null +++ b/src/public/admin/assets/js/counter-order.js @@ -0,0 +1,563 @@ +/* + * counter-order.js — Composeur de commande comptoir/drive (back-office, sous-lot 3c). + * + * CSP 'self' : script externe (pas d'inline, zero handler dans le HTML). Les donnees + * (produits commandables + leurs modificateurs, menus + slots + format + modificateurs + * du burger) sont lues depuis les attributs data-* de #counter-order-form. L'equipier + * ajoute des produits (champ quantite), personnalise un produit a la carte (retrait/ + * ajout d'ingredients) ou configure un menu (slots + format + retrait/ajout sur le + * burger). A la soumission, le panier est serialise en JSON dans le champ cache + * #items_json (Request::formBody cote serveur ne garde que les scalaires, d'ou le + * passage par une chaine JSON). Le serveur revalide la forme (RG-T18), revalide chaque + * modificateur metier (resolveModifiers) et recalcule les prix (RG-T16) : les libelles/ + * prix affiches ici sont indicatifs, jamais source de verite. + * + * La logique de slots (un pas par slot, requis/optionnel, format) calque + * page-product-menu.js (borne) ; la logique de modificateurs (cases "retirer" pour les + * ingredients is_removable, "ajouter +X.XX EUR" pour les is_addable) calque l'UX + * borne. Seul le rendu differe (idiome back-office, pas de style borne). Les lignes + * configurees (produit personnalise / menu) vivent dans un etat JS et sont rendues dans + * le panier ; les produits sans modificateur sont derives a la soumission depuis les + * champs qty_ (repli sans JS conserve : le serveur accepte aussi qty_ si + * #items_json est vide). Un produit personnalisable est routé par la modale (sa + * quantite directe est ignoree quand JS s'execute) pour ne pas le compter deux fois. + * + * Module CommonJS (admin = racine CommonJS, comme pin-modal.js) : init(doc) est + * exporte pour les tests et auto-appele au DOMContentLoaded en production. + */ +(function () { + 'use strict'; + + // SLOT_LABEL : seuls les slot_type geres deviennent une etape (l'enum DB autorise + // aussi dessert/extra). Aligne sur page-product-menu.js (anti-perte silencieuse). + var SLOT_LABEL = { side: 'Accompagnement', drink: 'Boisson', sauce: 'Sauce' }; + + function parseData(form, key, fallback) { + try { + var v = JSON.parse(form.dataset[key] || fallback); + return Array.isArray(v) ? v : JSON.parse(fallback); + } catch (e) { + return JSON.parse(fallback); + } + } + + // Surcout d'un ajout, formate en euros (affichage local indicatif ; le serveur + // refige extra_price_cents, RG-T16). + function formatExtra(cents) { + return '+' + (Number(cents) / 100).toFixed(2).replace('.', ',') + ' EUR'; + } + + // Etapes composables d'un menu : burger impose ignore (non choisi ici), un pas par + // slot gere, trie par display_order, options resolues via l'index produit. Pur. + function composerSteps(menu, productById) { + return (menu.slots || []) + .filter(function (slot) { + return Object.prototype.hasOwnProperty.call(SLOT_LABEL, slot.slot_type); + }) + .slice() + .sort(function (a, b) { + return (Number(a.display_order) || 0) - (Number(b.display_order) || 0); + }) + .map(function (slot) { + var options = (slot.option_product_ids || []) + .map(function (pid) { return productById[Number(pid)]; }) + .filter(Boolean); + return { + id: Number(slot.id), + name: slot.name || SLOT_LABEL[slot.slot_type], + slotType: slot.slot_type, + isRequired: Number(slot.is_required) === 1, + options: options, + }; + }); + } + + function init(doc) { + var form = doc.getElementById('counter-order-form'); + var hidden = doc.getElementById('items_json'); + var cart = doc.getElementById('order-cart'); + var cartEmpty = doc.getElementById('order-cart-empty'); + var modalHost = doc.getElementById('menu-composer-modal'); + if (!form || !hidden || !cart || !modalHost) { + return; + } + + var products = parseData(form, 'products', '[]'); // [{id, name, price, modifiers:[...]}] + var menus = parseData(form, 'menus', '[]'); // [{id, name, price_normal, price_maxi, burger_modifiers:[...], slots:[...]}] + + // Index produit par id : resolution des libelles d'options de slot + acces aux + // modificateurs proposables d'un produit a la carte. + var productById = {}; + products.forEach(function (p) { + productById[Number(p.id)] = p; + }); + + // Lignes configurees par l'equipier : items prets a serialiser, avec libelle recap. + // menuLines : menus configures ; productLines : produits personnalises (modifiers). + var menuLines = []; + var productLines = []; + var lineSeq = 0; + + // Produits routes par la modale (ils portent un bouton "Personnaliser") : leur + // quantite directe qty_ est ignoree a la serialisation pour eviter le double + // comptage (le champ reste present pour le repli sans JS). + var configurableIds = {}; + Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) { + configurableIds[Number(btn.dataset.productId)] = true; + }); + + function el(tag, className) { + var e = doc.createElement(tag); + if (className) { + e.className = className; + } + return e; + } + + /* ----------------------------------------------------------------- */ + /* Modificateurs : cases "retirer" / "ajouter +X.XX EUR" (UX borne) */ + /* ----------------------------------------------------------------- */ + + // Rend les controles de modificateurs d'un produit support dans un conteneur. + // selectedRemove/selectedAdd : maps ingredient_id -> bool, mutees au changement. + // Calque la borne : un ingredient is_removable propose "retirer", un is_addable + // propose "ajouter (+surcout)". Un meme ingredient peut etre les deux. + function renderModifierControls(modifiers, selectedRemove, selectedAdd) { + var block = el('div', 'menu-composer__modifiers'); + if (!modifiers || !modifiers.length) { + return block; + } + var legend = el('p', 'menu-composer__legend'); + legend.textContent = 'Personnalisation'; + block.appendChild(legend); + + modifiers.forEach(function (mod) { + var ingId = Number(mod.ingredient_id); + if (Number(mod.is_removable) === 1) { + var remLab = el('label', 'menu-composer__modifier'); + var remBox = el('input'); + remBox.type = 'checkbox'; + remBox.className = 'menu-composer__modifier-remove'; + remBox.dataset.ingredientId = String(ingId); + remBox.addEventListener('change', function () { + if (remBox.checked) { + selectedRemove[ingId] = true; + } else { + delete selectedRemove[ingId]; + } + }); + remLab.appendChild(remBox); + remLab.appendChild(doc.createTextNode(' Sans ' + String(mod.name))); + block.appendChild(remLab); + } + if (Number(mod.is_addable) === 1) { + var addLab = el('label', 'menu-composer__modifier'); + var addBox = el('input'); + addBox.type = 'checkbox'; + addBox.className = 'menu-composer__modifier-add'; + addBox.dataset.ingredientId = String(ingId); + addBox.addEventListener('change', function () { + if (addBox.checked) { + selectedAdd[ingId] = true; + } else { + delete selectedAdd[ingId]; + } + }); + addLab.appendChild(addBox); + addLab.appendChild(doc.createTextNode(' Extra ' + String(mod.name) + ' (' + formatExtra(mod.extra_price_cents) + ')')); + block.appendChild(addLab); + } + }); + + return block; + } + + // Construit la liste serialisable [{ingredient_id, action}] depuis les maps + // selectedRemove / selectedAdd (remove d'abord, puis add ; un ingredient a la + // fois retire et ajoute resterait deux entrees, mais l'UX coche rarement les deux). + function buildModifiers(selectedRemove, selectedAdd) { + var out = []; + Object.keys(selectedRemove).forEach(function (id) { + out.push({ ingredient_id: Number(id), action: 'remove' }); + }); + Object.keys(selectedAdd).forEach(function (id) { + out.push({ ingredient_id: Number(id), action: 'add' }); + }); + return out; + } + + // Libelle recap des modificateurs choisis (ex. "sans Oignon, +Bacon"), resolu + // via la liste de modificateurs proposables (pour le nom de l'ingredient). + function modifierLabel(modifiers, chosen) { + if (!chosen || !chosen.length) { + return ''; + } + var nameById = {}; + (modifiers || []).forEach(function (m) { + nameById[Number(m.ingredient_id)] = String(m.name); + }); + var parts = chosen.map(function (c) { + var name = nameById[Number(c.ingredient_id)] || ('#' + c.ingredient_id); + return c.action === 'add' ? ('+' + name) : ('sans ' + name); + }); + return parts.join(', '); + } + + /* ----------------------------------------------------------------- */ + /* Serialisation du panier -> #items_json */ + /* ----------------------------------------------------------------- */ + + // Produits sans modificateur : derives des champs qty_ (>= 1) NON routes par + // la modale. Produits personnalises : productLines. Menus : menuLines. La forme + // calque ce qu'attend OrderRepository::resolveLine (revalide cote serveur). + function serialize() { + var items = []; + + Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) { + var productId = Number(input.dataset.productId); + if (configurableIds[productId]) { + return; // route par la modale -> pas de double comptage. + } + var quantity = parseInt(input.value, 10); + if (productId > 0 && quantity >= 1) { + items.push({ type: 'product', product_id: productId, quantity: quantity }); + } + }); + + productLines.forEach(function (line) { + items.push({ + type: 'product', + product_id: line.productId, + quantity: line.quantity, + modifiers: line.modifiers.map(function (m) { + return { ingredient_id: m.ingredient_id, action: m.action }; + }), + }); + }); + + menuLines.forEach(function (line) { + items.push({ + type: 'menu', + menu_id: line.menuId, + quantity: 1, + format: line.format, + selections: line.selections.map(function (s) { + return { menu_slot_id: s.slotId, product_id: s.productId }; + }), + modifiers: line.modifiers.map(function (m) { + return { ingredient_id: m.ingredient_id, action: m.action }; + }), + }); + }); + + hidden.value = JSON.stringify(items); + } + + /* ----------------------------------------------------------------- */ + /* Rendu du panier (recap des lignes configurees) */ + /* ----------------------------------------------------------------- */ + + function renderCart() { + Array.prototype.forEach.call(cart.querySelectorAll('.order-cart__line'), function (node) { + node.parentNode.removeChild(node); + }); + + productLines.forEach(function (line) { + var li = el('li', 'order-cart__line'); + + var label = el('span', 'order-cart__label'); + var text = line.productName + ' x' + line.quantity; + var modLabel = modifierLabel(line.proposable, line.modifiers); + if (modLabel) { + text += ' (' + modLabel + ')'; + } + label.textContent = text; + li.appendChild(label); + + var removeBtn = el('button', 'btn btn-secondary order-cart__remove'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Retirer'; + removeBtn.addEventListener('click', function () { + productLines = productLines.filter(function (l) { return l.localId !== line.localId; }); + renderCart(); + }); + li.appendChild(removeBtn); + + cart.appendChild(li); + }); + + menuLines.forEach(function (line) { + var li = el('li', 'order-cart__line'); + + var label = el('span', 'order-cart__label'); + var parts = [line.menuName + ' (' + (line.format === 'maxi' ? 'Maxi' : 'Normal') + ')']; + line.selections.forEach(function (s) { + var p = productById[Number(s.productId)]; + if (p) { + parts.push(p.name); + } + }); + var text = parts.join(' - '); + var modLabel = modifierLabel(line.proposable, line.modifiers); + if (modLabel) { + text += ' (' + modLabel + ')'; + } + label.textContent = text; + li.appendChild(label); + + var removeBtn = el('button', 'btn btn-secondary order-cart__remove'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Retirer'; + removeBtn.addEventListener('click', function () { + menuLines = menuLines.filter(function (l) { return l.localId !== line.localId; }); + renderCart(); + }); + li.appendChild(removeBtn); + + cart.appendChild(li); + }); + + if (cartEmpty) { + cartEmpty.style.display = (productLines.length || menuLines.length) ? 'none' : ''; + } + } + + /* ----------------------------------------------------------------- */ + /* Modales de configuration */ + /* ----------------------------------------------------------------- */ + + function closeComposer() { + modalHost.textContent = ''; + modalHost.setAttribute('hidden', ''); + } + + // Modale d'un produit a la carte : quantite + modificateurs (retrait/ajout). + function openProductComposer(product) { + 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'); + title.textContent = product.name; + panel.appendChild(title); + + // Quantite + var qtyBlock = el('div', 'menu-composer__slot'); + var qtyLab = el('label', 'menu-composer__legend'); + qtyLab.textContent = 'Quantite'; + qtyLab.setAttribute('for', 'composer-product-qty'); + qtyBlock.appendChild(qtyLab); + var qtyInput = el('input', 'form-input menu-composer__qty'); + qtyInput.type = 'number'; + qtyInput.id = 'composer-product-qty'; + qtyInput.min = '1'; + qtyInput.value = '1'; + qtyInput.addEventListener('change', function () { + var v = parseInt(qtyInput.value, 10); + state.quantity = v >= 1 ? v : 1; + }); + qtyBlock.appendChild(qtyInput); + panel.appendChild(qtyBlock); + + // Modificateurs + panel.appendChild(renderModifierControls(proposable, state.selectedRemove, state.selectedAdd)); + + var actions = el('div', 'menu-composer__actions'); + var addBtn = el('button', 'btn btn-primary menu-composer__add'); + addBtn.type = 'button'; + addBtn.textContent = 'Ajouter au panier'; + addBtn.addEventListener('click', function () { + productLines.push({ + localId: ++lineSeq, + productId: Number(product.id), + productName: product.name, + quantity: state.quantity, + proposable: proposable, + modifiers: buildModifiers(state.selectedRemove, state.selectedAdd), + }); + renderCart(); + closeComposer(); + }); + actions.appendChild(addBtn); + + var cancelBtn = el('button', 'btn btn-secondary menu-composer__cancel'); + cancelBtn.type = 'button'; + cancelBtn.textContent = 'Annuler'; + cancelBtn.addEventListener('click', closeComposer); + actions.appendChild(cancelBtn); + + panel.appendChild(actions); + modalHost.appendChild(panel); + modalHost.removeAttribute('hidden'); + } + + // Ouvre la modale d'un menu : choix du format, une selection par slot, puis les + // modificateurs du burger. Pre-selectionne le 1er choix de chaque slot requis. + function openComposer(menu) { + var steps = composerSteps(menu, productById); + var proposable = menu.burger_modifiers || []; + var state = { format: 'normal', selections: {}, selectedRemove: {}, selectedAdd: {} }; + steps.forEach(function (step) { + if (step.isRequired && step.options[0]) { + state.selections[step.id] = step.options[0].id; + } + }); + + modalHost.textContent = ''; + var panel = el('div', 'menu-composer'); + + var title = el('h2', 'menu-composer__title'); + title.textContent = menu.name; + panel.appendChild(title); + + // Format Normal / Maxi + var formatGroup = el('div', 'menu-composer__format'); + var formatLegend = el('p', 'menu-composer__legend'); + formatLegend.textContent = 'Format'; + formatGroup.appendChild(formatLegend); + [ + { value: 'normal', label: 'Normal' }, + { value: 'maxi', label: 'Maxi' }, + ].forEach(function (fmt) { + var lab = el('label', 'menu-composer__radio'); + var radio = el('input'); + radio.type = 'radio'; + radio.name = 'composer-format'; + radio.value = fmt.value; + radio.className = 'menu-composer__format-input'; + if (state.format === fmt.value) { + radio.checked = true; + } + radio.addEventListener('change', function () { + state.format = fmt.value; + }); + lab.appendChild(radio); + lab.appendChild(doc.createTextNode(' ' + fmt.label)); + formatGroup.appendChild(lab); + }); + panel.appendChild(formatGroup); + + // Un bloc par slot : select des options (+ "Sans" si optionnel). + steps.forEach(function (step) { + var block = el('div', 'menu-composer__slot'); + var lab = el('label', 'menu-composer__legend'); + lab.textContent = step.name + (step.isRequired ? '' : ' (optionnel)'); + block.appendChild(lab); + + var select = el('select', 'form-input menu-composer__slot-select'); + select.dataset.slotId = String(step.id); + if (!step.isRequired) { + var none = el('option'); + none.value = ''; + none.textContent = 'Sans'; + select.appendChild(none); + } + step.options.forEach(function (opt) { + var o = el('option'); + o.value = String(opt.id); + o.textContent = String(opt.name); + if (state.selections[step.id] === opt.id) { + o.selected = true; + } + select.appendChild(o); + }); + select.addEventListener('change', function () { + var raw = select.value; + if (raw === '') { + delete state.selections[step.id]; + } else { + state.selections[step.id] = parseInt(raw, 10); + } + }); + block.appendChild(select); + panel.appendChild(block); + }); + + // Modificateurs du burger support (retrait/ajout d'ingredients). + panel.appendChild(renderModifierControls(proposable, state.selectedRemove, state.selectedAdd)); + + // Actions : ajouter (si tous les requis choisis) / annuler. + var actions = el('div', 'menu-composer__actions'); + var addBtn = el('button', 'btn btn-primary menu-composer__add'); + addBtn.type = 'button'; + addBtn.textContent = 'Ajouter au panier'; + addBtn.addEventListener('click', function () { + var allRequired = steps.filter(function (s) { return s.isRequired; }) + .every(function (s) { return state.selections[s.id] != null; }); + if (!allRequired) { + return; + } + var selections = []; + steps.forEach(function (step) { + var chosen = state.selections[step.id]; + if (chosen != null) { + selections.push({ slotId: step.id, productId: chosen }); + } + }); + menuLines.push({ + localId: ++lineSeq, + menuId: Number(menu.id), + menuName: menu.name, + format: state.format, + selections: selections, + proposable: proposable, + modifiers: buildModifiers(state.selectedRemove, state.selectedAdd), + }); + renderCart(); + closeComposer(); + }); + actions.appendChild(addBtn); + + var cancelBtn = el('button', 'btn btn-secondary menu-composer__cancel'); + cancelBtn.type = 'button'; + cancelBtn.textContent = 'Annuler'; + cancelBtn.addEventListener('click', closeComposer); + actions.appendChild(cancelBtn); + + panel.appendChild(actions); + modalHost.appendChild(panel); + modalHost.removeAttribute('hidden'); + } + + /* ----------------------------------------------------------------- */ + /* Cablage */ + /* ----------------------------------------------------------------- */ + + Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) { + btn.addEventListener('click', function () { + var productId = Number(btn.dataset.productId); + var product = productById[productId]; + if (product) { + openProductComposer(product); + } + }); + }); + + Array.prototype.forEach.call(doc.querySelectorAll('.menu-configure'), function (btn) { + btn.addEventListener('click', function () { + var menuId = Number(btn.dataset.menuId); + var menu = menus.filter(function (m) { return Number(m.id) === menuId; })[0]; + if (menu) { + openComposer(menu); + } + }); + }); + + form.addEventListener('submit', function () { + serialize(); + }); + + renderCart(); + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { init: init, composerSteps: composerSteps }; + } + if (typeof document !== 'undefined' && document.addEventListener) { + document.addEventListener('DOMContentLoaded', function () { + init(document); + }); + } +})(); diff --git a/src/public/admin/assets/js/menu-form.js b/src/public/admin/assets/js/menu-form.js new file mode 100644 index 0000000..a45f909 --- /dev/null +++ b/src/public/admin/assets/js/menu-form.js @@ -0,0 +1,160 @@ +/* + * menu-form.js — Builder de slots du formulaire menu (back-office). + * + * CSP 'self' : script externe (pas d'inline). Les donnees (produits, types, + * slots initiaux) sont lues depuis les attributs data-* de #slot-builder. + * A la soumission, l'etat des slots est serialise en JSON dans le champ cache + * #slots_json (Request::formBody cote serveur ne garde que les scalaires, d'ou + * le passage par une chaine JSON). Le serveur revalide tout (RG-T18). + */ +(function () { + 'use strict'; + + var builder = document.getElementById('slot-builder'); + var form = document.getElementById('menu-form'); + var hidden = document.getElementById('slots_json'); + var addBtn = document.getElementById('add-slot'); + if (!builder || !form || !hidden || !addBtn) { + return; + } + + function parseData(key, fallback) { + try { + var v = JSON.parse(builder.dataset[key] || fallback); + return Array.isArray(v) ? v : JSON.parse(fallback); + } catch (e) { + return JSON.parse(fallback); + } + } + + var products = parseData('products', '[]'); // [{id, name}] + var slotTypes = parseData('slotTypes', '[]'); // ['drink', 'side', ...] + var initialSlots = parseData('slots', '[]'); // [{name, slot_type, is_required, options:[id]}] + + function el(tag, className) { + var e = document.createElement(tag); + if (className) { + e.className = className; + } + return e; + } + + // Construit le bloc DOM d'un slot. `slot` peut etre vide (creation). + function renderSlot(slot) { + slot = slot || {}; + var selectedOptions = Array.isArray(slot.options) ? slot.options.map(Number) : []; + + var block = el('fieldset', 'slot-block form-group'); + block.style.border = '1px solid #ddd'; + block.style.padding = '0.75rem'; + block.style.marginBottom = '0.75rem'; + + var head = el('div'); + + // Nom du slot + var nameLabel = el('label'); + nameLabel.appendChild(document.createTextNode('Nom du slot ')); + var nameInput = el('input', 'form-input slot-name'); + nameInput.type = 'text'; + nameInput.maxLength = 80; + nameInput.value = slot.name ? String(slot.name) : ''; + nameLabel.appendChild(nameInput); + head.appendChild(nameLabel); + + // Type + var typeLabel = el('label'); + typeLabel.appendChild(document.createTextNode(' Type ')); + var typeSelect = el('select', 'form-input slot-type'); + slotTypes.forEach(function (t) { + var opt = el('option'); + opt.value = String(t); + opt.textContent = String(t); + if (String(slot.slot_type) === String(t)) { + opt.selected = true; + } + typeSelect.appendChild(opt); + }); + typeLabel.appendChild(typeSelect); + head.appendChild(typeLabel); + + // Requis + var reqLabel = el('label'); + var reqInput = el('input', 'slot-required'); + reqInput.type = 'checkbox'; + if (Number(slot.is_required) === 1) { + reqInput.checked = true; + } + reqLabel.appendChild(reqInput); + reqLabel.appendChild(document.createTextNode(' Requis')); + head.appendChild(reqLabel); + + // Retirer + var removeBtn = el('button', 'btn btn-secondary slot-remove'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Retirer'; + removeBtn.addEventListener('click', function () { + block.parentNode.removeChild(block); + }); + head.appendChild(removeBtn); + + block.appendChild(head); + + // Options : cases a cocher des produits eligibles + var optWrap = el('div', 'slot-options'); + optWrap.style.maxHeight = '160px'; + optWrap.style.overflowY = 'auto'; + optWrap.style.marginTop = '0.5rem'; + products.forEach(function (p) { + var lab = el('label'); + lab.style.display = 'block'; + var cb = el('input', 'slot-option'); + cb.type = 'checkbox'; + cb.value = String(p.id); + if (selectedOptions.indexOf(Number(p.id)) !== -1) { + cb.checked = true; + } + lab.appendChild(cb); + lab.appendChild(document.createTextNode(' ' + String(p.name))); + optWrap.appendChild(lab); + }); + block.appendChild(optWrap); + + return block; + } + + // Lit l'etat des blocs et le serialise dans #slots_json. + function serialize() { + var slots = []; + var blocks = builder.querySelectorAll('.slot-block'); + Array.prototype.forEach.call(blocks, function (block) { + var name = block.querySelector('.slot-name').value.trim(); + var type = block.querySelector('.slot-type').value; + var required = block.querySelector('.slot-required').checked ? 1 : 0; + var options = []; + Array.prototype.forEach.call(block.querySelectorAll('.slot-option'), function (cb) { + if (cb.checked) { + options.push(Number(cb.value)); + } + }); + slots.push({ name: name, slot_type: type, is_required: required, options: options }); + }); + hidden.value = JSON.stringify(slots); + } + + addBtn.addEventListener('click', function () { + builder.appendChild(renderSlot(null)); + }); + + form.addEventListener('submit', function () { + serialize(); + }); + + // Rendu initial : slots existants (edition) ou un slot vide (creation). + if (initialSlots.length) { + initialSlots.forEach(function (s) { + builder.appendChild(renderSlot(s)); + }); + } else { + builder.appendChild(renderSlot(null)); + } +})(); diff --git a/src/public/admin/assets/js/pin-modal.js b/src/public/admin/assets/js/pin-modal.js new file mode 100644 index 0000000..9aab54e --- /dev/null +++ b/src/public/admin/assets/js/pin-modal.js @@ -0,0 +1,139 @@ +/** + * pin-modal.js — Re-autorisation par PIN au moment de l'action sensible. + * + * Les formulaires d'action sensible portent un fieldset inline (email equipier + PIN, + * RG-T13). Plutot que ce bloc noye en bas du formulaire, on le masque et on le remplace + * par un MODAL clair qui surgit au clic sur "Enregistrer/Supprimer" : l'equipier confirme + * avec son email + PIN (ou ceux d'un responsable), on reinjecte dans les champs caches, + * puis on soumet. Le contrat serveur ne change pas (il lit toujours pin_email + pin). + * + * CSP 'self' : script externe, aucun handler inline, le DOM du modal est construit ici. + */ +(function () { + 'use strict'; + + function init(doc) { + var emailInput = doc.getElementById('pin_email'); + var pinInput = doc.getElementById('pin'); + // Seuls les formulaires de RE-AUTORISATION ont pin_email (la page set-PIN ne + // l'a pas : on ne l'intercepte donc pas). + if (!emailInput || !pinInput) { + return; + } + var form = pinInput.closest('form'); + if (!form) { + return; + } + + var fieldset = pinInput.closest('fieldset'); + if (fieldset) { + fieldset.hidden = true; + } + + // Email de l'utilisateur connecte (expose sur ) : pre-remplit + // le modal pour le cas courant ou l'on valide sa PROPRE action ; reste modifiable + // pour validation par un responsable. + var prefillEmail = (doc.body && doc.body.getAttribute('data-user-email')) || ''; + + var overlay = buildModal(doc); + doc.body.appendChild(overlay); + + var modalEmail = overlay.querySelector('#pm-email'); + var modalPin = overlay.querySelector('#pm-pin'); + var modalError = overlay.querySelector('[data-pm-error]'); + var confirmed = false; + + form.addEventListener('submit', function (e) { + if (confirmed) { + return; // deja valide via le modal -> soumission reelle + } + e.preventDefault(); + openModal(); + }); + + overlay.querySelector('[data-pm-cancel]').addEventListener('click', closeModal); + overlay.addEventListener('mousedown', function (e) { + if (e.target === overlay) { + closeModal(); + } + }); + doc.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && overlay.classList.contains('open')) { + closeModal(); + } + }); + + overlay.querySelector('[data-pm-form]').addEventListener('submit', function (e) { + e.preventDefault(); + var email = modalEmail.value.trim(); + var pin = modalPin.value; + if (email === '' || pin === '') { + modalError.textContent = 'Email et PIN requis pour confirmer.'; + modalError.hidden = false; + return; + } + emailInput.value = email; + pinInput.value = pin; + confirmed = true; + closeModal(); + form.submit(); + }); + + function openModal() { + modalError.hidden = true; + modalEmail.value = emailInput.value || prefillEmail || ''; + modalPin.value = ''; + overlay.classList.add('open'); + (modalEmail.value === '' ? modalEmail : modalPin).focus(); + } + + function closeModal() { + overlay.classList.remove('open'); + } + } + + function buildModal(doc) { + var overlay = doc.createElement('div'); + overlay.className = 'pin-modal-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Confirmation par PIN'); + overlay.innerHTML = + '
' + + '
' + + ' ' + + '
' + + '

Action a confirmer

' + + '

Saisissez vos identifiants equipier (ou ceux d\'un responsable).

' + + '
' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + ' ' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + '
'; + return overlay; + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { init: init, buildModal: buildModal }; + } + if (typeof document !== 'undefined' && document.addEventListener) { + document.addEventListener('DOMContentLoaded', function () { + init(document); + }); + } +})(); diff --git a/src/public/admin/assets/js/product-recipe.js b/src/public/admin/assets/js/product-recipe.js new file mode 100644 index 0000000..80ff9b5 --- /dev/null +++ b/src/public/admin/assets/js/product-recipe.js @@ -0,0 +1,164 @@ +/* + * product-recipe.js — Builder de composition (recette) du formulaire produit. + * + * CSP 'self' : script externe (pas d'inline). Les donnees (catalogue d'ingredients, + * composition initiale) sont lues depuis les attributs data-* de #recipe-builder. + * A la soumission, l'etat est serialise en JSON dans le champ cache #composition_json + * (Request::formBody cote serveur ne garde que les scalaires). Le serveur revalide + * tout (RG-T18) : bornes, existence de l'ingredient, dedup par PK composite. + * + * Une composition VIDE est valide (un produit peut n'avoir aucune recette definie). + */ +(function () { + 'use strict'; + + var builder = document.getElementById('recipe-builder'); + var form = document.getElementById('recipe-form'); + var hidden = document.getElementById('composition_json'); + var addBtn = document.getElementById('add-ingredient'); + if (!builder || !form || !hidden || !addBtn) { + return; + } + + function parseData(key, fallback) { + try { + var v = JSON.parse(builder.dataset[key] || fallback); + return Array.isArray(v) ? v : JSON.parse(fallback); + } catch (e) { + return JSON.parse(fallback); + } + } + + var ingredients = parseData('ingredients', '[]'); // [{id, name, unit}] + var initial = parseData('composition', '[]'); // [{ingredient_id, quantity_normal, ...}] + + function el(tag, className) { + var e = document.createElement(tag); + if (className) { + e.className = className; + } + return e; + } + + function numberInput(className, value, min) { + var input = el('input', 'form-input ' + className); + input.type = 'number'; + input.min = String(min); + input.value = String(value); + input.style.width = '7rem'; + return input; + } + + // Construit le bloc DOM d'une ligne de composition. `line` peut etre vide (ajout). + function renderLine(line) { + line = line || {}; + + var block = el('fieldset', 'recipe-line form-group'); + block.style.border = '1px solid #ddd'; + block.style.padding = '0.75rem'; + block.style.marginBottom = '0.75rem'; + + // Ingredient (picker) + var ingLabel = el('label'); + ingLabel.appendChild(document.createTextNode('Ingredient ')); + var ingSelect = el('select', 'form-input recipe-ingredient'); + ingredients.forEach(function (i) { + var opt = el('option'); + opt.value = String(i.id); + opt.textContent = String(i.name) + (i.unit ? ' (' + String(i.unit) + ')' : ''); + if (Number(line.ingredient_id) === Number(i.id)) { + opt.selected = true; + } + ingSelect.appendChild(opt); + }); + ingLabel.appendChild(ingSelect); + block.appendChild(ingLabel); + + // Quantites + var qnLabel = el('label'); + qnLabel.appendChild(document.createTextNode(' Qte normale ')); + qnLabel.appendChild(numberInput('recipe-qn', line.quantity_normal != null ? line.quantity_normal : 1, 1)); + block.appendChild(qnLabel); + + var qmLabel = el('label'); + qmLabel.appendChild(document.createTextNode(' Qte maxi ')); + qmLabel.appendChild(numberInput('recipe-qm', line.quantity_maxi != null ? line.quantity_maxi : 1, 1)); + block.appendChild(qmLabel); + + // Supplement (centimes) + var extraLabel = el('label'); + extraLabel.appendChild(document.createTextNode(' Supplement (cts) ')); + extraLabel.appendChild(numberInput('recipe-extra', line.extra_price_cents != null ? line.extra_price_cents : 0, 0)); + block.appendChild(extraLabel); + + // Retirable / Ajoutable + var remLabel = el('label'); + var remInput = el('input', 'recipe-removable'); + remInput.type = 'checkbox'; + if (Number(line.is_removable) === 1) { + remInput.checked = true; + } + remLabel.appendChild(remInput); + remLabel.appendChild(document.createTextNode(' Retirable')); + block.appendChild(remLabel); + + var addLabel = el('label'); + var addInput = el('input', 'recipe-addable'); + addInput.type = 'checkbox'; + if (Number(line.is_addable) === 1) { + addInput.checked = true; + } + addLabel.appendChild(addInput); + addLabel.appendChild(document.createTextNode(' Ajoutable')); + block.appendChild(addLabel); + + // Retirer la ligne + var removeBtn = el('button', 'btn btn-secondary recipe-remove'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Retirer'; + removeBtn.addEventListener('click', function () { + block.parentNode.removeChild(block); + }); + block.appendChild(removeBtn); + + return block; + } + + // Lit l'etat des lignes et le serialise dans #composition_json. + function serialize() { + var lines = []; + var blocks = builder.querySelectorAll('.recipe-line'); + Array.prototype.forEach.call(blocks, function (block) { + var ingredientId = Number(block.querySelector('.recipe-ingredient').value); + if (!ingredientId) { + return; + } + lines.push({ + ingredient_id: ingredientId, + quantity_normal: Number(block.querySelector('.recipe-qn').value), + quantity_maxi: Number(block.querySelector('.recipe-qm').value), + extra_price_cents: Number(block.querySelector('.recipe-extra').value), + is_removable: block.querySelector('.recipe-removable').checked ? 1 : 0, + is_addable: block.querySelector('.recipe-addable').checked ? 1 : 0 + }); + }); + hidden.value = JSON.stringify(lines); + } + + addBtn.addEventListener('click', function () { + if (!ingredients.length) { + return; // aucun ingredient au catalogue : rien a composer + } + builder.appendChild(renderLine(null)); + }); + + form.addEventListener('submit', function () { + serialize(); + }); + + // Rendu initial : lignes existantes (edition). Composition vide -> aucune ligne + // (l'utilisateur ajoute a la demande, ou enregistre une recette vide). + initial.forEach(function (l) { + builder.appendChild(renderLine(l)); + }); +})(); diff --git a/src/public/admin/catalogue.html b/src/public/admin/catalogue.html deleted file mode 100644 index d24407e..0000000 --- a/src/public/admin/catalogue.html +++ /dev/null @@ -1,306 +0,0 @@ - - - - - - Catalogue — Wakdo Admin - - - -
- - -
- - -
-
- - -
-
-
- - - - - -
- - - -
- - - -
- - -
-
-
-
- - -
- - -
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ImageLibelle Categorie Prix Stock
Big MacBurgers6,00 €Disponible
Modifier
Royal BaconBurgers5,10 €Disponible
Modifier
CBOBurgers8,90 €Disponible
Modifier
MC CrispyBurgers5,30 €Indisponible
Modifier
Coca ColaBoissons1,90 €Disponible
Modifier
Moyenne FriteFrites2,75 €Disponible
Modifier
KetchupSauces0,70 €Disponible
Modifier
Nuggets x4Encas4,20 €Disponible
Modifier
-
- -
-
- - -
-
-
- - - - - - - - - - - - - - - - - - - - - -
LibelleProduitsOrdre affichageStatut
Menus131Visible
Modifier
Burgers132Visible
Modifier
Wraps43Visible
Modifier
Salades34Visible
Modifier
Frites55Visible
Modifier
Boissons86Visible
Modifier
Desserts97Visible
Modifier
Encas48Visible
Modifier
Sauces79Masquee
Modifier
-
-
-
- - -
-
-
- - - - - - - - - - - - - - - - - - -
LibellePrixContenuStatut
Menu Le 2808,80 €Burger + Frites + Boisson + SauceDisponible
Modifier
Menu Big Tasty10,60 €Burger + Frites + Boisson + SauceDisponible
Modifier
Menu Big Mac8,00 €Burger + Frites + Boisson + SauceDisponible
Modifier
Menu CBO10,90 €Burger + Frites + Boisson + SauceDisponible
Modifier
Menu Royal Cheese6,40 €Burger + Frites + Boisson + SauceDisponible
Modifier
Menu Royal Bacon7,05 €Burger + Frites + Boisson + SauceIndisponible
Modifier
-
-
-
-
-
- - - - diff --git a/src/public/admin/commandes.html b/src/public/admin/commandes.html deleted file mode 100644 index 8569971..0000000 --- a/src/public/admin/commandes.html +++ /dev/null @@ -1,254 +0,0 @@ - - - - - - Commandes — Wakdo Admin - - - -
- - -
- - -
-
- - -
-
-
- - - - - -
- - - -
-
-
- - -
- - - -
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Date / Heure ModeSourceStatutLignesTotal
#108709/05/2026 13:42Sur placeBorneLivree318,70 €
#108609/05/2026 13:38A emporterComptoirEn preparation524,30 €
#108509/05/2026 13:31Sur placeBorneLivree211,40 €
#108409/05/2026 13:27A emporterDriveLivree28,80 €
#108309/05/2026 13:19Sur placeBorneAnnulee16,40 €
#108209/05/2026 13:14Sur placeBorneLivree732,10 €
#108109/05/2026 13:08A emporterDriveLivree210,90 €
#108009/05/2026 12:58Sur placeComptoirLivree415,60 €
-
- -
-
-
- - - - diff --git a/src/public/admin/cuisine.html b/src/public/admin/cuisine.html deleted file mode 100644 index 81275a5..0000000 --- a/src/public/admin/cuisine.html +++ /dev/null @@ -1,253 +0,0 @@ - - - - - - Cuisine — Wakdo Admin - - - -
- - -
- - -
-
- - -
-
-
- - - - - -
- - - -
- -
-
-
-
#1086
-
13:38 — 4 min
-
- A emporter -
-
-
- x2Menu Big Mac -
-
- x1Grande Frite -
-
- x2Coca Cola -
-
- x1Nuggets x4 -
-
- -
- -
-
-
-
#1088
-
13:44 — 2 min
-
- Sur place -
-
-
- x1Menu CBO -
-
- x1Fanta Orange -
-
- x1Classic Barbecue -
-
- -
- -
-
-
-
#1089
-
13:45 — 1 min
-
- A emporter -
-
-
- x3Menu Royal Cheese -
-
- x1Petite Salade -
-
- x3Eau -
-
- -
- -
-
-
-
#1090
-
13:46 — maintenant
-
- Sur place -
-
-
- x1Big Tasty Bacon -
-
- x1Grande Frite -
-
- x1Ice Tea Peche -
-
- x2Ketchup -
-
- -
- -
-
-
-
#1091
-
13:46 — maintenant
-
- A emporter -
-
-
- x4Cheeseburger -
-
- x2Moyenne Frite -
-
- x4Coca Cola -
-
- -
- -
-
-
- - - - diff --git a/src/public/admin/dashboard.html b/src/public/admin/dashboard.html deleted file mode 100644 index a9a668b..0000000 --- a/src/public/admin/dashboard.html +++ /dev/null @@ -1,411 +0,0 @@ - - - - - - Tableau de bord — Wakdo Admin - - - -
- - -
- - - - -
-
- - -
-
-
- - - - - -
- - - -
-
-
Ventes du jour
-
2 847,50 €
-
- - - +12,4 % - - vs hier -
-
- -
-
Commandes du jour
-
231
-
- - - +8,2 % - - vs hier -
-
- -
-
Panier moyen
-
12,33 €
-
- - - -1,8 % - - vs hier -
-
- -
-
Produits actifs
-
53
-
- - — - - inchange -
-
-
- - -
-
- Dernieres commandes - - Voir tout - - -
- -
-
-
- - -
- -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Heure ModeStatutTotal
#108713:42Sur placeLivree18,70 € -
- -
- - - Voir detail - -
- -
-
-
#108613:38A emporterEn preparation24,30 € -
- -
- - - Voir detail - -
- -
-
-
#108513:31Sur placeLivree11,40 € -
- -
- Voir detail -
- -
-
-
#108413:27A emporterLivree8,80 € -
- -
- Voir detail -
- -
-
-
#108313:19Sur placeAnnulee6,40 € -
- - -
-
#108213:14Sur placeLivree32,10 € -
- -
- Voir detail -
- -
-
-
#108113:08A emporterLivree10,90 € -
- -
- Voir detail -
- -
-
-
#108012:58Sur placeLivree15,60 € -
- -
- Voir detail -
- -
-
-
-
- - -
-
-
- - - - diff --git a/src/public/admin/index.php b/src/public/admin/index.php index bea045c..b345bc7 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -1,34 +1,255 @@ public -> src) pour atteindre la racine src/. +require dirname(__DIR__, 2) . '/app/Core/Autoloader.php'; +Autoloader::register(); + +// En-tetes de securite poses tot, valables sur toute reponse y compris une 500. +header('X-Content-Type-Options: nosniff'); header('X-Robots-Tag: noindex, nofollow'); -$phpVersion = htmlspecialchars(PHP_VERSION, ENT_QUOTES, 'UTF-8'); -$now = htmlspecialchars(date('Y-m-d H:i:s'), ENT_QUOTES, 'UTF-8'); -?> - - - - - - Wakdo - back-office - - - -

Wakdo - back-office

-

En construction.

-

Phase P1 - conception Merise en cours. Le back-office sera implemente en phases P2 a P4.

-
-

Diagnostic FastCGI : PHP repond a .

-

TODO P2 : assets partages (logo, images produits) via Apache Alias entre les 2 vhosts.

- - +$config = new Config(); +date_default_timezone_set($config->timezone()); + +// Requete + middleware CORS construits AVANT le try : ils ne dependent que de la +// config et des globales, et doivent rester accessibles dans le catch pour decorer +// la reponse 500 d'une requete /api/ cross-origin (sans quoi le navigateur de la +// borne ne peut pas lire le corps de l'erreur). +$request = Request::fromGlobals(); +$cors = new Cors($config->get('CORS_ALLOWED_ORIGIN', '') ?? ''); + +try { + // Acces BDD paresseux : la connexion n'est ouverte qu'au premier query(), + // donc la home back-office reste servie meme base indisponible. + $database = new Database($config); + + // Demarre la session du vhost admin avant le dispatch (effet de bord global, + // hors du Core stateless). Les controleurs y rattachent leur SessionManager. + (new SessionManager($config))->start(); + + $router = new Router($config, $database); + $router->add('GET', '/', [HomeController::class, 'index']); + $router->add('GET', '/api/health', [HealthController::class, 'index']); + + // Authentification back-office (mlt.md section 12). Le docroot du vhost admin + // etant src/public/admin, le Router voit "/login" (pas de prefixe "/admin"). + $router->add('GET', '/login', [AuthController::class, 'showLogin']); + $router->add('POST', '/login', [AuthController::class, 'login']); + $router->add('POST', '/logout', [AuthController::class, 'logout']); + $router->add('GET', '/forgot_password', [PasswordResetController::class, 'showRequest']); + $router->add('POST', '/forgot_password', [PasswordResetController::class, 'submitRequest']); + $router->add('GET', '/reset_password', [PasswordResetController::class, 'showConfirm']); + $router->add('POST', '/reset_password', [PasswordResetController::class, 'submitConfirm']); + + // RBAC : identite + permissions de la session courante (gardee par SessionGuard). + $router->add('GET', '/api/me', [MeController::class, 'show']); + + // Commandes borne (P4, domaine 7). API publique kiosk, ANONYME (pas de session) : + // creation en pending_payment puis encaissement (paid + decrement stock RG-T20). + // Idempotente sur idempotency_key (anti double-clic / retry reseau). {number} = + // un seul segment (numero K+id), pas de collision avec un sous-chemin. + $router->add('POST', '/api/orders', [OrderController::class, 'create']); + $router->add('POST', '/api/orders/{number}/pay', [OrderController::class, 'pay']); + // Suivi public du statut d'une commande par son numero (lecture seule, anonyme). + $router->add('GET', '/api/orders/{number}', [OrderController::class, 'show']); + + // Lecture catalogue borne (P4, docs/api/conventions.md section 5.2). API publique + // kiosk, ANONYME : la borne consulte sans session. Lecture seule ; ne sert que le + // commandable (categories actives, produits disponibles en categorie active). + // {id} = un seul segment ; /api/products (collection) et /api/products/{id} + // (unitaire) ne se chevauchent pas. + $router->add('GET', '/api/categories', [CatalogueController::class, 'categories']); + $router->add('GET', '/api/products', [CatalogueController::class, 'products']); + $router->add('GET', '/api/products/{id}', [CatalogueController::class, 'product']); + // Menus composes : liste legere + detail avec slots (B1 burger impose, B2 Normal/Maxi). + $router->add('GET', '/api/menus', [CatalogueController::class, 'menus']); + $router->add('GET', '/api/menus/{id}', [CatalogueController::class, 'menu']); + // Allergenes INCO (info generale, 14 categories). La borne garde son JSON statique + // (descriptions riches) ; l'endpoint sert d'autres consommateurs eventuels. + $router->add('GET', '/api/allergens', [CatalogueController::class, 'allergens']); + + // Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard. + $router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']); + // Tableau de bord statistiques (stats.read) : landing du role manager. KPIs + // catalogue + sante stock (RG-T21) ; KPIs de vente avec les commandes (P4). + $router->add('GET', '/admin/stats', [StatsController::class, 'index']); + + // Commandes (P4, order.read) : liste lecture seule du domaine commande. + $router->add('GET', '/admin/orders', [OrderAdminController::class, 'index']); + // Remise au client : paid -> delivered (order.deliver, geste unique, POST + CSRF). + $router->add('POST', '/admin/orders/{number}/deliver', [OrderAdminController::class, 'deliver']); + // Annulation : pending_payment|paid -> cancelled (CANCEL_ORDER mlt 7.1, order.cancel). + // PIN equipier + audit + restock conditionnel (RG-T13/T14). {number} = un seul + // segment (numero K/C/D + id) ; /cancel ne chevauche ni /deliver ni la liste. + $router->add('GET', '/admin/orders/{number}/cancel', [OrderAdminController::class, 'confirmCancel']); + $router->add('POST', '/admin/orders/{number}/cancel', [OrderAdminController::class, 'cancel']); + // Affichage cuisine (KDS) : file des commandes payees (order.read). Landing du role + // kitchen (seed role.default_route = /kitchen/display) ; corrige le 404 d'apres-login. + $router->add('GET', '/kitchen/display', [KitchenController::class, 'display']); + + // Saisie de commande comptoir / drive (CREATE_COUNTER_ORDER, mlt 4.1, order.create). + // UN controleur, deux canaux : la source est derivee du chemin (/drive -> drive, + // sinon counter). Landings des roles counter/drive (seed role.default_route = + // /counter/orders + /drive/orders) ; corrige le 404 d'apres-login. Sans PIN + // (la permission order.create suffit) ; la commande est encaissee directement. + // {new}/{POST liste} = segments distincts, pas de collision avec /kitchen ni /admin. + $router->add('GET', '/counter/orders', [CounterOrderController::class, 'index']); + $router->add('GET', '/counter/orders/new', [CounterOrderController::class, 'create']); + $router->add('POST', '/counter/orders', [CounterOrderController::class, 'store']); + $router->add('GET', '/drive/orders', [CounterOrderController::class, 'index']); + $router->add('GET', '/drive/orders/new', [CounterOrderController::class, 'create']); + $router->add('POST', '/drive/orders', [CounterOrderController::class, 'store']); + + // Gestion des comptes (mlt domaine 10). user.read (liste) ; user.create/update/ + // deactivate. TOUTES les mutations = PIN equipier + audit (RG-T13/14). {id} = un + // seul segment (pas de collision avec /edit, /deactivate, /reset-pin, /erase). + $router->add('GET', '/admin/users', [UserController::class, 'index']); + $router->add('GET', '/admin/users/new', [UserController::class, 'create']); + $router->add('POST', '/admin/users', [UserController::class, 'store']); + $router->add('GET', '/admin/users/{id}/edit', [UserController::class, 'edit']); + $router->add('POST', '/admin/users/{id}', [UserController::class, 'update']); + $router->add('GET', '/admin/users/{id}/deactivate', [UserController::class, 'confirmDeactivate']); + $router->add('POST', '/admin/users/{id}/deactivate', [UserController::class, 'deactivate']); + $router->add('GET', '/admin/users/{id}/reset-pin', [UserController::class, 'confirmResetPin']); + $router->add('POST', '/admin/users/{id}/reset-pin', [UserController::class, 'resetPin']); + $router->add('GET', '/admin/users/{id}/erase', [UserController::class, 'confirmErase']); + $router->add('POST', '/admin/users/{id}/erase', [UserController::class, 'erase']); + + // RBAC (mlt 10.4, role.manage) : matrice roles x permissions + roles custom. + // Toute mutation = PIN equipier + audit (details = diff de permissions, RG-6). + $router->add('GET', '/admin/roles', [RoleController::class, 'index']); + $router->add('GET', '/admin/roles/new', [RoleController::class, 'create']); + $router->add('POST', '/admin/roles', [RoleController::class, 'store']); + $router->add('GET', '/admin/roles/{id}/edit', [RoleController::class, 'edit']); + $router->add('POST', '/admin/roles/{id}', [RoleController::class, 'update']); + + // CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active. + $router->add('GET', '/admin/categories', [CategoryController::class, 'index']); + $router->add('GET', '/admin/categories/new', [CategoryController::class, 'create']); + $router->add('POST', '/admin/categories', [CategoryController::class, 'store']); + $router->add('GET', '/admin/categories/{id}/edit', [CategoryController::class, 'edit']); + $router->add('POST', '/admin/categories/{id}', [CategoryController::class, 'update']); + $router->add('POST', '/admin/categories/{id}/toggle', [CategoryController::class, 'toggle']); + + // Profil self-service : definition du PIN d'action sensible (RG-T13). + $router->add('GET', '/admin/profile/pin', [ProfileController::class, 'showPin']); + $router->add('POST', '/admin/profile/pin', [ProfileController::class, 'updatePin']); + + // Mention d'information RGPD (Cr 3.d.2) : traitement des donnees personnelles du + // personnel. Accessible a tout utilisateur authentifie (aucune permission requise). + $router->add('GET', '/admin/privacy', [PrivacyController::class, 'index']); + + // CRUD Produits (product.read/create/update/delete). PIN equipier + audit sur + // changement prix/TVA (update) et suppression (delete). + $router->add('GET', '/admin/products', [ProductController::class, 'index']); + $router->add('GET', '/admin/products/new', [ProductController::class, 'create']); + $router->add('POST', '/admin/products', [ProductController::class, 'store']); + $router->add('GET', '/admin/products/{id}/edit', [ProductController::class, 'edit']); + $router->add('POST', '/admin/products/{id}', [ProductController::class, 'update']); + $router->add('GET', '/admin/products/{id}/delete', [ProductController::class, 'confirmDelete']); + $router->add('POST', '/admin/products/{id}/delete', [ProductController::class, 'destroy']); + // Editeur de recette (composition product_ingredient). Permission ingredient.manage + // (composition), distincte du CRUD produit ; sans PIN. Debloque la dispo calculee + // RG-T21 et ferme la dette #27 (trace cascade a la suppression). + $router->add('GET', '/admin/products/{id}/recipe', [ProductController::class, 'recipeForm']); + $router->add('POST', '/admin/products/{id}/recipe', [ProductController::class, 'saveRecipe']); + + // CRUD Menus (menu.read/create/update/delete). Menu compose = burger de base + + // slots (menu_slot / menu_slot_option). PIN equipier + audit sur suppression + // (mlt 8.6) ; create/update sans PIN. {id} = un seul segment, pas de collision + // avec /toggle ni /delete. + $router->add('GET', '/admin/menus', [MenuController::class, 'index']); + $router->add('GET', '/admin/menus/new', [MenuController::class, 'create']); + $router->add('POST', '/admin/menus', [MenuController::class, 'store']); + $router->add('GET', '/admin/menus/{id}/edit', [MenuController::class, 'edit']); + $router->add('POST', '/admin/menus/{id}', [MenuController::class, 'update']); + $router->add('POST', '/admin/menus/{id}/toggle', [MenuController::class, 'toggle']); + $router->add('GET', '/admin/menus/{id}/delete', [MenuController::class, 'confirmDelete']); + $router->add('POST', '/admin/menus/{id}/delete', [MenuController::class, 'destroy']); + + // Stock / Ingredients (P3, mlt 8.8 + domaine 9). Permissions par operation : + // stock.read (liste/mouvements, tous roles) ; ingredient.manage (CRUD, sans PIN) ; + // stock.manage (reappro, sans PIN) ; stock.count (inventaire, + PIN). Pas d'audit_log + // (RG-T14) : l'attribution passe par stock_movement.user_id. + $router->add('GET', '/admin/ingredients', [IngredientController::class, 'index']); + $router->add('GET', '/admin/ingredients/new', [IngredientController::class, 'create']); + $router->add('POST', '/admin/ingredients', [IngredientController::class, 'store']); + $router->add('GET', '/admin/ingredients/{id}/edit', [IngredientController::class, 'edit']); + $router->add('POST', '/admin/ingredients/{id}', [IngredientController::class, 'update']); + $router->add('POST', '/admin/ingredients/{id}/toggle', [IngredientController::class, 'toggle']); + $router->add('GET', '/admin/ingredients/{id}/delete', [IngredientController::class, 'confirmDelete']); + $router->add('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']); + $router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']); + $router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']); + $router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']); + $router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']); + $router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']); + // Enrichissement nutritionnel depuis une API externe (OpenFoodFacts, Cr 3.a.3) : + // action explicite ingredient.manage, POST + CSRF, opt-in (pas d'egress automatique). + $router->add('POST', '/admin/ingredients/{id}/enrich', [IngredientController::class, 'enrich']); + + // CORS (docs/api/conventions.md section 10) : preflight OPTIONS traite AVANT le + // routeur (pas de route OPTIONS) ; sinon dispatch puis decoration de la reponse. + // Scope /api/ + origine exacte geres par le middleware (fail-closed). $request et + // $cors sont construits hors du try pour que le catch puisse decorer aussi le 500. + $preflight = $cors->preflightResponse($request); + if ($preflight !== null) { + $preflight->send(); + } else { + $response = $router->dispatch($request); + $cors->applyTo($request, $response); + $response->send(); + } +} catch (Throwable $exception) { + // En debug on remonte le message pour iterer ; en prod, reponse generique + // pour ne rien divulguer de la pile interne (information disclosure). + $payload = $config->isDebug() + ? ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => $exception->getMessage()]] + : ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => 'Internal server error']]; + + // Decore aussi la 500 : une requete /api/ cross-origin (ex. BDD indisponible) + // doit rester lisible par le navigateur de la borne (RG enveloppe d'erreur). + $errorResponse = (new Response())->json($payload, 500); + $cors->applyTo($request, $errorResponse); + $errorResponse->send(); +} diff --git a/src/public/admin/login.html b/src/public/admin/login.html deleted file mode 100644 index e9107c6..0000000 --- a/src/public/admin/login.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - Connexion — Wakdo Admin - - - - - - diff --git a/src/public/admin/users.html b/src/public/admin/users.html deleted file mode 100644 index 1f8c1be..0000000 --- a/src/public/admin/users.html +++ /dev/null @@ -1,296 +0,0 @@ - - - - - - Utilisateurs — Wakdo Admin - - - -
- - -
- - -
-
- - -
-
-
- - - - - -
- - -
-
-
- - -
- - -
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Nom / Email Role StatutDerniere connexion
-
-
CJ
-
-
Corentin Jog
-
corentin@wakdo.fr
-
-
-
AdministrateurActif09/05/2026 13:42 -
- -
- Modifier - -
- -
-
-
-
-
ML
-
-
Marie Laurent
-
marie.laurent@wakdo.fr
-
-
-
ManagerActif09/05/2026 10:15 -
- -
- Modifier - -
- -
-
-
-
-
AD
-
-
Ahmed Diallo
-
ahmed.diallo@wakdo.fr
-
-
-
PreparationActif09/05/2026 11:00 -
- -
- Modifier - -
- -
-
-
-
-
SP
-
-
Sophie Petit
-
sophie.petit@wakdo.fr
-
-
-
AccueilActif09/05/2026 09:58 -
- -
- Modifier - -
- -
-
-
-
-
TM
-
-
Thomas Martin
-
thomas.martin@wakdo.fr
-
-
-
PreparationInactif02/04/2026 17:30 -
- -
- Modifier - -
- -
-
-
-
- -
-
-
- - - - diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css index 5f11afb..ba4e07b 100644 --- a/src/public/borne/assets/css/style.css +++ b/src/public/borne/assets/css/style.css @@ -12,8 +12,11 @@ * - Card border : 2px solid #FFC72C when selected / active * * Kiosk target: 1080x1920 portrait (touch screen). - * Font stack: system-ui fallback — school font is not available as a web asset. - * OpenDys is loaded conditionally for accessibility (RGAA Cr 1.c.4). + * Base font stack: system-ui fallback (the school font is not available as a web asset). + * Accessibility (RGAA Cr 1.c.2): the OpenDyslexic font (OFL 1.1) is self-hosted under + * assets/fonts (see @font-face in section 12). a11y.js adds the .dys-font class on + * on demand, which redefines --font-family-base for the whole interface, and + * persists the choice in localStorage. Base stack is the default. */ /* ============================================================ @@ -271,9 +274,12 @@ button { background: var(--color-bg-card); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); padding: var(--space-4) var(--space-6); - display: flex; + /* Grille 3 colonnes : le logo (colonne centrale auto) reste centre quelles que + soient les largeurs du bouton retour (gauche) et du badge/panier (droite). + En flex + space-between, le logo derivait selon ces largeurs (ex: panier). */ + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - justify-content: space-between; /* Sticky so the logo stays visible while user scrolls categories */ position: sticky; top: 0; @@ -283,10 +289,17 @@ button { .site-header__logo { height: 56px; width: auto; + justify-self: center; +} + +.site-header__cart, +.site-header__mode { + justify-self: end; } .site-header__back { display: inline-flex; + justify-self: start; align-items: center; gap: var(--space-2); font-size: var(--font-size-sm); @@ -1417,8 +1430,8 @@ button { /* Selected state — mirrors aria-pressed=true */ .composer-card--selected { - border-color: var(--color-border-active); - box-shadow: 0 0 0 3px rgba(255, 199, 44, 0.45), var(--shadow-card); + border-color: var(--color-brand-yellow-dk); /* jaune fonce : contraste a11y 1.4.11 */ + box-shadow: 0 0 0 3px rgba(230, 168, 0, 0.55), var(--shadow-card); background: rgba(255, 199, 44, 0.06); } @@ -1437,6 +1450,122 @@ button { line-height: 1.3; } +/* ============================================================ + * Allergenes — bouton "i" + modale generale (14 INCO) + * Info reglementaire generale (pas un calcul par produit). La modale reutilise + * le pattern overlay du composer ; z-index 220 pour passer au-dessus de lui. + * ============================================================ */ + +/* Le bouton "i" se superpose au coin de l'image de la carte. */ +.product-card__image-wrap { + position: relative; +} + +.allergen-info-btn { + width: 2rem; + height: 2rem; + border-radius: 50%; + border: 2px solid var(--color-brand-dark); + background: var(--color-bg-card); + color: var(--color-brand-dark); + font-weight: var(--font-weight-bold); + font-style: italic; + font-size: var(--font-size-base); + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-overlay); +} + +.product-card__image-wrap .allergen-info-btn { + position: absolute; + top: var(--space-2); + right: var(--space-2); + z-index: 2; +} + +.product-detail__info .allergen-info-btn { + margin-top: var(--space-3); +} + +.allergen-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 220; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); + animation: composer-fade-in var(--transition-base) both; +} + +.allergen-modal { + position: relative; + background: var(--color-bg-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-overlay); + width: 100%; + max-width: 640px; + max-height: 90vh; + overflow-y: auto; + padding: var(--space-6); + animation: composer-slide-up var(--transition-base) both; +} + +.allergen-modal-close { + position: absolute; + top: var(--space-3); + right: var(--space-3); + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + border: none; + background: var(--color-bg-page); + color: var(--color-text-primary); + font-size: var(--font-size-md); + cursor: pointer; +} + +.allergen-modal-title { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin-bottom: var(--space-2); +} + +.allergen-modal-intro { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + margin-bottom: var(--space-4); +} + +.allergen-modal-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-2); +} + +.allergen-modal-list li { + padding: var(--space-2) var(--space-3); + background: var(--color-bg-page); + border-radius: var(--radius-md); +} + +.allergen-name { + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); +} + +.allergen-desc { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + .composer-card__price { font-size: var(--font-size-sm); color: var(--color-text-muted); @@ -1683,3 +1812,450 @@ button { margin-right: 0; } } + +/* === Panneau de commande persistant (L1 - maquette : recap a droite) ======= + Layout deux colonnes sur les ecrans de commande : contenu a gauche, panneau + a droite, sticky pleine hauteur. Le panneau = entete (titre + mode), corps + scrollable (lignes du panier), pied (total + Abandon/Payer). Reutilise les + tokens de :root pour rester aligne sur la charte. */ +.order-layout { + display: flex; + align-items: flex-start; + gap: var(--space-5); + padding: 0 var(--space-5) var(--space-5); +} + +.order-layout > main { + flex: 1 1 auto; + min-width: 0; /* laisse la grille produits se retrecir au lieu de deborder */ +} + +.order-panel { + flex: 0 0 360px; + width: 360px; + position: sticky; + top: var(--space-4); + max-height: calc(100vh - var(--space-6)); + display: flex; + flex-direction: column; + background: var(--color-bg-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.order-panel__head { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--color-border-default); +} + +.order-panel__logo { + height: 22px; + width: auto; +} + +.order-panel__title { + font-size: var(--font-size-md); + font-weight: var(--font-weight-bold); +} + +.order-panel__mode { + margin-left: auto; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + background: var(--color-bg-page); + border-radius: var(--radius-pill); + padding: var(--space-1) var(--space-3); +} + +.order-panel__body { + flex: 1 1 auto; + overflow-y: auto; + padding: var(--space-3) var(--space-4); +} + +.order-panel__empty { + color: var(--color-text-muted); + text-align: center; + padding: var(--space-6) var(--space-3); + line-height: 1.5; +} + +.order-panel__lines { + list-style: none; + margin: 0; + padding: 0; +} + +.order-panel__line { + position: relative; + padding: var(--space-3) var(--space-6) var(--space-3) 0; + border-bottom: 1px solid var(--color-border-default); +} + +.order-panel__line:last-child { + border-bottom: none; +} + +.order-panel__line-main { + display: flex; + justify-content: space-between; + gap: var(--space-2); + font-weight: var(--font-weight-bold); +} + +.order-panel__line-price { + white-space: nowrap; +} + +.order-panel__options { + list-style: none; + margin: var(--space-1) 0 0; + padding: 0; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.order-panel__options li::before { + content: "+ "; +} + +.order-panel__remove { + position: absolute; + top: var(--space-3); + right: 0; + border: none; + background: none; + cursor: pointer; + padding: var(--space-1); + line-height: 0; +} + +.order-panel__foot { + border-top: 1px solid var(--color-border-default); + padding: var(--space-4) var(--space-5); +} + +.order-panel__total { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: var(--font-size-md); + margin-bottom: var(--space-4); +} + +.order-panel__total-value { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); +} + +.order-panel__actions { + display: flex; + gap: var(--space-3); +} + +.order-panel__abandon, +.order-panel__pay { + flex: 1 1 0; + text-align: center; + border-radius: var(--radius-pill); + padding: var(--space-3) var(--space-4); + font-weight: var(--font-weight-bold); + font-size: var(--font-size-base); + cursor: pointer; + text-decoration: none; +} + +.order-panel__abandon { + background: var(--color-bg-card); + border: 1px solid var(--color-border-default); + color: var(--color-text-secondary); +} + +.order-panel__pay { + background: var(--color-brand-yellow); + border: 1px solid var(--color-brand-yellow); + color: var(--color-text-primary); +} + +.order-panel__pay[aria-disabled="true"] { + opacity: 0.5; + pointer-events: none; +} + +/* Ecran etroit : le panneau passe sous le contenu (le kiosk reste large en + pratique ; repli de surete pour les petits viewports). */ +@media (max-width: 900px) { + .order-layout { + flex-direction: column; + } + + .order-panel { + width: auto; + flex-basis: auto; + position: static; + max-height: none; + align-self: stretch; + } +} + +/* === Bandeau categories (L1 - maquette : strip horizontal en haut) ========= + Cartes categorie defilantes avec fleches rouges ; la categorie courante porte + une bordure jaune (charte maquette). Sticky en haut du contenu de commande. */ +.category-strip { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4) 0; + position: sticky; + top: 0; + background: var(--color-bg-page); + z-index: 5; +} + +.category-strip__scroller { + display: flex; + gap: var(--space-3); + overflow-x: auto; + scroll-behavior: smooth; + flex: 1 1 auto; + scrollbar-width: none; /* Firefox : masque la scrollbar, on navigue aux fleches */ +} + +.category-strip__scroller::-webkit-scrollbar { + display: none; +} + +.category-strip__item { + flex: 0 0 auto; + width: 110px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + padding: var(--space-2); + background: var(--color-bg-card); + border: 2px solid transparent; + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + text-decoration: none; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + text-align: center; +} + +.category-strip__item.is-active { + border-color: var(--color-brand-yellow-dk); + border-width: 3px; + background: #FFF8E6; /* 2e cue (pas que la couleur de bordure) -- a11y 1.4.11 */ +} + +.category-strip__img { + width: 56px; + height: 56px; + object-fit: contain; +} + +.category-strip__label { + font-weight: var(--font-weight-bold); +} + +.category-strip__arrow { + flex: 0 0 auto; + border: none; + background: none; + color: var(--color-brand-red); + font-size: var(--font-size-xl); + line-height: 1; + padding: var(--space-2); + cursor: pointer; +} + +/* Focus clavier visible sur les controles L1 (panneau + bandeau). Outline jaune + fonce decale pour rester percevable par-dessus la charte. */ +.order-panel__pay:focus-visible, +.order-panel__abandon:focus-visible, +.order-panel__remove:focus-visible, +.category-strip__item:focus-visible, +.category-strip__arrow:focus-visible { + outline: 3px solid var(--color-brand-yellow-dk); + outline-offset: 2px; +} + +/* === Modale d'options produit (L3 - quantite + ajout, au-dessus de la grille) === */ +.product-options { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-4); + text-align: center; +} + +.product-options__image { + width: 160px; + height: 160px; + object-fit: contain; +} + +.product-options__unit { + color: var(--color-text-secondary); +} + +/* Picker de taille (R4 : 30/50 cl) : boutons-pills, l'actif borde par l'accent. */ +.product-options__sizes { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; + justify-content: center; +} + +.size-btn { + min-width: 72px; + padding: var(--space-2) var(--space-3); + border: 2px solid var(--color-border-default); + border-radius: var(--radius-pill); + background: var(--color-bg-card); + color: var(--color-text-primary); + font-size: var(--font-size-md); + cursor: pointer; +} + +.size-btn--selected { + border-color: var(--color-border-active); + font-weight: 700; +} + +.product-options__total { + font-size: var(--font-size-md); +} + +.qty-control { + display: flex; + align-items: center; + gap: var(--space-4); +} + +/* === Modale chevalet (sur-place, L4) + erreur paiement === */ +.chevalet__hint { + color: var(--color-text-secondary); + text-align: center; + margin-bottom: var(--space-4); +} + +.chevalet__input { + display: block; + width: 100%; + max-width: 220px; + margin: 0 auto; + padding: var(--space-3); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + text-align: center; + letter-spacing: 0.2em; + border: 2px solid var(--color-border-default); + border-radius: var(--radius-md); +} + +.chevalet__input:focus-visible { + outline: 3px solid var(--color-brand-yellow-dk); + outline-offset: 2px; +} + +.chevalet__error { + color: var(--color-brand-red); + text-align: center; + margin-top: var(--space-3); +} + +.payment-error { + color: var(--color-brand-red); + font-weight: var(--font-weight-bold); + text-align: center; + margin: var(--space-4) 0; +} + +/* ============================================================ + 12. ACCESSIBILITY — dyslexia-friendly font + toggle (RGAA Cr 1.c.2) + ============================================================ */ + +/* OpenDyslexic (OFL 1.1), self-hosted under assets/fonts (see LICENSE-OpenDyslexic.txt). + font-display: swap keeps text visible while the face downloads. */ +@font-face { + font-family: "OpenDyslexic"; + src: url("../fonts/opendyslexic-latin-400-normal.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "OpenDyslexic"; + src: url("../fonts/opendyslexic-latin-700-normal.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +/* a11y.js sets .dys-font on ; the whole interface then inherits the + dyslexia-friendly stack via --font-family-base. Higher specificity than :root, + so it overrides the base token. */ +html.dys-font { + --font-family-base: "OpenDyslexic", system-ui, -apple-system, "Segoe UI", Arial, sans-serif; +} + +/* Fixed accessibility control, present on every screen (injected by a11y.js). */ +/* Coin bas-gauche : libre du bouton Retour (haut-gauche), du panier (haut-droite) + et du panneau commande sticky (flanc droit). En portrait, le bas reste en zone + de pouce, donc atteignable. */ +.a11y-toggle { + position: fixed; + bottom: var(--space-3); + left: var(--space-3); + z-index: 1000; + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--color-bg-card); + color: var(--color-text-primary); + border: 2px solid var(--color-border-default); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-card); + font-family: var(--font-family-base); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + cursor: pointer; +} + +.a11y-toggle__icon { + font-size: var(--font-size-md); + line-height: 1; +} + +/* Active state: not signalled by colour alone (RGAA 1.4.1) — aria-pressed exposes + the state to assistive tech, and the label text stays visible. */ +.a11y-toggle[aria-pressed="true"] { + background: var(--color-brand-yellow); + border-color: var(--color-brand-yellow-dk); +} + +.a11y-toggle:focus-visible { + outline: 3px solid var(--color-brand-yellow-dk); + outline-offset: 2px; +} + +/* ============================================================ + 13. COMPATIBILITE NAVIGATEURS (Cr 1.b.3) + ============================================================ */ + +/* 'gap' sur conteneurs flex : non reconnu par d'anciens navigateurs (ex. Safari + anterieur a 14.1). La detection @supports applique un repli par marges sur les + enfants directs si la propriete n'est pas supportee (alternative documentee). */ +@supports not (gap: 1rem) { + .welcome__choices > *, + .composer-footer__row > * { + margin: var(--space-2); + } +} diff --git a/src/public/borne/assets/fonts/LICENSE-OpenDyslexic.txt b/src/public/borne/assets/fonts/LICENSE-OpenDyslexic.txt new file mode 100644 index 0000000..8ee9c14 --- /dev/null +++ b/src/public/borne/assets/fonts/LICENSE-OpenDyslexic.txt @@ -0,0 +1,21 @@ +OpenDyslexic +============ + +Files: + opendyslexic-latin-400-normal.woff2 + opendyslexic-latin-700-normal.woff2 + +Copyright (c) Abelardo Gonzalez, with Reserved Font Name "OpenDyslexic". + +OpenDyslexic is licensed under the SIL Open Font License, Version 1.1 (OFL-1.1). +This license is available with a FAQ at: https://openfontlicense.org + +These web font files (latin subset, woff2) were obtained from the npm package +@fontsource/opendyslexic (jsDelivr mirror) and are redistributed here unmodified, +as permitted by the OFL, to be served self-hosted by the Wakdo kiosk for the +dyslexia-friendly display option (RGAA Cr 1.c.2). + +Under the OFL: the font may be used, studied, modified and redistributed freely +as long as it is not sold by itself; redistributions must retain this notice and +the license; and the Reserved Font Name may not be used to promote derivative +fonts. The full license text accompanies the upstream font package. diff --git a/src/public/borne/assets/fonts/opendyslexic-latin-400-normal.woff2 b/src/public/borne/assets/fonts/opendyslexic-latin-400-normal.woff2 new file mode 100644 index 0000000..47e26d8 Binary files /dev/null and b/src/public/borne/assets/fonts/opendyslexic-latin-400-normal.woff2 differ diff --git a/src/public/borne/assets/fonts/opendyslexic-latin-700-normal.woff2 b/src/public/borne/assets/fonts/opendyslexic-latin-700-normal.woff2 new file mode 100644 index 0000000..2f04ad1 Binary files /dev/null and b/src/public/borne/assets/fonts/opendyslexic-latin-700-normal.woff2 differ diff --git a/src/public/borne/assets/images/favicon.svg b/src/public/borne/assets/images/favicon.svg new file mode 100644 index 0000000..fa27a82 --- /dev/null +++ b/src/public/borne/assets/images/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/public/borne/assets/js/a11y.js b/src/public/borne/assets/js/a11y.js new file mode 100644 index 0000000..384744e --- /dev/null +++ b/src/public/borne/assets/js/a11y.js @@ -0,0 +1,131 @@ +/* + * a11y.js — Bascule de police adaptee aux personnes dyslexiques (front borne). + * + * Accessibilite RGAA Cr 1.c.2 : une police specifique pour les personnes + * dyslexiques est prevue ET integree. La police OpenDyslexic (OFL 1.1) est + * auto-hebergee (assets/fonts, @font-face dans style.css). Ce module ajoute un + * bouton fixe present sur chaque ecran : au clic, il pose la classe .dys-font sur + * (qui redefinit --font-family-base, applique a tout le texte) et persiste + * le choix dans localStorage pour le conserver d'un ecran a l'autre. + * + * CSP 'self' : aucun script inline, aucun handler inline. Le DOM est construit par + * l'API (createElement/textContent). Les fonctions sont exportees pour etre testees + * sans navigateur (jsdom) ; l'auto-init au chargement est gardee pour ne pas + * s'executer a l'import en environnement de test (document absent a ce moment-la). + */ + +export const STORAGE_KEY = 'wakdo_dyslexia_font'; +export const ROOT_CLASS = 'dys-font'; +const TOGGLE_SELECTOR = '[data-a11y-dys-toggle]'; + +/** + * Lit la preference persistee. Tolere l'absence de storage (mode prive, quota) : + * toute erreur d'acces renvoie false (police de base par defaut). + * @param {Storage|null} storage + * @returns {boolean} + */ +export function isDyslexiaEnabled(storage) { + try { + return storage != null && storage.getItem(STORAGE_KEY) === '1'; + } catch { + return false; + } +} + +/** + * Applique (ou retire) la classe .dys-font sur l'element racine fourni. + * @param {boolean} enabled + * @param {HTMLElement} root typiquement document.documentElement + */ +export function applyDyslexiaPreference(enabled, root) { + if (root && root.classList) { + root.classList.toggle(ROOT_CLASS, Boolean(enabled)); + } +} + +/** + * Persiste la preference. Silencieux si le storage est indisponible. + * @param {boolean} enabled + * @param {Storage|null} storage + */ +export function persistDyslexiaPreference(enabled, storage) { + try { + if (storage != null) { + storage.setItem(STORAGE_KEY, enabled ? '1' : '0'); + } + } catch { + /* storage indisponible : la preference reste valable pour la session en cours */ + } +} + +/** + * Construit le bouton de bascule (aria-pressed reflete l'etat). `onToggle` est + * appele au clic avec le nouvel etat booleen ; l'appelant persiste et applique. + * @param {boolean} initialEnabled + * @param {(next: boolean) => void} onToggle + * @returns {HTMLButtonElement} + */ +export function buildDyslexiaToggle(initialEnabled, onToggle) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'a11y-toggle'; + btn.setAttribute('data-a11y-dys-toggle', ''); + btn.setAttribute('aria-pressed', initialEnabled ? 'true' : 'false'); + // Libelle neutre (decrit le controle, pas l'action) : reste correct dans les + // deux etats ; l'etat actif/inactif est porte par aria-pressed. + btn.setAttribute('aria-label', 'Police adaptee aux personnes dyslexiques'); + + const icon = document.createElement('span'); + icon.className = 'a11y-toggle__icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = 'Aa'; + btn.appendChild(icon); + + const label = document.createElement('span'); + label.className = 'a11y-toggle__label'; + label.textContent = 'Police adaptee'; + btn.appendChild(label); + + btn.addEventListener('click', () => { + const next = btn.getAttribute('aria-pressed') !== 'true'; + btn.setAttribute('aria-pressed', next ? 'true' : 'false'); + if (typeof onToggle === 'function') { + onToggle(next); + } + }); + + return btn; +} + +/** + * Initialise la bascule : applique la preference persistee, puis injecte le bouton + * dans le conteneur (idempotent : ne reinjecte pas si un bouton existe deja). + * @param {{storage?: Storage|null, root?: HTMLElement, container?: HTMLElement}} [options] + * @returns {HTMLButtonElement|null} le bouton injecte, ou null si deja present + */ +export function initDyslexiaToggle(options = {}) { + const storage = options.storage ?? (typeof window !== 'undefined' ? window.localStorage : null); + const root = options.root ?? (typeof document !== 'undefined' ? document.documentElement : null); + const container = options.container ?? (typeof document !== 'undefined' ? document.body : null); + + const enabled = isDyslexiaEnabled(storage); + applyDyslexiaPreference(enabled, root); + + if (!container || container.querySelector(TOGGLE_SELECTOR)) { + return null; + } + + const btn = buildDyslexiaToggle(enabled, (next) => { + applyDyslexiaPreference(next, root); + persistDyslexiaPreference(next, storage); + }); + + container.appendChild(btn); + return btn; +} + +/* Auto-init au chargement. Gardee : a l'import en test (document absent), ne + s'enregistre pas ; en navigateur, s'execute une fois le DOM pret. */ +if (typeof document !== 'undefined') { + document.addEventListener('DOMContentLoaded', () => initDyslexiaToggle()); +} diff --git a/src/public/borne/assets/js/allergens.js b/src/public/borne/assets/js/allergens.js new file mode 100644 index 0000000..62f1244 --- /dev/null +++ b/src/public/borne/assets/js/allergens.js @@ -0,0 +1,132 @@ +/* + * allergens.js — Modale GENERALE d'information allergenes (front borne). + * + * Information reglementaire (UE INCO 1169/2011) presentee au client : la liste + * des 14 allergenes a declaration obligatoire. C'est une info GENERALE (pas un + * calcul par produit) ; le mapping ingredient_allergen par produit reste differe. + * + * CSP 'self' : aucun script inline, aucun handler inline. Le DOM est construit par + * l'API (createElement/textContent) ; textContent neutralise toute injection. + * Les donnees viennent de data.js (loadAllergens) : liste fixe en P5, /api/allergens + * au swap P4. openAllergenModal prend la liste en parametre pour rester independant + * de la couche de chargement (et testable sans fetch). + */ + +const OVERLAY_CLASS = 'allergen-modal-overlay'; + +/* Reference stable du handler clavier pour pouvoir le retirer a la fermeture. */ +function onKeydown(event) { + if (event.key === 'Escape') { + closeAllergenModal(); + } +} + +/** + * Construit le bouton "i" qui ouvre la modale. `onOpen` est appele au clic ; + * la propagation est stoppee pour ne pas declencher le clic de la carte produit + * (sur la carte, le bouton est superpose a une zone cliquable). + * @param {() => void} onOpen + * @returns {HTMLButtonElement} + */ +export function buildAllergenInfoButton(onOpen) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'allergen-info-btn'; + btn.setAttribute('aria-label', 'Informations allergenes'); + btn.title = 'Informations allergenes'; + btn.textContent = 'i'; + btn.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (typeof onOpen === 'function') { + onOpen(); + } + }); + return btn; +} + +/** + * Ouvre la modale generale listant les allergenes fournis. Idempotent : une + * eventuelle modale ouverte est d'abord fermee (pas de doublon empile). + * @param {Array<{id:number, name:string, description?:string}>} allergens + * @returns {HTMLElement} l'overlay cree + */ +export function openAllergenModal(allergens) { + closeAllergenModal(); + + const list = Array.isArray(allergens) ? allergens : []; + + const overlay = document.createElement('div'); + overlay.className = OVERLAY_CLASS; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Informations allergenes'); + + const modal = document.createElement('div'); + modal.className = 'allergen-modal'; + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'allergen-modal-close'; + closeBtn.setAttribute('aria-label', 'Fermer'); + closeBtn.textContent = 'x'; + closeBtn.addEventListener('click', closeAllergenModal); + modal.appendChild(closeBtn); + + const title = document.createElement('h2'); + title.className = 'allergen-modal-title'; + title.textContent = 'Allergenes'; + modal.appendChild(title); + + const intro = document.createElement('p'); + intro.className = 'allergen-modal-intro'; + intro.textContent = 'Les 14 allergenes a declaration obligatoire (reglement UE INCO 1169/2011). Pour toute question, demandez en caisse.'; + modal.appendChild(intro); + + const ul = document.createElement('ul'); + ul.className = 'allergen-modal-list'; + for (const allergen of list) { + const li = document.createElement('li'); + + const name = document.createElement('span'); + name.className = 'allergen-name'; + name.textContent = String(allergen.name ?? ''); + li.appendChild(name); + + if (allergen.description) { + const desc = document.createElement('span'); + desc.className = 'allergen-desc'; + desc.textContent = ' - ' + String(allergen.description); + li.appendChild(desc); + } + + ul.appendChild(li); + } + modal.appendChild(ul); + + overlay.appendChild(modal); + + // Clic sur le fond (hors du panneau) = fermeture ; clic dans le panneau, non. + overlay.addEventListener('click', (event) => { + if (event.target === overlay) { + closeAllergenModal(); + } + }); + + document.addEventListener('keydown', onKeydown); + document.body.appendChild(overlay); + + return overlay; +} + +/** + * Ferme la modale si elle est ouverte et retire le handler clavier. Sans effet + * si aucune modale n'est ouverte (sur appel ou Echap repete). + */ +export function closeAllergenModal() { + const existing = document.querySelector('.' + OVERLAY_CLASS); + if (existing && existing.parentNode) { + existing.parentNode.removeChild(existing); + } + document.removeEventListener('keydown', onKeydown); +} diff --git a/src/public/borne/assets/js/category-strip.js b/src/public/borne/assets/js/category-strip.js new file mode 100644 index 0000000..ad90e9d --- /dev/null +++ b/src/public/borne/assets/js/category-strip.js @@ -0,0 +1,104 @@ +/* + * category-strip.js — Bandeau categories horizontal (maquette : strip en haut de + * l'ecran de commande, avec fleches ◀ ▶ et la categorie courante surlignee). + * + * Permet de changer de categorie sans repasser par categories.html. Les categories + * viennent de /api/categories (via loadCategories de data.js : seules les categories + * actives/commandables). La logique est separee en : buildStripModel (PUR, testable) + * + renderStripInto (DOM sans fetch, testable jsdom) + renderCategoryStrip (lit l'URL, + * fetch, monte) pour l'auto-montage. + */ + +import { loadCategories } from './data.js'; +import { escHtml } from './state.js'; + +/** + * Vue-modele PUR : marque la categorie active. Aucune dependance DOM/fetch. + * L'actif est resolu par ID (donnees live de l'API), pas via une table id->slug + * codee en dur, pour rester aligne sur le catalogue reel. + * @param {Array<{id:number,title:string,slug:string,image:string}>} categories + * @param {number} activeId + * @returns {Array} + */ +export function buildStripModel(categories, activeId) { + return categories.map(c => ({ + id: Number(c.id), + slug: c.slug, + title: c.title, + image: c.image, + active: Number(c.id) === activeId, + })); +} + +/** + * Capitalise la 1re lettre (titre de categorie affiche). + * @param {string} s + * @returns {string} + */ +function cap(s) { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Rend le bandeau dans le conteneur a partir d'un modele deja construit. Pas de + * fetch : c'est la cible des tests jsdom. Chaque carte navigue vers la categorie + * (en preservant le mode). Les fleches font defiler le scroller horizontalement. + * @param {HTMLElement} container + * @param {Array} model — sortie de buildStripModel + * @param {string|null} modeParam — mode de consommation a propager dans l'URL + */ +export function renderStripInto(container, model, modeParam) { + if (!container) return; + const modeQS = modeParam ? `&mode=${encodeURIComponent(modeParam)}` : ''; + + const cards = model.map(c => ` + + + ${escHtml(cap(c.title))} + + `).join(''); + + container.innerHTML = ` + +
${cards}
+ + `; + + const scroller = container.querySelector('.category-strip__scroller'); + const step = 320; + const prev = container.querySelector('.category-strip__arrow--prev'); + const next = container.querySelector('.category-strip__arrow--next'); + // scrollBy/scrollIntoView absents de jsdom -> gardes pour ne pas jeter en test. + if (prev) prev.addEventListener('click', () => scroller.scrollBy?.({ left: -step, behavior: 'smooth' })); + if (next) next.addEventListener('click', () => scroller.scrollBy?.({ left: step, behavior: 'smooth' })); + const active = scroller.querySelector('.is-active'); + active?.scrollIntoView?.({ inline: 'center', block: 'nearest' }); +} + +/** + * Monte le bandeau : lit ?category=&mode= dans l'URL, charge les categories, + * construit le modele et rend. Tolerant a un echec de chargement (ne casse pas la page). + * @param {HTMLElement} container + */ +export async function renderCategoryStrip(container) { + if (!container) return; + const params = new URLSearchParams(window.location.search); + const activeId = parseInt(params.get('category'), 10) || 1; + const modeParam = params.get('mode'); + try { + const categories = await loadCategories(); + renderStripInto(container, buildStripModel(categories, activeId), modeParam); + } catch (e) { + console.error('renderCategoryStrip error:', e); + } +} + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('[data-category-strip]').forEach(renderCategoryStrip); +}); diff --git a/src/public/borne/assets/js/checkout.js b/src/public/borne/assets/js/checkout.js new file mode 100644 index 0000000..b758e3a --- /dev/null +++ b/src/public/borne/assets/js/checkout.js @@ -0,0 +1,161 @@ +/* + * checkout.js — Soumission reelle de la commande a l'API (P5 L4). + * + * Avant : payment.html simulait (redirection directe vers confirmation). Desormais + * le panier est traduit vers le contrat /api/orders et POSTe (creation pending_payment + * puis encaissement -> paid + decrement stock RG-T20). + * + * Traduction panier borne -> contrat API : + * - produit simple -> { type:'product', product_id, quantity } + * - menu -> { type:'menu', menu_id, quantity, format, selections } + * format = 'maxi' si supplement_cents>0, sinon 'normal'. + * 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'. + * - service_tag (numero de chevalet) : requis en dine_in. + * + * Les fonctions de traduction sont PURES (testables) ; submitOrder fait les I/O. + */ + +import { getCart, getMode } from './state.js'; +import { loadMenu } from './data.js'; + +const MODE_MAP = { 'sur-place': 'dine_in', 'a-emporter': 'takeaway' }; + +/** + * Mode de consommation borne -> service_mode du contrat API. null si inconnu. + * @param {string|null} mode + * @returns {string|null} + */ +export function mapServiceMode(mode) { + return MODE_MAP[mode] ?? null; +} + +/** + * Reconstruit les selections [{menu_slot_id, product_id}] d'un menu a partir de sa + * composition (produits choisis) et de ses slots reels (option_product_ids). Pur. + * Un produit choisi est rattache au slot dont les options le contiennent. + * @param {Object|undefined} composition + * @param {Array<{id:number, option_product_ids:number[]}>} slots + * @returns {Array<{menu_slot_id:number, product_id:number}>} + */ +export function buildSelections(composition, slots) { + const out = []; + if (!composition) return out; + const chosenIds = ['accompagnement', 'boisson', 'sauce'] + .map(k => composition[k]?.id) + .filter(id => id != null); + for (const pid of chosenIds) { + const slot = (slots || []).find(s => (s.option_product_ids || []).includes(pid)); + if (slot) out.push({ menu_slot_id: slot.id, product_id: pid }); + } + return out; +} + +/** + * Traduit une ligne de panier en item du contrat API. Pur. + * @param {Object} cartItem + * @param {Object} menuSlotsById — slots par id de menu (pour les menus) + * @returns {Object} + */ +export function buildOrderItem(cartItem, menuSlotsById) { + if (cartItem.type === 'menu') { + return { + type: 'menu', + menu_id: cartItem.id, + quantity: cartItem.quantite, + format: (cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal', + selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []), + }; + } + return { type: 'product', product_id: cartItem.id, quantity: cartItem.quantite }; +} + +/** + * Construit la charge utile complete du POST /api/orders. Pur. + * service_tag n'est inclus qu'en dine_in. + * @returns {Object} + */ +export function buildOrderPayload(cart, mode, serviceTag, menuSlotsById, idempotencyKey) { + const serviceMode = mapServiceMode(mode); + const payload = { + idempotency_key: idempotencyKey, + service_mode: serviceMode, + items: cart.map(it => buildOrderItem(it, menuSlotsById)), + }; + if (serviceMode === 'dine_in') { + payload.service_tag = serviceTag; + } + return payload; +} + +/** Cle d'idempotence brute : crypto.randomUUID si dispo, repli horodate. */ +function newIdempotencyKey() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID(); + return `k-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +/** + * Cle d'idempotence STABLE pour la tentative de paiement courante : memorisee en + * sessionStorage. Un retry (apres echec reseau du pay) reutilise la MEME cle -> + * l'API renvoie la commande pending existante (findByIdempotencyKey) au lieu d'en + * creer un doublon (RG-T19). Effacee au succes ; regeneree a chaque entree sur la + * page de paiement (page-payment.js efface la cle au chargement). + */ +function checkoutKey() { + try { + let k = sessionStorage.getItem('wakdo_order_key'); + if (!k) { + k = newIdempotencyKey(); + sessionStorage.setItem('wakdo_order_key', k); + } + return k; + } catch { + return newIdempotencyKey(); + } +} + +/** POST JSON avec enveloppe ; jette une Error(code) en cas d'echec, payload attache. */ +async function postJson(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const json = await res.json().catch(() => null); + if (!res.ok) { + const err = new Error(json?.error?.code || `HTTP ${res.status}`); + err.payload = json; + throw err; + } + return json; +} + +/** + * Soumet la commande : re-fetch les slots des menus du panier, construit la charge, + * POST /api/orders (creation) puis POST /api/orders/{number}/pay (encaissement). + * @param {{ serviceTag?: string }} [opts] + * @returns {Promise<{order_number: string, total_ttc_cents: number|null}>} + */ +export async function submitOrder({ serviceTag = '' } = {}) { + const cart = getCart(); + if (!cart.length) throw new Error('EMPTY_CART'); + + const menuIds = [...new Set(cart.filter(i => i.type === 'menu').map(i => i.id))]; + const menuSlotsById = {}; + for (const id of menuIds) { + const detail = await loadMenu(id); + menuSlotsById[id] = detail?.slots ?? []; + } + + const payload = buildOrderPayload(cart, getMode(), serviceTag, menuSlotsById, checkoutKey()); + + const created = await postJson('/api/orders', payload); + const number = created?.data?.order_number; + if (!number) throw new Error(created?.error?.code || 'ORDER_FAILED'); + + const paid = await postJson(`/api/orders/${encodeURIComponent(number)}/pay`, {}); + // Succes : la cle d'idempotence a joue son role, on la libere pour la commande suivante. + try { sessionStorage.removeItem('wakdo_order_key'); } catch { /* sessionStorage indispo : noop */ } + return { order_number: number, total_ttc_cents: paid?.data?.total_ttc_cents ?? null }; +} diff --git a/src/public/borne/assets/js/data.js b/src/public/borne/assets/js/data.js index 38f03b1..741003b 100644 --- a/src/public/borne/assets/js/data.js +++ b/src/public/borne/assets/js/data.js @@ -1,53 +1,163 @@ /* * data.js — Data loading layer for the Wakdo kiosk. * - * P5 reads static JSON copies in /data/ (same origin). - * In P4, swap the BASE_URL constants to point to REST API endpoints. - * The function signatures and return shapes remain unchanged so that - * page scripts need no modification when the data source changes. + * Source = REST API (P4). La borne (kiosk) consomme l'API catalogue en lecture + * (docs/api/conventions.md section 5.2) : /api/categories, /api/products, /api/menus. + * Les reponses sont enveloppees ({ data: [...], total }) et en forme CANONIQUE + * (snake_case : name, price_cents, image_path...). Cette couche est le point unique + * de rapprochement (section 8.3) : elle deballe l'enveloppe et traduit vers la forme + * historique attendue par le reste de la borne (nom, prix, image, type ; objet + * indexe par slug de categorie ; menus glisses sous la cle 'menus'). Les signatures + * publiques et les formes de retour sont inchangees -> les pages n'ont pas bouge. * - * Category-to-slug mapping (mirrors data/categories.json id field): - * 1=menus 2=boissons 3=burgers 4=frites 5=encas - * 6=wraps 7=salades 8=desserts 9=sauces + * Les allergenes restent un repli statique (data/allergens.json) : leur bascule + * sur /api/allergens est un chunk ulterieur. */ -/* --- P4 swap point ------------------------------------------------------- - * TODO(P4): replace these two paths with API endpoints, e.g.: - * const CATEGORIES_URL = '/api/categories'; - * const PRODUCTS_URL = '/api/products'; - * The rest of this file is API-agnostic. - * ----------------------------------------------------------------------- */ -const CATEGORIES_URL = 'data/categories.json'; -const PRODUCTS_URL = 'data/produits.json'; +const CATEGORIES_URL = '/api/categories'; +const PRODUCTS_URL = '/api/products'; +const MENUS_URL = '/api/menus'; +/* Liste fixe des 14 allergenes INCO (info generale, modale borne). L'endpoint + * /api/allergens existe desormais (id/code/name), mais la borne garde ce JSON + * statique : il porte les DESCRIPTIONS riches, absentes du schema allergen. Bascule + * possible si les descriptions sont ajoutees cote API. */ +const ALLERGENS_URL = 'data/allergens.json'; -/** @type {Array|null} — in-memory cache to avoid repeated fetches */ -let _categoriesCache = null; - -/** @type {Object|null} */ -let _productsCache = null; +/* Memoisation par PROMESSE (pas par resultat) : N appelants concurrents au meme + * chargement partagent UNE seule requete reseau (evite les fetch /api/* redondants + * au DOMContentLoaded de products.html). Sur echec, la promesse est reinitialisee + * pour autoriser un nouvel essai. */ +let _categoriesPromise = null; +let _productsPromise = null; +let _allergensPromise = null; /** - * Fetches and caches the categories list. + * Recupere une collection enveloppee de l'API et renvoie le tableau `data`. + * @param {string} url * @returns {Promise} */ -export async function loadCategories() { - if (_categoriesCache) return _categoriesCache; - const res = await fetch(CATEGORIES_URL); - if (!res.ok) throw new Error(`Failed to load categories: HTTP ${res.status}`); - _categoriesCache = await res.json(); - return _categoriesCache; +async function fetchCollection(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to load ${url}: HTTP ${res.status}`); + const body = await res.json(); + return Array.isArray(body?.data) ? body.data : []; } /** - * Fetches and caches the full products object keyed by category slug. + * Fetches and caches the categories list (forme borne : id, title, slug, image). + * @returns {Promise} + */ +export function loadCategories() { + if (!_categoriesPromise) { + _categoriesPromise = fetchCollection(CATEGORIES_URL) + .then(rows => rows.map(c => ({ id: c.id, title: c.name, slug: c.slug, image: c.image_path }))) + .catch(e => { _categoriesPromise = null; throw e; }); + } + return _categoriesPromise; +} + +/** + * Fetches and caches the products object keyed by category slug. Les produits et + * les menus sont regroupes par slug de leur categorie (les menus tombent sous + * 'menus' via leur category_id) et ramenes a la forme borne. Le menu garde son + * prix NORMAL (le supplement Maxi est gere par le composeur cote borne). * @returns {Promise} */ -export async function loadProducts() { - if (_productsCache) return _productsCache; - const res = await fetch(PRODUCTS_URL); - if (!res.ok) throw new Error(`Failed to load products: HTTP ${res.status}`); - _productsCache = await res.json(); - return _productsCache; +export function loadProducts() { + if (_productsPromise) return _productsPromise; + + _productsPromise = Promise.all([ + loadCategories(), + fetchCollection(PRODUCTS_URL), + fetchCollection(MENUS_URL), + ]).then(([categories, products, menus]) => { + const slugByCategoryId = {}; + const bySlug = {}; + for (const cat of categories) { + slugByCategoryId[cat.id] = cat.slug; + bySlug[cat.slug] = []; + } + for (const p of products) { + const slug = slugByCategoryId[p.category_id]; + if (slug === undefined) continue; + // sizes (R4) : tailles a la carte d'une boisson (30/50 cl). Tableau vide + // si le produit n'a pas de dimension taille -> ajout direct inchange. La + // borne ne montre un picker que si sizes a plus d'une entree. + bySlug[slug].push({ + id: p.id, nom: p.name, prix: p.price_cents, image: p.image_path, type: 'produit', + // maxiNom : nom de la variante Maxi (ex. "Grande Frite") quand le produit + // 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 : [], + }); + } + 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' }); + } + return bySlug; + }).catch(e => { _productsPromise = null; throw e; }); + + return _productsPromise; +} + +/** @type {Promise|null} — index id->produit memoise (type 'produit' uniquement) */ +let _productsByIdPromise = null; + +/** + * Index des PRODUITS par id (type 'produit' seulement : exclut les menus, dont + * l'espace d'id est distinct -> pas de collision id produit/menu). Sert au composeur + * de menu (L2) pour resoudre les option_product_ids des slots /api/menus en produits + * affichables. Derive de loadProducts() : aucune requete reseau supplementaire. + * @returns {Promise>} + */ +export function loadProductsById() { + if (!_productsByIdPromise) { + _productsByIdPromise = loadProducts().then(bySlug => { + const byId = {}; + for (const slug of Object.keys(bySlug)) { + for (const item of bySlug[slug]) { + if (item.type === 'produit') byId[item.id] = item; + } + } + return byId; + }).catch(e => { _productsByIdPromise = null; throw e; }); + } + return _productsByIdPromise; +} + +/** + * Charge le detail d'un menu avec ses slots depuis GET /api/menus/{id}. Renvoie la + * forme canonique de l'API (snake_case) telle quelle : { id, burger_product_id, + * price_normal_cents, price_maxi_cents, name, image_path, slots: [{ id, name, + * slot_type, is_required, display_order, option_product_ids }] }. Le composeur (L2) + * la traduit en etapes. Pas de cache : un menu est compose ponctuellement. + * @param {number} id + * @returns {Promise} + */ +export async function loadMenu(id) { + const res = await fetch(`${MENUS_URL}/${id}`); + if (!res.ok) throw new Error(`Failed to load menu ${id}: HTTP ${res.status}`); + const body = await res.json(); + return body && typeof body === 'object' ? (body.data ?? null) : null; +} + +/** + * Fetches and caches the 14 INCO allergens (general info modal). Repli statique : + * la reponse est un tableau nu (pas d'enveloppe), conserve tel quel. + * @returns {Promise} + */ +export function loadAllergens() { + if (!_allergensPromise) { + _allergensPromise = fetch(ALLERGENS_URL) + .then(res => { + if (!res.ok) throw new Error(`Failed to load allergens: HTTP ${res.status}`); + return res.json(); + }) + .catch(e => { _allergensPromise = null; throw e; }); + } + return _allergensPromise; } /** @@ -72,13 +182,24 @@ export async function getCategoryById(id) { } /** - * Finds a product by its numeric id, searching all category slates. - * Returns null if not found. + * Finds a product/menu by id. product et menu sont deux espaces d'id DISTINCTS + * (tables auto-increment separees) : un meme id peut designer a la fois un produit + * et un menu. categorySlug (le slug de la categorie d'ou vient l'appel) leve + * l'ambiguite -- dans une categorie donnee, l'id est unique. Sans categorySlug, on + * retombe sur un scan global (best-effort, potentiellement ambigu en cas de + * collision d'id). Renvoie null si introuvable. * @param {number} id + * @param {string|null} [categorySlug] * @returns {Promise} */ -export async function findProduct(id) { +export async function findProduct(id, categorySlug = null) { const data = await loadProducts(); + + if (categorySlug !== null && Array.isArray(data[categorySlug])) { + const found = data[categorySlug].find(p => p.id === id); + return found ? { ...found, categorie: categorySlug } : null; + } + for (const slug of Object.keys(data)) { const found = data[slug].find(p => p.id === id); if (found) return { ...found, categorie: slug }; @@ -88,8 +209,8 @@ export async function findProduct(id) { /** * Maps a category id integer to its slug string. - * Derived from data/categories.json — kept here as a convenience - * so page scripts can convert query-string ids without an extra fetch. + * Derived from the seed catalogue — kept here as a convenience so page scripts can + * convert query-string ids without an extra fetch. */ export const CATEGORY_ID_TO_SLUG = { 1: 'menus', diff --git a/src/public/borne/assets/js/nav.js b/src/public/borne/assets/js/nav.js index 5b431b8..74fd5ea 100644 --- a/src/public/borne/assets/js/nav.js +++ b/src/public/borne/assets/js/nav.js @@ -6,12 +6,38 @@ * element with [data-mode-badge] on the page. * - Sync the cart item count into any element with [data-cart-count]. * - Handle the mode query-string on page load (welcome -> categories handoff). + * - Guard : une page au-dela de l'accueil EXIGE un mode de consommation. Sans + * mode (ex. localStorage vide en cours de session), la borne POSTerait + * service_mode:null et la commande est rejetee en 422. Sur une page profonde + * sans mode, on renvoie vers l'accueil pour que le mode soit (re)choisi. * * Import this module in every page that has a header. */ import { getMode, setMode, getCartCount } from './state.js'; +const VALID_MODES = ['sur-place', 'a-emporter']; + +/** Libelle humain d'un mode ; chaine vide si aucun mode valide (ne ment pas). */ +export function modeLabel(mode) { + if (mode === 'a-emporter') return 'A emporter'; + if (mode === 'sur-place') return 'Sur place'; + return ''; +} + +/** + * Faut-il renvoyer vers l'accueil ? Vrai hors de l'ecran d'accueil quand aucun + * mode de consommation valide n'est memorise. Pur (teste sans DOM). Sans cette + * garde, atteindre une page de commande sans mode mene a service_mode:null -> 422. + * @param {string} pathname window.location.pathname + * @param {string|null} mode mode memorise + * @returns {boolean} + */ +export function needsModeRedirect(pathname, mode) { + const onWelcome = pathname === '/' || pathname.endsWith('/index.html'); + return !onWelcome && !VALID_MODES.includes(mode); +} + /** * Reads ?mode= from the current URL and persists it if present. * Called once on DOMContentLoaded so that the welcome -> categories @@ -20,7 +46,7 @@ import { getMode, setMode, getCartCount } from './state.js'; function syncModeFromURL() { const params = new URLSearchParams(window.location.search); const modeParam = params.get('mode'); - if (modeParam === 'sur-place' || modeParam === 'a-emporter') { + if (VALID_MODES.includes(modeParam)) { setMode(modeParam); } } @@ -29,8 +55,7 @@ function syncModeFromURL() { * Renders the human-readable mode label into every [data-mode-badge] element. */ function renderModeBadge() { - const mode = getMode(); - const label = mode === 'a-emporter' ? 'A emporter' : 'Sur place'; + const label = modeLabel(getMode()); document.querySelectorAll('[data-mode-badge]').forEach(el => { el.textContent = label; }); @@ -48,9 +73,17 @@ export function refreshCartBadge() { }); } -/* Initialise on DOM ready */ -document.addEventListener('DOMContentLoaded', () => { - syncModeFromURL(); - renderModeBadge(); - refreshCartBadge(); -}); +/* Initialise on DOM ready. Garde derriere typeof document pour rester importable + * en test pur (node sans jsdom) : modeLabel/needsModeRedirect n'ont alors aucun effet de bord. */ +if (typeof document !== 'undefined') { + document.addEventListener('DOMContentLoaded', () => { + syncModeFromURL(); + // Mode absent sur une page profonde -> retour accueil (evite le 422 service_mode:null). + if (needsModeRedirect(window.location.pathname, getMode())) { + window.location.replace('index.html'); + return; + } + renderModeBadge(); + refreshCartBadge(); + }); +} diff --git a/src/public/borne/assets/js/order-panel.js b/src/public/borne/assets/js/order-panel.js new file mode 100644 index 0000000..ed57f00 --- /dev/null +++ b/src/public/borne/assets/js/order-panel.js @@ -0,0 +1,196 @@ +/* + * order-panel.js — Panneau de commande persistant (maquette : recap a droite de + * l'ecran de commande). Rendu sur les ecrans de commande (products, product) pour + * que le panier reste visible en permanence, comme sur la maquette borne. + * + * C'est un miroir COMPACT de page-cart.js : meme modele d'item, meme rendu de la + * composition de menu. La page panier (cart.html) reste la vue detaillee (TVA, +/-) ; + * le panneau, lui, montre lignes + total + Abandon/Payer et permet de retirer une + * ligne. La logique de mise en forme est extraite en fonctions PURES (buildPanelModel, + * compositionLabels) pour etre testable sans DOM. + */ + +import { + getCart, + removeFromCart, + computeMenuLineCents, + clearCart, + formatPrice, + escHtml, + getMode, +} from './state.js'; +import { refreshCartBadge } from './nav.js'; + +/** + * Calcule le total d'une ligne en centimes (menu : avec supplement de taille ; + * produit simple : prix * quantite). Pur. + * @param {Object} item + * @returns {number} + */ +export function lineCents(item) { + return item.type === 'menu' + ? computeMenuLineCents(item) + : item.prix_cents * item.quantite; +} + +/** + * Construit les libelles des options d'un menu (puces sous le nom de ligne). + * Miroir de renderCompositionBlock() de page-cart.js, sans le supplement (le panneau + * affiche le total de ligne, pas le detail TVA). Tolerant aux composants absents. + * @param {Object|undefined} c — objet composition de l'item menu + * @returns {string[]} + */ +export function compositionLabels(c) { + if (!c) return []; + const out = []; + if (c.burger) { + const opts = c.burger.options && c.burger.options.length + ? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})` + : ''; + out.push(`${c.burger.libelle}${opts}`); + } + // libelle fait foi : en Maxi l'accompagnement porte deja sa variante par nom + // ("Grande Frite"). Plus de suffixe " grande" -- il doublait le nom ("Grande Frite + // grande") et mentait pour la boisson (le menu Maxi ne l'agrandit pas). + if (c.accompagnement) { + out.push(c.accompagnement.libelle); + } + if (c.boisson) { + out.push(c.boisson.libelle); + } + if (c.sauce) { + out.push(c.sauce.libelle); + } + return out; +} + +/** + * Vue-modele PUR du panneau a partir d'un panier. Aucune dependance DOM/localStorage : + * c'est la cible des tests unitaires. + * @param {Array} cart + * @returns {{lines: Array, totalCents: number, count: number, empty: boolean}} + */ +export function buildPanelModel(cart) { + const lines = cart.map((item, index) => ({ + index, + libelle: item.libelle, + quantite: item.quantite, + lineCents: lineCents(item), + options: item.type === 'menu' ? compositionLabels(item.composition) : [], + })); + const totalCents = cart.reduce((sum, item) => sum + lineCents(item), 0); + const count = cart.reduce((sum, item) => sum + item.quantite, 0); + return { lines, totalCents, count, empty: cart.length === 0 }; +} + +/** + * Libelle lisible du mode de consommation pour l'en-tete du panneau. + * @returns {string} + */ +function modeLabel() { + return getMode() === 'a-emporter' ? 'A emporter' : 'Sur place'; +} + +/** + * Construit le HTML d'une ligne du panneau. Toute valeur derivee du catalogue est + * echappee (RG-T15 anti-XSS), comme dans page-cart.js. + * @param {Object} line — element de buildPanelModel().lines + * @returns {string} + */ +function lineHtml(line) { + const options = line.options.length + ? `
    ${line.options + .map(o => `
  • ${escHtml(o)}
  • `) + .join('')}
` + : ''; + return ` +
  • +
    + ${line.quantite}× ${escHtml(line.libelle)} + ${formatPrice(line.lineCents)} +
    + ${options} + +
  • + `; +} + +/** + * Rend le panneau dans le conteneur fourni et cable les interactions (retrait de + * ligne, Abandon, Payer). Lit le panier courant via getCart(). Re-rend apres chaque + * mutation pour rester synchrone avec localStorage. + * + * Abandon = annuler toute la commande -> retour accueil (index.html), semantique borne. + * Payer = aller a la page de paiement ; desactive (aria-disabled) panier vide. + * + * @param {HTMLElement} container — l'element [data-order-panel] + */ +export function renderOrderPanel(container) { + if (!container) return; + const model = buildPanelModel(getCart()); + refreshCartBadge(); + + const body = model.empty + ? '

    Votre commande est vide.
    Ajoutez un produit pour commencer.

    ' + : `
      ${model.lines.map(lineHtml).join('')}
    `; + + container.innerHTML = ` +
    + + Ma commande + ${escHtml(modeLabel())} +
    +
    ${body}
    +
    +
    + TOTAL (ttc) + ${formatPrice(model.totalCents)} +
    +
    + + Payer +
    +
    + `; + + container.querySelectorAll('.order-panel__remove').forEach(btn => { + btn.addEventListener('click', () => { + removeFromCart(parseInt(btn.dataset.index, 10)); + renderOrderPanel(container); + }); + }); + + const abandon = container.querySelector('.order-panel__abandon'); + if (abandon) { + abandon.addEventListener('click', () => { + clearCart(); + window.location.href = 'index.html'; + }); + } + + // Payer desactive sur panier vide : un ignore `disabled`, on bloque le clic + // via aria-disabled (meme parade que page-cart.js / le fix a11y E2E #45). + const pay = container.querySelector('.order-panel__pay'); + if (pay) { + pay.addEventListener('click', e => { + if (pay.getAttribute('aria-disabled') === 'true') e.preventDefault(); + }); + } +} + +/* Auto-montage sur les ecrans qui exposent un conteneur [data-order-panel]. */ +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('[data-order-panel]').forEach(renderOrderPanel); +}); diff --git a/src/public/borne/assets/js/package.json b/src/public/borne/assets/js/package.json new file mode 100644 index 0000000..a6f8a22 --- /dev/null +++ b/src/public/borne/assets/js/package.json @@ -0,0 +1,4 @@ +{ + "//": "Marque les scripts du kiosk comme ESM (import/export) pour Node (tests). Le navigateur les charge via + diff --git a/src/public/borne/categories.html b/src/public/borne/categories.html index 64a0453..e7bea2e 100644 --- a/src/public/borne/categories.html +++ b/src/public/borne/categories.html @@ -6,6 +6,8 @@ Wakdo - Categories + + @@ -175,5 +177,11 @@ + + + diff --git a/src/public/borne/confirmation.html b/src/public/borne/confirmation.html index 4c86263..f9ff57c 100644 --- a/src/public/borne/confirmation.html +++ b/src/public/borne/confirmation.html @@ -6,6 +6,8 @@ Wakdo - Confirmation + + @@ -40,7 +42,7 @@
    Votre numero de commande - +

    @@ -65,5 +67,6 @@ + diff --git a/src/public/borne/data/README.md b/src/public/borne/data/README.md new file mode 100644 index 0000000..363ce16 --- /dev/null +++ b/src/public/borne/data/README.md @@ -0,0 +1,22 @@ +# Donnees statiques de la borne (repli P5) + +`categories.json` et `produits.json` sont un **repli statique fige** consomme par +le front de la borne (Bloc 1 / P5) tant que l'API REST n'existe pas. Ils sont +copies du jeu de donnees source de l'ecole (`docs/merise/_sources/`), **pas** +generes depuis la base. + +## Ces fichiers ne refletent pas la base + +Le catalogue servi ici est le jeu source complet (66 produits) ; le seed de la +base (`db/seeds/0002_catalogue.sql`) en est un sous-ensemble curate (53 produits). +Les categories, elles, coincident (9 de chaque cote). La borne est une demo front +sur donnees statiques : un ecart de comptage produits avec la table `product` est +**attendu**, ce n'est pas une incoherence a corriger. + +## Point de bascule (P4) + +`assets/js/data.js` lit ces fichiers via les constantes `CATEGORIES_URL` / +`PRODUCTS_URL`. En P4, ces constantes pointeront vers `/api/categories` et +`/api/products` (memes formes de retour, le reste du code est agnostique). La +borne refletera alors la base via l'API, et ces fichiers deviendront obsoletes +(a retirer a ce moment-la). diff --git a/src/public/borne/data/allergens.json b/src/public/borne/data/allergens.json new file mode 100644 index 0000000..710fa1e --- /dev/null +++ b/src/public/borne/data/allergens.json @@ -0,0 +1,16 @@ +[ + { "id": 1, "name": "Cereales contenant du gluten", "description": "Ble, seigle, orge, avoine, epeautre, kamut et produits derives." }, + { "id": 2, "name": "Crustaces", "description": "Et produits a base de crustaces." }, + { "id": 3, "name": "Oeufs", "description": "Et produits a base d'oeufs." }, + { "id": 4, "name": "Poissons", "description": "Et produits a base de poissons." }, + { "id": 5, "name": "Arachides", "description": "Et produits a base d'arachides." }, + { "id": 6, "name": "Soja", "description": "Et produits a base de soja." }, + { "id": 7, "name": "Lait", "description": "Et produits a base de lait (y compris le lactose)." }, + { "id": 8, "name": "Fruits a coque", "description": "Amandes, noisettes, noix, noix de cajou, pistaches et autres." }, + { "id": 9, "name": "Celeri", "description": "Et produits a base de celeri." }, + { "id": 10, "name": "Moutarde", "description": "Et produits a base de moutarde." }, + { "id": 11, "name": "Graines de sesame", "description": "Et produits a base de graines de sesame." }, + { "id": 12, "name": "Anhydride sulfureux et sulfites", "description": "En concentration de plus de 10 mg/kg ou 10 mg/l." }, + { "id": 13, "name": "Lupin", "description": "Et produits a base de lupin." }, + { "id": 14, "name": "Mollusques", "description": "Et produits a base de mollusques." } +] diff --git a/src/public/borne/index.html b/src/public/borne/index.html index e07ccef..0033a6f 100644 --- a/src/public/borne/index.html +++ b/src/public/borne/index.html @@ -10,7 +10,23 @@ Wakdo - Bienvenue + + + + @@ -77,5 +93,6 @@ + diff --git a/src/public/borne/payment.html b/src/public/borne/payment.html index d43d3f3..927cc75 100644 --- a/src/public/borne/payment.html +++ b/src/public/borne/payment.html @@ -6,6 +6,8 @@ Wakdo - Paiement + + @@ -38,14 +40,16 @@

    - +
    + +