Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dee190a8c | |||
| 510404013c |
132 changed files with 1936 additions and 8339 deletions
13
.env.example
13
.env.example
|
|
@ -131,16 +131,3 @@ CRON_TIMEZONE=Europe/Paris
|
||||||
# Nom du reseau Docker externe partage avec le Traefik de l'hote (doit exister
|
# Nom du reseau Docker externe partage avec le Traefik de l'hote (doit exister
|
||||||
# AVANT le up : cree par la stack Traefik, ou `docker network create <nom>`).
|
# AVANT le up : cree par la stack Traefik, ou `docker network create <nom>`).
|
||||||
REVERSE_PROXY_NETWORK=admin_proxy
|
REVERSE_PROXY_NETWORK=admin_proxy
|
||||||
|
|
||||||
# ===================================================================
|
|
||||||
# Envoi d'email (reinitialisation mot de passe) - OPTIONNEL
|
|
||||||
# ===================================================================
|
|
||||||
# Absentes en local : l'app journalise le lien de reset (LogMailer), aucun envoi.
|
|
||||||
# Renseigner SMTP_HOST + SMTP_USER + SMTP_PASSWORD active l'envoi via relais SMTP.
|
|
||||||
# Mettre les vraies valeurs uniquement dans le .env de l'hote (jamais versionnees).
|
|
||||||
# SMTP_HOST=smtp-relay.brevo.com
|
|
||||||
# SMTP_PORT=587
|
|
||||||
# SMTP_USER=
|
|
||||||
# SMTP_PASSWORD=
|
|
||||||
# MAIL_FROM_EMAIL=noreply@example.com
|
|
||||||
# MAIL_FROM_NAME=Wakdo
|
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
# Modele de configuration de PRODUCTION (derriere Traefik).
|
|
||||||
#
|
|
||||||
# cp .env.prod.example .env
|
|
||||||
# puis renseigner les lignes <REMPLIR> (domaines, mots de passe, reseau Traefik).
|
|
||||||
#
|
|
||||||
# Difference avec .env.example (dev) : APP_ENV=prod, APP_DEBUG=false, URLs en HTTPS,
|
|
||||||
# mots de passe forts, REVERSE_PROXY_NETWORK renseigne.
|
|
||||||
|
|
||||||
APP_ENV=prod
|
|
||||||
APP_DEBUG=false
|
|
||||||
APP_TIMEZONE=Europe/Paris
|
|
||||||
|
|
||||||
# Domaines publics (doivent resoudre en DNS vers l'hote de prod).
|
|
||||||
APP_HOST_KIOSK=<REMPLIR-domaine-borne>
|
|
||||||
APP_HOST_ADMIN=<REMPLIR-domaine-admin>
|
|
||||||
APP_URL_KIOSK=https://<REMPLIR-domaine-borne>
|
|
||||||
APP_URL_ADMIN=https://<REMPLIR-domaine-admin>
|
|
||||||
|
|
||||||
# Base de donnees : mots de passe FORTS en prod (openssl rand -base64 24).
|
|
||||||
DB_HOST=wakdo-db
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_NAME=wakdo
|
|
||||||
DB_USER=wakdo
|
|
||||||
DB_PASSWORD=<REMPLIR-mot-de-passe-fort>
|
|
||||||
DB_ROOT_PASSWORD=<REMPLIR-autre-mot-de-passe-fort>
|
|
||||||
|
|
||||||
SESSION_LIFETIME_IDLE=14400
|
|
||||||
SESSION_LIFETIME_ABSOLUTE=36000
|
|
||||||
SESSION_NAME=WAKDO_SID
|
|
||||||
|
|
||||||
# Doit correspondre EXACTEMENT a APP_URL_KIOSK (pas de wildcard).
|
|
||||||
CORS_ALLOWED_ORIGIN=https://<REMPLIR-domaine-borne>
|
|
||||||
|
|
||||||
ARGON2_MEMORY_COST=65536
|
|
||||||
ARGON2_TIME_COST=4
|
|
||||||
ARGON2_THREADS=1
|
|
||||||
|
|
||||||
ACCOUNT_LOCKOUT_THRESHOLD=5
|
|
||||||
ACCOUNT_LOCKOUT_BASE_SECONDS=60
|
|
||||||
ACCOUNT_LOCKOUT_MAX_SECONDS=900
|
|
||||||
IP_THROTTLE_WINDOW_SECONDS=900
|
|
||||||
IP_THROTTLE_MAX_ATTEMPTS=20
|
|
||||||
|
|
||||||
STAFF_PIN_MIN_LENGTH=4
|
|
||||||
STAFF_PIN_MAX_LENGTH=12
|
|
||||||
PIN_THROTTLE_THRESHOLD=5
|
|
||||||
PIN_THROTTLE_BASE_SECONDS=30
|
|
||||||
PIN_THROTTLE_MAX_SECONDS=300
|
|
||||||
PIN_THROTTLE_WINDOW_SECONDS=900
|
|
||||||
PASSWORD_RESET_TTL=3600
|
|
||||||
|
|
||||||
AUDIT_LOG_RETENTION_DAYS=365
|
|
||||||
THROTTLE_PURGE_AFTER_HOURS=24
|
|
||||||
ORDER_RETENTION_DAYS=1095
|
|
||||||
|
|
||||||
UPLOAD_MAX_SIZE_MB=5
|
|
||||||
UPLOAD_ALLOWED_MIME=image/jpeg,image/png,image/webp
|
|
||||||
|
|
||||||
CRON_TIMEZONE=Europe/Paris
|
|
||||||
|
|
||||||
# Nom du reseau Docker externe du Traefik de l'hote (doit exister avant le up).
|
|
||||||
REVERSE_PROXY_NETWORK=<REMPLIR-reseau-traefik>
|
|
||||||
|
|
||||||
# ===================================================================
|
|
||||||
# Envoi d'email (reinitialisation mot de passe) - relais SMTP
|
|
||||||
# ===================================================================
|
|
||||||
# Si SMTP_HOST + SMTP_USER + SMTP_PASSWORD sont presents, l'app envoie via le
|
|
||||||
# relais ; sinon elle se rabat sur le journal (LogMailer). Renseigner ces 3
|
|
||||||
# valeurs UNIQUEMENT ici (jamais dans le depot). Exemple : relais Brevo.
|
|
||||||
SMTP_HOST=smtp-relay.brevo.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=<REMPLIR-login-smtp>
|
|
||||||
SMTP_PASSWORD=<REMPLIR-cle-smtp-secrete>
|
|
||||||
MAIL_FROM_EMAIL=noreply@a3n.fr
|
|
||||||
MAIL_FROM_NAME=Wakdo
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
name: Deploy
|
|
||||||
# Deploiement continu (CD) vers Vision (prod) a chaque release sur main.
|
|
||||||
#
|
|
||||||
# Topologie : le runner tourne sur Stark (dev) et n'a pas le socket Docker. Il ne
|
|
||||||
# pilote donc PAS Docker lui-meme : il OUVRE une session SSH vers Vision (prod, hote
|
|
||||||
# distinct) ou une forced command (cote Vision) lance scripts/deploy.sh. La cle CI ne
|
|
||||||
# peut ainsi declencher QUE le deploiement, rien d'autre.
|
|
||||||
#
|
|
||||||
# main n'est alimentee que par des PR dev->main deja passees par la CI : le code
|
|
||||||
# deploye a donc deja ete teste. Voir docs/architecture/deployment.md pour la mise en
|
|
||||||
# place cote Vision (utilisateur deploy, forced command) et les secrets a creer.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- name: Install SSH client
|
|
||||||
run: |
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq openssh-client >/dev/null
|
|
||||||
- name: Deploy to Vision over SSH
|
|
||||||
env:
|
|
||||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
|
||||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
|
||||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
|
||||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
install -d -m 700 ~/.ssh
|
|
||||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy
|
|
||||||
chmod 600 ~/.ssh/id_deploy
|
|
||||||
# Cle d'hote epinglee (pas de TOFU) : la connexion echoue si Vision ne
|
|
||||||
# presente pas la cle attendue.
|
|
||||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
||||||
# Aucune commande passee : la forced command cote Vision lance deploy.sh.
|
|
||||||
# BatchMode : pas de prompt interactif (un echec d'auth echoue vite au lieu
|
|
||||||
# de pendre le job) ; ConnectTimeout borne l'attente si Vision est injoignable.
|
|
||||||
ssh -i ~/.ssh/id_deploy -o IdentitiesOnly=yes \
|
|
||||||
-o StrictHostKeyChecking=yes \
|
|
||||||
-o BatchMode=yes -o ConnectTimeout=15 \
|
|
||||||
"$DEPLOY_USER@$DEPLOY_HOST"
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -56,9 +56,6 @@ Thumbs.db
|
||||||
*.log
|
*.log
|
||||||
/logs/
|
/logs/
|
||||||
|
|
||||||
# === Marqueur de version (ecrit par scripts/deploy.sh sur l'hote, propre au deploiement) ===
|
|
||||||
/src/VERSION
|
|
||||||
|
|
||||||
# === Data / Uploads / Backups ===
|
# === Data / Uploads / Backups ===
|
||||||
# /var/ : contient /var/backups/ (bind-mount des dumps BDD du conteneur cron)
|
# /var/ : contient /var/backups/ (bind-mount des dumps BDD du conteneur cron)
|
||||||
# et tout futur artefact run-time (caches persistes, logs).
|
# et tout futur artefact run-time (caches persistes, logs).
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ d'authentification durci dans `docs/uml/security-sequence.md`.
|
||||||
| Brute-force | double throttle : compteur par compte (`user`) + par IP (`login_throttle`), backoff degressif |
|
| 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 |
|
| Sessions | cookies `HttpOnly` + `Secure` + `SameSite=Strict`, regeneration d'ID a la connexion (anti-fixation), idle 4h / absolu 10h |
|
||||||
| Injection | PDO prepared statements exclusivement |
|
| Injection | PDO prepared statements exclusivement |
|
||||||
| Upload | non implemente (aucun flux d'upload livre) ; prevu : validation MIME + taille, stockage hors webroot |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Secrets | `.env` gitignore, tenu hors de `.git/config` (credential helper lisant `.env`), secret-scan gitleaks en CI |
|
||||||
|
|
|
||||||
48
db/README.md
48
db/README.md
|
|
@ -6,53 +6,23 @@ Transcription executable du MLD (`docs/merise/mld.md`, 21 tables) vers MariaDB 1
|
||||||
|
|
||||||
```
|
```
|
||||||
db/
|
db/
|
||||||
migrations/ migrations SQL versionnees, appliquees dans l'ordre lexicographique
|
migrations/ migrations SQL versionnees, appliquees dans l'ordre lexicographique
|
||||||
0001_init_schema.sql schema initial : 21 tables, FK, CHECK, index (InnoDB, utf8mb4)
|
0001_init_schema.sql schema initial : 21 tables, FK, CHECK, index (InnoDB, utf8mb4)
|
||||||
seeds/ donnees de reference (RBAC, allergenes, catalogue, variantes)
|
seeds/ donnees de demonstration (a venir : roles/permissions, allergenes, catalogue)
|
||||||
migrate-container.sh runner de boot IN-CONTAINER (canonique, service wakdo-migrate)
|
migrate.sh runner de migrations (idempotent)
|
||||||
migrate.sh runner de migrations cote HOTE (manuel, via docker exec)
|
|
||||||
seed.sh runner de seeds cote HOTE (manuel, via docker exec)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Numerotation des migrations (trou 0004 assume)
|
## Appliquer les migrations
|
||||||
|
|
||||||
Les migrations sautent `0004` : la sequence est `0001, 0002, 0003, 0005, 0006,
|
|
||||||
0007`. Ce n'est PAS un fichier manquant mais un desalignement historique assume :
|
|
||||||
le numero `0004` a ete consomme cote `seeds/` (`0004_menu_side_maxi.sql`) lors
|
|
||||||
d'un meme increment de travail, sans contrepartie cote `migrations/`. Le suivi se
|
|
||||||
fait par NOM DE FICHIER (`schema_migrations`), pas par numero contigu : le trou
|
|
||||||
est donc inoffensif (rien ne presuppose une sequence sans lacune). Convention
|
|
||||||
conservee : ne pas reattribuer `0004` cote migrations pour eviter toute confusion
|
|
||||||
avec le seed homonyme ; la prochaine migration prend le numero suivant disponible.
|
|
||||||
|
|
||||||
## Appliquer les migrations + seeds
|
|
||||||
|
|
||||||
Chemin canonique (boot de la stack) : le service one-shot `wakdo-migrate`
|
|
||||||
(`docker compose up`) execute `db/migrate-container.sh`, qui applique
|
|
||||||
`db/migrations/*.sql` (suivi : table `schema_migrations`) PUIS `db/seeds/*.sql`
|
|
||||||
(suivi : table `seeds_applied`), de maniere idempotente. Il se connecte a
|
|
||||||
`wakdo-db` par le reseau compose.
|
|
||||||
|
|
||||||
Chemin manuel (hote, via `docker exec`) :
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash db/migrate.sh # applique les migrations en attente
|
bash db/migrate.sh # applique les migrations en attente
|
||||||
bash db/migrate.sh --status # liste l'etat des migrations sans rien appliquer
|
bash db/migrate.sh --status # liste l'etat sans rien appliquer
|
||||||
bash db/seed.sh # charge les seeds en attente
|
|
||||||
bash db/seed.sh --status # liste l'etat des seeds sans rien charger
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Les runners hote ciblent le conteneur `wakdo-db` et lisent les identifiants dans
|
Le runner cible le conteneur `wakdo-db` et lit les identifiants dans `.env`
|
||||||
`.env` (`DB_NAME`, `DB_ROOT_PASSWORD`).
|
(`DB_NAME`, `DB_ROOT_PASSWORD`). Il maintient une table `schema_migrations`
|
||||||
|
(une ligne par fichier applique) : relancer ne rejoue que les nouvelles
|
||||||
### Suivi partage entre les deux chemins
|
migrations. La cible `bash db/migrate.sh` est destinee a appeler ce script.
|
||||||
|
|
||||||
Les runners hote et conteneur partagent les MEMES tables de suivi (memes noms,
|
|
||||||
memes colonnes `filename` / `applied_at`) : `schema_migrations` pour les
|
|
||||||
migrations, `seeds_applied` pour les seeds. Consequence : rejouer un chemin apres
|
|
||||||
l'autre ne replaye RIEN. (Auparavant `db/seed.sh` suivait une table distincte
|
|
||||||
`seed_history`, ce qui pouvait lui faire re-jouer des seeds deja charges par
|
|
||||||
`wakdo-migrate` et echouer sur une contrainte UNIQUE — corrige.)
|
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,6 @@
|
||||||
-- the application layer.
|
-- the application layer.
|
||||||
-- - No CREATE DATABASE / USE here: the target DB is chosen by the runner.
|
-- - 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).
|
-- - No seed / INSERT data here (see db/seeds/0001_demo_data.sql).
|
||||||
--
|
|
||||||
-- Idempotence (defense en profondeur, suivi par schema_migrations) : chaque table
|
|
||||||
-- est creee en CREATE TABLE IF NOT EXISTS. Index, cles uniques et contraintes
|
|
||||||
-- (FK / CHECK) sont declares INLINE dans le CREATE TABLE : ils ne sont donc pas
|
|
||||||
-- re-joues quand la table preexiste. Re-jouer ce fichier sur une base deja
|
|
||||||
-- migree ne modifie pas le schema (aucun ALTER autonome).
|
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
@ -35,7 +29,7 @@ SET FOREIGN_KEY_CHECKS = 0;
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.1 category — root table for the Catalogue sub-domain (no FK)
|
-- 4.1 category — root table for the Catalogue sub-domain (no FK)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS category (
|
CREATE TABLE category (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
name VARCHAR(60) NOT NULL,
|
name VARCHAR(60) NOT NULL,
|
||||||
slug VARCHAR(60) NOT NULL,
|
slug VARCHAR(60) NOT NULL,
|
||||||
|
|
@ -52,7 +46,7 @@ CREATE TABLE IF NOT EXISTS category (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.6 ingredient — root table for Ingredients & Stock (no FK)
|
-- 4.6 ingredient — root table for Ingredients & Stock (no FK)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS ingredient (
|
CREATE TABLE ingredient (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
unit VARCHAR(40) NOT NULL,
|
unit VARCHAR(40) NOT NULL,
|
||||||
|
|
@ -77,7 +71,7 @@ CREATE TABLE IF NOT EXISTS ingredient (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.8 allergen — reference table (INCO EU 1169/2011), no FK
|
-- 4.8 allergen — reference table (INCO EU 1169/2011), no FK
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS allergen (
|
CREATE TABLE allergen (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
code VARCHAR(30) NOT NULL,
|
code VARCHAR(30) NOT NULL,
|
||||||
name VARCHAR(80) NOT NULL,
|
name VARCHAR(80) NOT NULL,
|
||||||
|
|
@ -89,7 +83,7 @@ CREATE TABLE IF NOT EXISTS allergen (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.10 role — root table for RBAC (no FK)
|
-- 4.10 role — root table for RBAC (no FK)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS role (
|
CREATE TABLE role (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
code VARCHAR(40) NOT NULL,
|
code VARCHAR(40) NOT NULL,
|
||||||
label VARCHAR(80) NOT NULL,
|
label VARCHAR(80) NOT NULL,
|
||||||
|
|
@ -106,7 +100,7 @@ CREATE TABLE IF NOT EXISTS role (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.13 permission — reference table, catalogue frozen at 23 codes (no FK)
|
-- 4.13 permission — reference table, catalogue frozen at 23 codes (no FK)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS permission (
|
CREATE TABLE permission (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
code VARCHAR(60) NOT NULL,
|
code VARCHAR(60) NOT NULL,
|
||||||
label VARCHAR(120) NOT NULL,
|
label VARCHAR(120) NOT NULL,
|
||||||
|
|
@ -119,7 +113,7 @@ CREATE TABLE IF NOT EXISTS permission (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.21 login_throttle — per-source-IP brute-force throttle (no FK)
|
-- 4.21 login_throttle — per-source-IP brute-force throttle (no FK)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS login_throttle (
|
CREATE TABLE login_throttle (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
ip_address VARCHAR(45) NOT NULL,
|
ip_address VARCHAR(45) NOT NULL,
|
||||||
failed_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
failed_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
|
@ -134,7 +128,7 @@ CREATE TABLE IF NOT EXISTS login_throttle (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.2 product — depends on category
|
-- 4.2 product — depends on category
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS product (
|
CREATE TABLE product (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
category_id INT UNSIGNED NOT NULL,
|
category_id INT UNSIGNED NOT NULL,
|
||||||
name VARCHAR(120) NOT NULL,
|
name VARCHAR(120) NOT NULL,
|
||||||
|
|
@ -157,7 +151,7 @@ CREATE TABLE IF NOT EXISTS product (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.3 menu — depends on category, product
|
-- 4.3 menu — depends on category, product
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS menu (
|
CREATE TABLE menu (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
category_id INT UNSIGNED NOT NULL,
|
category_id INT UNSIGNED NOT NULL,
|
||||||
burger_product_id INT UNSIGNED NOT NULL,
|
burger_product_id INT UNSIGNED NOT NULL,
|
||||||
|
|
@ -183,7 +177,7 @@ CREATE TABLE IF NOT EXISTS menu (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.4 menu_slot — depends on menu (no audit fields)
|
-- 4.4 menu_slot — depends on menu (no audit fields)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS menu_slot (
|
CREATE TABLE menu_slot (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
menu_id INT UNSIGNED NOT NULL,
|
menu_id INT UNSIGNED NOT NULL,
|
||||||
name VARCHAR(80) NOT NULL,
|
name VARCHAR(80) NOT NULL,
|
||||||
|
|
@ -200,7 +194,7 @@ CREATE TABLE IF NOT EXISTS menu_slot (
|
||||||
-- 4.5 menu_slot_option — pure join table, composite PK
|
-- 4.5 menu_slot_option — pure join table, composite PK
|
||||||
-- depends on menu_slot, product
|
-- depends on menu_slot, product
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS menu_slot_option (
|
CREATE TABLE menu_slot_option (
|
||||||
menu_slot_id INT UNSIGNED NOT NULL,
|
menu_slot_id INT UNSIGNED NOT NULL,
|
||||||
product_id INT UNSIGNED NOT NULL,
|
product_id INT UNSIGNED NOT NULL,
|
||||||
PRIMARY KEY (menu_slot_id, product_id),
|
PRIMARY KEY (menu_slot_id, product_id),
|
||||||
|
|
@ -215,7 +209,7 @@ CREATE TABLE IF NOT EXISTS menu_slot_option (
|
||||||
-- 4.7 product_ingredient — join table with attributes, composite PK
|
-- 4.7 product_ingredient — join table with attributes, composite PK
|
||||||
-- depends on product, ingredient
|
-- depends on product, ingredient
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS product_ingredient (
|
CREATE TABLE product_ingredient (
|
||||||
product_id INT UNSIGNED NOT NULL,
|
product_id INT UNSIGNED NOT NULL,
|
||||||
ingredient_id INT UNSIGNED NOT NULL,
|
ingredient_id INT UNSIGNED NOT NULL,
|
||||||
quantity_normal SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
quantity_normal SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
|
@ -238,7 +232,7 @@ CREATE TABLE IF NOT EXISTS product_ingredient (
|
||||||
-- 4.9 ingredient_allergen — pure join table, composite PK
|
-- 4.9 ingredient_allergen — pure join table, composite PK
|
||||||
-- depends on ingredient, allergen
|
-- depends on ingredient, allergen
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS ingredient_allergen (
|
CREATE TABLE ingredient_allergen (
|
||||||
ingredient_id INT UNSIGNED NOT NULL,
|
ingredient_id INT UNSIGNED NOT NULL,
|
||||||
allergen_id INT UNSIGNED NOT NULL,
|
allergen_id INT UNSIGNED NOT NULL,
|
||||||
PRIMARY KEY (ingredient_id, allergen_id),
|
PRIMARY KEY (ingredient_id, allergen_id),
|
||||||
|
|
@ -252,7 +246,7 @@ CREATE TABLE IF NOT EXISTS ingredient_allergen (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.11 user — depends on role
|
-- 4.11 user — depends on role
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS user (
|
CREATE TABLE user (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
email VARCHAR(254) NOT NULL,
|
email VARCHAR(254) NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
|
@ -281,7 +275,7 @@ CREATE TABLE IF NOT EXISTS user (
|
||||||
-- 4.12 role_visible_source — pure join table, composite PK
|
-- 4.12 role_visible_source — pure join table, composite PK
|
||||||
-- depends on role
|
-- depends on role
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS role_visible_source (
|
CREATE TABLE role_visible_source (
|
||||||
role_id INT UNSIGNED NOT NULL,
|
role_id INT UNSIGNED NOT NULL,
|
||||||
source ENUM('kiosk','counter','drive') NOT NULL,
|
source ENUM('kiosk','counter','drive') NOT NULL,
|
||||||
PRIMARY KEY (role_id, source),
|
PRIMARY KEY (role_id, source),
|
||||||
|
|
@ -293,7 +287,7 @@ CREATE TABLE IF NOT EXISTS role_visible_source (
|
||||||
-- 4.14 role_permission — pure join table, composite PK
|
-- 4.14 role_permission — pure join table, composite PK
|
||||||
-- depends on role, permission
|
-- depends on role, permission
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS role_permission (
|
CREATE TABLE role_permission (
|
||||||
role_id INT UNSIGNED NOT NULL,
|
role_id INT UNSIGNED NOT NULL,
|
||||||
permission_id INT UNSIGNED NOT NULL,
|
permission_id INT UNSIGNED NOT NULL,
|
||||||
PRIMARY KEY (role_id, permission_id),
|
PRIMARY KEY (role_id, permission_id),
|
||||||
|
|
@ -307,7 +301,7 @@ CREATE TABLE IF NOT EXISTS role_permission (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.15 customer_order — depends on user (acting_user_id)
|
-- 4.15 customer_order — depends on user (acting_user_id)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS customer_order (
|
CREATE TABLE customer_order (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
order_number VARCHAR(20) NOT NULL,
|
order_number VARCHAR(20) NOT NULL,
|
||||||
idempotency_key VARCHAR(36) NULL,
|
idempotency_key VARCHAR(36) NULL,
|
||||||
|
|
@ -342,7 +336,7 @@ CREATE TABLE IF NOT EXISTS customer_order (
|
||||||
-- 4.16 order_item — depends on customer_order, product, menu
|
-- 4.16 order_item — depends on customer_order, product, menu
|
||||||
-- polymorphic line (product XOR menu)
|
-- polymorphic line (product XOR menu)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS order_item (
|
CREATE TABLE order_item (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
order_id INT UNSIGNED NOT NULL,
|
order_id INT UNSIGNED NOT NULL,
|
||||||
item_type ENUM('product','menu') NOT NULL,
|
item_type ENUM('product','menu') NOT NULL,
|
||||||
|
|
@ -376,7 +370,7 @@ CREATE TABLE IF NOT EXISTS order_item (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.17 order_item_selection — depends on order_item, menu_slot, product
|
-- 4.17 order_item_selection — depends on order_item, menu_slot, product
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS order_item_selection (
|
CREATE TABLE order_item_selection (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
order_item_id INT UNSIGNED NOT NULL,
|
order_item_id INT UNSIGNED NOT NULL,
|
||||||
menu_slot_id INT UNSIGNED NOT NULL,
|
menu_slot_id INT UNSIGNED NOT NULL,
|
||||||
|
|
@ -397,7 +391,7 @@ CREATE TABLE IF NOT EXISTS order_item_selection (
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- 4.18 order_item_modifier — depends on order_item, ingredient
|
-- 4.18 order_item_modifier — depends on order_item, ingredient
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS order_item_modifier (
|
CREATE TABLE order_item_modifier (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
order_item_id INT UNSIGNED NOT NULL,
|
order_item_id INT UNSIGNED NOT NULL,
|
||||||
ingredient_id INT UNSIGNED NOT NULL,
|
ingredient_id INT UNSIGNED NOT NULL,
|
||||||
|
|
@ -417,7 +411,7 @@ CREATE TABLE IF NOT EXISTS order_item_modifier (
|
||||||
-- 4.19 stock_movement — append-only audit log
|
-- 4.19 stock_movement — append-only audit log
|
||||||
-- depends on ingredient, customer_order, user
|
-- depends on ingredient, customer_order, user
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS stock_movement (
|
CREATE TABLE stock_movement (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
ingredient_id INT UNSIGNED NOT NULL,
|
ingredient_id INT UNSIGNED NOT NULL,
|
||||||
movement_type ENUM('sale','cancellation','restock','inventory_correction') NOT NULL,
|
movement_type ENUM('sale','cancellation','restock','inventory_correction') NOT NULL,
|
||||||
|
|
@ -443,7 +437,7 @@ CREATE TABLE IF NOT EXISTS stock_movement (
|
||||||
-- 4.20 audit_log — append-only sensitive-action log
|
-- 4.20 audit_log — append-only sensitive-action log
|
||||||
-- depends on user, role
|
-- depends on user, role
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE audit_log (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
actor_user_id INT UNSIGNED NULL,
|
actor_user_id INT UNSIGNED NULL,
|
||||||
actor_role_id INT UNSIGNED NULL,
|
actor_role_id INT UNSIGNED NULL,
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,7 @@
|
||||||
|
|
||||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Idempotence (defense en profondeur) : CREATE TABLE IF NOT EXISTS. La cle
|
CREATE TABLE pin_throttle (
|
||||||
-- unique, l'index et la FK sont inline dans le CREATE TABLE, donc non re-joues
|
|
||||||
-- quand la table preexiste. Re-jouer ce fichier ne modifie pas le schema.
|
|
||||||
CREATE TABLE IF NOT EXISTS pin_throttle (
|
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
actor_user_id INT UNSIGNED NOT NULL,
|
actor_user_id INT UNSIGNED NOT NULL,
|
||||||
failed_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
failed_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
|
|
||||||
|
|
@ -13,21 +13,5 @@
|
||||||
|
|
||||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Idempotence : meme garde information_schema que 0006/0007 (re-jouable sans
|
ALTER TABLE customer_order
|
||||||
-- erreur). On verifie l'absence de la colonne `service_tag` avant l'ALTER ;
|
ADD COLUMN service_tag VARCHAR(20) NULL AFTER service_mode;
|
||||||
-- si elle existe deja, on execute un no-op (DO 0). Le schema resultant est
|
|
||||||
-- inchange : seul l'ajout de la colonne (si absente) est joue.
|
|
||||||
SET @col_exists := (
|
|
||||||
SELECT COUNT(*) FROM information_schema.columns
|
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'customer_order' AND column_name = 'service_tag'
|
|
||||||
);
|
|
||||||
|
|
||||||
SET @ddl := IF(
|
|
||||||
@col_exists = 0,
|
|
||||||
'ALTER TABLE customer_order
|
|
||||||
ADD COLUMN service_tag VARCHAR(20) NULL AFTER service_mode',
|
|
||||||
'DO 0'
|
|
||||||
);
|
|
||||||
PREPARE stmt FROM @ddl;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
|
||||||
|
|
@ -14,26 +14,10 @@
|
||||||
|
|
||||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- Idempotence : meme garde information_schema que 0007 (re-jouable sans erreur).
|
ALTER TABLE ingredient
|
||||||
-- Les trois colonnes sont ajoutees ensemble ; l'existence de la premiere
|
ADD COLUMN energy_kcal_100g SMALLINT UNSIGNED NULL AFTER pack_label,
|
||||||
-- (`energy_kcal_100g`) suffit donc a court-circuiter le groupe. Si elle existe
|
ADD COLUMN nutrition_source VARCHAR(120) NULL AFTER energy_kcal_100g,
|
||||||
-- deja, on execute un no-op (DO 0). Le schema resultant est inchange.
|
ADD COLUMN nutrition_fetched_at DATETIME NULL AFTER nutrition_source;
|
||||||
SET @col_exists := (
|
|
||||||
SELECT COUNT(*) FROM information_schema.columns
|
|
||||||
WHERE table_schema = DATABASE() AND table_name = 'ingredient' AND column_name = 'energy_kcal_100g'
|
|
||||||
);
|
|
||||||
|
|
||||||
SET @ddl := IF(
|
|
||||||
@col_exists = 0,
|
|
||||||
'ALTER TABLE ingredient
|
|
||||||
ADD COLUMN energy_kcal_100g SMALLINT UNSIGNED NULL AFTER pack_label,
|
|
||||||
ADD COLUMN nutrition_source VARCHAR(120) NULL AFTER energy_kcal_100g,
|
|
||||||
ADD COLUMN nutrition_fetched_at DATETIME NULL AFTER nutrition_source',
|
|
||||||
'DO 0'
|
|
||||||
);
|
|
||||||
PREPARE stmt FROM @ddl;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
-- energy_kcal_100g : apport energetique pour 100 g (SMALLINT UNSIGNED suffit ; les
|
-- energy_kcal_100g : apport energetique pour 100 g (SMALLINT UNSIGNED suffit ; les
|
||||||
-- valeurs reelles restent < 1000). nutrition_source : provenance ("OpenFoodFacts").
|
-- valeurs reelles restent < 1000). nutrition_source : provenance ("OpenFoodFacts").
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,10 @@
|
||||||
-- domaine commande facture deja par product_id : le flux de commande
|
-- domaine commande facture deja par product_id : le flux de commande
|
||||||
-- reste inchange, la borne resout juste la taille choisie en product_id.
|
-- reste inchange, la borne resout juste la taille choisie en product_id.
|
||||||
--
|
--
|
||||||
-- Grouping DEDIE (base_product_id), distinct de maxi_variant_product_id
|
-- Grouping DEDIE, distinct de maxi_variant_product_id (migration 0006) :
|
||||||
-- (migration 0006) : base_product_id pilote la selection de taille A LA
|
-- ce dernier pilote la substitution Maxi de l'accompagnement de menu
|
||||||
-- CARTE (picker 30/50 cl) ; maxi_variant_product_id pilote la substitution
|
-- (resolveSelections) ; le reutiliser ferait basculer en 50 cl une
|
||||||
-- Maxi en MENU (resolveSelections). Les deux coexistent sur une boisson :
|
-- boisson 30 cl glissee dans un menu Maxi (effet de bord non voulu).
|
||||||
-- le seed 0006 pointe desormais chaque soda 30 cl vers sa variante 50 cl
|
|
||||||
-- pour qu'un menu Maxi serve la grande boisson (decision metier). Cet
|
|
||||||
-- "effet" est VOULU et ne s'applique qu'aux selections de menu au format
|
|
||||||
-- maxi ; une boisson 30 cl commandee a la carte (resolveLine type product)
|
|
||||||
-- ne consulte jamais maxi_variant_product_id et reste en 30 cl.
|
|
||||||
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
|
-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci.
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|
||||||
|
|
|
||||||
22
db/seed.sh
22
db/seed.sh
|
|
@ -1,19 +1,11 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Wakdo - seed runner (hote, via `docker exec`).
|
# Wakdo - seed runner.
|
||||||
#
|
#
|
||||||
# Applique les fichiers db/seeds/*.sql dans l'ordre lexicographique, de maniere
|
# Applique les fichiers db/seeds/*.sql dans l'ordre lexicographique, de maniere
|
||||||
# idempotente : la table seeds_applied enregistre les fichiers deja charges.
|
# idempotente : une table seed_history enregistre les fichiers deja charges.
|
||||||
# Les seeds doivent etre joues APRES les migrations (les tables doivent exister).
|
# Les seeds doivent etre joues APRES les migrations (les tables doivent exister).
|
||||||
#
|
#
|
||||||
# Contrepartie hote de db/migrate.sh : meme role que la phase seed du service de
|
|
||||||
# boot wakdo-migrate (db/migrate-container.sh), mais lance manuellement depuis
|
|
||||||
# l'hote. Le suivi DOIT utiliser la MEME table que le runner conteneur
|
|
||||||
# (seeds_applied) pour que les deux interoperent : rejouer l'un apres l'autre ne
|
|
||||||
# replaye RIEN. Auparavant ce script suivait une table distincte (seed_history),
|
|
||||||
# ce qui lui faisait re-jouer des seeds deja charges par wakdo-migrate (INSERT
|
|
||||||
# bruts -> echec sur contrainte UNIQUE).
|
|
||||||
#
|
|
||||||
# Cible : le service docker-compose `wakdo-db`. Identifiants lus dans .env.
|
# Cible : le service docker-compose `wakdo-db`. Identifiants lus dans .env.
|
||||||
#
|
#
|
||||||
# Usage :
|
# Usage :
|
||||||
|
|
@ -42,9 +34,7 @@ if [ ! -d "$SEEDS_DIR" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Meme schema de suivi que db/migrate-container.sh (seeds_applied) : nom de table
|
db "$DB_NAME" -e "CREATE TABLE IF NOT EXISTS seed_history (
|
||||||
# et colonnes identiques, pour que les deux runners partagent le meme journal.
|
|
||||||
db "$DB_NAME" -e "CREATE TABLE IF NOT EXISTS seeds_applied (
|
|
||||||
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"
|
||||||
|
|
@ -57,7 +47,7 @@ if [ "${1:-}" = "--status" ]; then
|
||||||
echo "[seed] etat des seeds (base $DB_NAME) :"
|
echo "[seed] etat des seeds (base $DB_NAME) :"
|
||||||
for f in "${files[@]}"; do
|
for f in "${files[@]}"; do
|
||||||
base="$(basename "$f")"
|
base="$(basename "$f")"
|
||||||
n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seeds_applied WHERE filename='$base';")"
|
n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seed_history WHERE filename='$base';")"
|
||||||
[ "$n" = "0" ] && echo " PENDING $base" || echo " loaded $base"
|
[ "$n" = "0" ] && echo " PENDING $base" || echo " loaded $base"
|
||||||
done
|
done
|
||||||
exit 0
|
exit 0
|
||||||
|
|
@ -66,11 +56,11 @@ fi
|
||||||
loaded=0
|
loaded=0
|
||||||
for f in "${files[@]}"; do
|
for f in "${files[@]}"; do
|
||||||
base="$(basename "$f")"
|
base="$(basename "$f")"
|
||||||
n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seeds_applied WHERE filename='$base';")"
|
n="$(db "$DB_NAME" -N -s -e "SELECT COUNT(*) FROM seed_history WHERE filename='$base';")"
|
||||||
if [ "$n" = "0" ]; then
|
if [ "$n" = "0" ]; then
|
||||||
echo "[seed] chargement de $base ..."
|
echo "[seed] chargement de $base ..."
|
||||||
db "$DB_NAME" < "$f"
|
db "$DB_NAME" < "$f"
|
||||||
db "$DB_NAME" -e "INSERT INTO seeds_applied (filename) VALUES ('$base');"
|
db "$DB_NAME" -e "INSERT INTO seed_history (filename) VALUES ('$base');"
|
||||||
loaded=$((loaded + 1))
|
loaded=$((loaded + 1))
|
||||||
else
|
else
|
||||||
echo "[seed] $base deja charge, ignore"
|
echo "[seed] $base deja charge, ignore"
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
-- =============================================================================
|
|
||||||
-- Wakdo — Seed 0006 : boisson de menu = variante 50 cl automatique en Maxi
|
|
||||||
-- =============================================================================
|
|
||||||
-- Purpose : cabler la regle metier "boisson Maxi" sur les donnees seedees, sans
|
|
||||||
-- toucher au code. En menu Maxi, la boisson fontaine doit passer en
|
|
||||||
-- grande (50 cl), comme l'accompagnement passe en Grande Frite.
|
|
||||||
--
|
|
||||||
-- Mecanique reutilisee : product.maxi_variant_product_id (schema 0006),
|
|
||||||
-- deja exploite par OrderRepository::resolveSelections (substitution de
|
|
||||||
-- toute selection de menu au format 'maxi', sans garde sur le slot_type).
|
|
||||||
-- Il suffit donc de POINTER chaque soda fontaine 30 cl vers sa variante
|
|
||||||
-- 50 cl (creee par le seed 0005) : aucune ligne de code serveur a ecrire.
|
|
||||||
-- Le decrement de stock (consumption) frappera la 50 cl, et le snapshot
|
|
||||||
-- de libelle reflechira "<soda> 50cl".
|
|
||||||
--
|
|
||||||
-- Perimetre : seules les boissons fontaine ont une variante 50 cl (Coca Cola, Coca
|
|
||||||
-- Sans Sucres, Fanta Orange, Ice Tea Peche, Ice Tea Citron). Les boissons en
|
|
||||||
-- bouteille (Eau, Jus d'Orange, Jus de Pommes Bio) n'ont pas de variante : elles
|
|
||||||
-- restent en taille standard meme en Maxi (degradation gracieuse, modele fast-food
|
|
||||||
-- usuel). Le surcout Maxi est porte par le menu (price_maxi_cents), pas par la
|
|
||||||
-- boisson : aucune incidence de prix sur ces bouteilles.
|
|
||||||
--
|
|
||||||
-- Phase : depend du schema 0006 (maxi_variant_product_id) ET du seed 0005 (les
|
|
||||||
-- variantes 50 cl doivent exister). Joue donc APRES 0005 (ordre
|
|
||||||
-- lexicographique du runner db/seed.sh).
|
|
||||||
--
|
|
||||||
-- Conventions:
|
|
||||||
-- - Aucun id en dur : la cible est resolue structurellement (la variante 50 cl
|
|
||||||
-- est la ligne dont base_product_id pointe la base et size_cl = 50).
|
|
||||||
-- - IDEMPOTENT : UPDATE ... JOIN convergent (repositionne la meme valeur a chaque
|
|
||||||
-- execution). MariaDB autorise le self-join en UPDATE multi-tables (l'erreur
|
|
||||||
-- 1093 ne vise que les sous-requetes sur la table cible, pas les JOIN).
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
-- Lier chaque boisson de base (30 cl, base_product_id NULL) a sa variante 50 cl.
|
|
||||||
-- La jointure ne matche que les produits ayant une variante de taille 50 cl :
|
|
||||||
-- structurellement, les seules boissons fontaine. Les accompagnements (frites,
|
|
||||||
-- deja relies par 0004) ne sont pas des variantes de taille -> non touches. Les
|
|
||||||
-- bouteilles sans variante 50 cl ne matchent pas -> maxi_variant_product_id reste
|
|
||||||
-- NULL.
|
|
||||||
-- -----------------------------------------------------------------------------
|
|
||||||
UPDATE product AS base
|
|
||||||
JOIN product AS variant
|
|
||||||
ON variant.base_product_id = base.id
|
|
||||||
AND variant.size_cl = 50
|
|
||||||
SET base.maxi_variant_product_id = variant.id
|
|
||||||
WHERE base.base_product_id IS NULL;
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
# Modele de compose de production (derriere un reverse proxy Traefik).
|
|
||||||
#
|
|
||||||
# Entierement pilote par le .env : le meme fichier marche sur n'importe quel hote,
|
|
||||||
# seules les valeurs du .env changent. Sur l'hote de prod :
|
|
||||||
# cp docker-compose.prod.yml.example docker-compose.prod.yml
|
|
||||||
# cp .env.prod.example .env # puis renseigner domaines + mots de passe
|
|
||||||
# docker compose -f docker-compose.prod.yml up -d --build
|
|
||||||
#
|
|
||||||
# Prerequis : le reseau externe ${REVERSE_PROXY_NETWORK} existe (cree par la stack
|
|
||||||
# Traefik de l'hote). Les entrypoints (websecure) et le certresolver (letsencrypt)
|
|
||||||
# doivent correspondre a la config Traefik de l'hote.
|
|
||||||
|
|
||||||
name: wakdo
|
|
||||||
|
|
||||||
networks:
|
|
||||||
wakdo_internal:
|
|
||||||
driver: bridge
|
|
||||||
reverse_proxy:
|
|
||||||
name: ${REVERSE_PROXY_NETWORK}
|
|
||||||
external: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
wakdo_db_data:
|
|
||||||
wakdo_uploads:
|
|
||||||
|
|
||||||
services:
|
|
||||||
|
|
||||||
wakdo-db:
|
|
||||||
image: mariadb:11.4
|
|
||||||
container_name: wakdo-db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
|
||||||
MARIADB_DATABASE: ${DB_NAME}
|
|
||||||
MARIADB_USER: ${DB_USER}
|
|
||||||
MARIADB_PASSWORD: ${DB_PASSWORD}
|
|
||||||
MARIADB_AUTO_UPGRADE: "1"
|
|
||||||
TZ: ${APP_TIMEZONE:-Europe/Paris}
|
|
||||||
volumes:
|
|
||||||
- wakdo_db_data:/var/lib/mysql
|
|
||||||
- ./db/init:/docker-entrypoint-initdb.d:ro
|
|
||||||
networks:
|
|
||||||
- wakdo_internal
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 6
|
|
||||||
start_period: 30s
|
|
||||||
|
|
||||||
wakdo-migrate:
|
|
||||||
image: mariadb:11.4
|
|
||||||
container_name: wakdo-migrate
|
|
||||||
restart: "no"
|
|
||||||
environment:
|
|
||||||
DB_HOST: ${DB_HOST}
|
|
||||||
DB_PORT: ${DB_PORT}
|
|
||||||
DB_NAME: ${DB_NAME}
|
|
||||||
DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- ./db:/db:ro
|
|
||||||
networks:
|
|
||||||
- wakdo_internal
|
|
||||||
depends_on:
|
|
||||||
wakdo-db:
|
|
||||||
condition: service_healthy
|
|
||||||
entrypoint: ["bash", "/db/migrate-container.sh"]
|
|
||||||
|
|
||||||
wakdo-app:
|
|
||||||
build:
|
|
||||||
context: ./docker/php-fpm
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: wakdo-app
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
APP_ENV: ${APP_ENV}
|
|
||||||
APP_DEBUG: ${APP_DEBUG}
|
|
||||||
APP_TIMEZONE: ${APP_TIMEZONE}
|
|
||||||
APP_URL_KIOSK: ${APP_URL_KIOSK}
|
|
||||||
APP_URL_ADMIN: ${APP_URL_ADMIN}
|
|
||||||
DB_HOST: ${DB_HOST}
|
|
||||||
DB_PORT: ${DB_PORT}
|
|
||||||
DB_NAME: ${DB_NAME}
|
|
||||||
DB_USER: ${DB_USER}
|
|
||||||
DB_PASSWORD: ${DB_PASSWORD}
|
|
||||||
SESSION_LIFETIME_IDLE: ${SESSION_LIFETIME_IDLE}
|
|
||||||
SESSION_LIFETIME_ABSOLUTE: ${SESSION_LIFETIME_ABSOLUTE}
|
|
||||||
SESSION_NAME: ${SESSION_NAME}
|
|
||||||
CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN}
|
|
||||||
ARGON2_MEMORY_COST: ${ARGON2_MEMORY_COST}
|
|
||||||
ARGON2_TIME_COST: ${ARGON2_TIME_COST}
|
|
||||||
ARGON2_THREADS: ${ARGON2_THREADS}
|
|
||||||
ACCOUNT_LOCKOUT_THRESHOLD: ${ACCOUNT_LOCKOUT_THRESHOLD}
|
|
||||||
ACCOUNT_LOCKOUT_BASE_SECONDS: ${ACCOUNT_LOCKOUT_BASE_SECONDS}
|
|
||||||
ACCOUNT_LOCKOUT_MAX_SECONDS: ${ACCOUNT_LOCKOUT_MAX_SECONDS}
|
|
||||||
IP_THROTTLE_WINDOW_SECONDS: ${IP_THROTTLE_WINDOW_SECONDS}
|
|
||||||
IP_THROTTLE_MAX_ATTEMPTS: ${IP_THROTTLE_MAX_ATTEMPTS}
|
|
||||||
STAFF_PIN_MIN_LENGTH: ${STAFF_PIN_MIN_LENGTH}
|
|
||||||
STAFF_PIN_MAX_LENGTH: ${STAFF_PIN_MAX_LENGTH}
|
|
||||||
PIN_THROTTLE_THRESHOLD: ${PIN_THROTTLE_THRESHOLD}
|
|
||||||
PIN_THROTTLE_BASE_SECONDS: ${PIN_THROTTLE_BASE_SECONDS}
|
|
||||||
PIN_THROTTLE_MAX_SECONDS: ${PIN_THROTTLE_MAX_SECONDS}
|
|
||||||
PIN_THROTTLE_WINDOW_SECONDS: ${PIN_THROTTLE_WINDOW_SECONDS}
|
|
||||||
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
|
|
||||||
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
|
|
||||||
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
|
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
|
||||||
SMTP_USER: ${SMTP_USER:-}
|
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
|
||||||
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
|
|
||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
|
|
||||||
volumes:
|
|
||||||
- ./src:/var/www/html
|
|
||||||
- wakdo_uploads:/var/www/html/public/uploads
|
|
||||||
networks:
|
|
||||||
- wakdo_internal
|
|
||||||
depends_on:
|
|
||||||
wakdo-migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
wakdo-db:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
wakdo-web:
|
|
||||||
build:
|
|
||||||
context: ./docker/apache
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: wakdo-web
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
APP_HOST_KIOSK: ${APP_HOST_KIOSK}
|
|
||||||
APP_HOST_ADMIN: ${APP_HOST_ADMIN}
|
|
||||||
volumes:
|
|
||||||
- ./src:/var/www/html
|
|
||||||
- wakdo_uploads:/var/www/html/public/uploads
|
|
||||||
networks:
|
|
||||||
- wakdo_internal
|
|
||||||
- reverse_proxy
|
|
||||||
depends_on:
|
|
||||||
wakdo-migrate:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
wakdo-app:
|
|
||||||
condition: service_started
|
|
||||||
wakdo-db:
|
|
||||||
condition: service_healthy
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.docker.network=${REVERSE_PROXY_NETWORK}"
|
|
||||||
- "traefik.http.routers.wakdo-kiosk.rule=Host(`${APP_HOST_KIOSK}`)"
|
|
||||||
- "traefik.http.routers.wakdo-kiosk.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.wakdo-kiosk.tls=true"
|
|
||||||
- "traefik.http.routers.wakdo-kiosk.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.routers.wakdo-kiosk.service=wakdo-kiosk-svc"
|
|
||||||
- "traefik.http.services.wakdo-kiosk-svc.loadbalancer.server.port=80"
|
|
||||||
- "traefik.http.routers.wakdo-admin.rule=Host(`${APP_HOST_ADMIN}`)"
|
|
||||||
- "traefik.http.routers.wakdo-admin.entrypoints=websecure"
|
|
||||||
- "traefik.http.routers.wakdo-admin.tls=true"
|
|
||||||
- "traefik.http.routers.wakdo-admin.tls.certresolver=letsencrypt"
|
|
||||||
- "traefik.http.routers.wakdo-admin.service=wakdo-admin-svc"
|
|
||||||
- "traefik.http.services.wakdo-admin-svc.loadbalancer.server.port=80"
|
|
||||||
|
|
||||||
wakdo-cron:
|
|
||||||
build:
|
|
||||||
context: ./docker/cron
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: wakdo-cron
|
|
||||||
restart: unless-stopped
|
|
||||||
init: true
|
|
||||||
environment:
|
|
||||||
DB_HOST: ${DB_HOST}
|
|
||||||
DB_PORT: ${DB_PORT}
|
|
||||||
DB_NAME: ${DB_NAME}
|
|
||||||
DB_USER: ${DB_USER}
|
|
||||||
DB_PASSWORD: ${DB_PASSWORD}
|
|
||||||
AUDIT_LOG_RETENTION_DAYS: ${AUDIT_LOG_RETENTION_DAYS:-365}
|
|
||||||
THROTTLE_PURGE_AFTER_HOURS: ${THROTTLE_PURGE_AFTER_HOURS:-24}
|
|
||||||
TZ: ${CRON_TIMEZONE:-Europe/Paris}
|
|
||||||
volumes:
|
|
||||||
- ./var/backups:/backups
|
|
||||||
networks:
|
|
||||||
- wakdo_internal
|
|
||||||
depends_on:
|
|
||||||
wakdo-db:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
@ -89,12 +89,6 @@ services:
|
||||||
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
|
PASSWORD_RESET_TTL: ${PASSWORD_RESET_TTL}
|
||||||
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
|
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
|
||||||
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
|
UPLOAD_ALLOWED_MIME: ${UPLOAD_ALLOWED_MIME}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
|
||||||
SMTP_PORT: ${SMTP_PORT:-587}
|
|
||||||
SMTP_USER: ${SMTP_USER:-}
|
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
|
||||||
MAIL_FROM_EMAIL: ${MAIL_FROM_EMAIL:-}
|
|
||||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Wakdo}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/var/www/html
|
- ./src:/var/www/html
|
||||||
- wakdo_uploads:/var/www/html/public/uploads
|
- wakdo_uploads:/var/www/html/public/uploads
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,13 @@ Wakdo simule une borne de commande tactile de restauration rapide, avec back-off
|
||||||
d'administration, workflow cuisine et API REST interne. Deux surfaces applicatives :
|
d'administration, workflow cuisine et API REST interne. Deux surfaces applicatives :
|
||||||
|
|
||||||
- **Borne (kiosk)** — front statique (HTML/CSS/JS vanilla ES6) servi par Apache,
|
- **Borne (kiosk)** — front statique (HTML/CSS/JS vanilla ES6) servi par Apache,
|
||||||
consommant l'API REST DB-backed (`/api/*`). Le repli JSON statique initial a ete
|
consommant des donnees (JSON statique en P5, API DB-backed au swap P4).
|
||||||
retire au profit d'un branchement direct sur l'API.
|
|
||||||
- **Back-office + API** — application PHP rendue serveur (MVC maison) + endpoints
|
- **Back-office + API** — application PHP rendue serveur (MVC maison) + endpoints
|
||||||
`/api/*`, derriere authentification et RBAC.
|
`/api/*`, derriere authentification et RBAC.
|
||||||
|
|
||||||
Trois canaux de commande (`source`) : `kiosk`, `counter`, `drive`. Le cycle de vie
|
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/`. Le domaine
|
d'une commande et la machine a etats sont decrits dans `docs/merise/` (domaine
|
||||||
commande est livre de bout en bout : creation et encaissement via l'API
|
commande = phase **P4**, schema en base mais workflow applicatif a venir).
|
||||||
(`POST /api/orders`, `POST /api/orders/{number}/pay` avec decrement de stock),
|
|
||||||
file cuisine (KDS), annulation et livraison cote back-office, saisie comptoir et
|
|
||||||
drive (POS tactile).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -136,8 +132,8 @@ src/app/
|
||||||
Views/ admin/* (pages back-office rendues serveur), auth/* (login/reset)
|
Views/ admin/* (pages back-office rendues serveur), auth/* (login/reset)
|
||||||
src/public/
|
src/public/
|
||||||
admin/ front controller + assets (CSS/JS) du back-office
|
admin/ front controller + assets (CSS/JS) du back-office
|
||||||
borne/ front kiosk statique (index, categories, products, payment,
|
borne/ front kiosk statique (index, categories, products, product, cart,
|
||||||
confirmation ; panier en panneau persistant) + assets JS modules
|
payment, confirmation) + assets JS modules + data JSON
|
||||||
```
|
```
|
||||||
|
|
||||||
Conventions transverses : controleurs non-`final` (seam de test : sous-classe injectant
|
Conventions transverses : controleurs non-`final` (seam de test : sous-classe injectant
|
||||||
|
|
@ -169,7 +165,7 @@ Vue rendue dans admin/layout (sorties echappees, RG-T15) | ou JSON pour /api/*
|
||||||
```
|
```
|
||||||
|
|
||||||
La borne (kiosk) est servie en statique par Apache ; ses pages consomment les donnees
|
La borne (kiosk) est servie en statique par Apache ; ses pages consomment les donnees
|
||||||
via `fetch` sur l'API DB-backed (`/api/*`).
|
via `fetch` (JSON statique en P5 ; bascule sur `/api/*` DB-backed au swap P4).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -218,7 +214,7 @@ Threat model STRIDE + classification des donnees : `docs/PROJECT_CONTEXT.md` sec
|
||||||
`ingredient`, `product_ingredient`, `allergen`, `ingredient_allergen`, `stock_movement`.
|
`ingredient`, `product_ingredient`, `allergen`, `ingredient_allergen`, `stock_movement`.
|
||||||
- **RBAC / comptes** : `user`, `role`, `permission`, `role_permission`,
|
- **RBAC / comptes** : `user`, `role`, `permission`, `role_permission`,
|
||||||
`role_visible_source`.
|
`role_visible_source`.
|
||||||
- **Commande (livre)** : `customer_order`, `order_item`,
|
- **Commande (P4, schema pret)** : `customer_order`, `order_item`,
|
||||||
`order_item_selection`, `order_item_modifier`.
|
`order_item_selection`, `order_item_modifier`.
|
||||||
- **Transverses** : `audit_log` (journal immuable), `login_throttle`, `pin_throttle`.
|
- **Transverses** : `audit_log` (journal immuable), `login_throttle`, `pin_throttle`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ Client Borne (Bloc 1) API (Bloc 2) BDD
|
||||||
|
|
||||||
### Compatibilite evaluation par bloc
|
### Compatibilite evaluation par bloc
|
||||||
|
|
||||||
- **Jury Bloc 1** : voit le front seul ; le front consomme les donnees via `fetch` sur l'API (`/api/*`). Le fallback JSON statique initialement envisage a ete retire (la borne est branchee directement sur l'API DB-backed).
|
- **Jury Bloc 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 2** : voit le back-office + teste l'API via curl/Postman de maniere autonome, sans dependre du front.
|
||||||
- **Jury Bloc 5** : lance `docker compose up` ou `docker compose up`, verifie la CI/CD, les crons, l'archi, les scripts.
|
- **Jury Bloc 5** : lance `docker compose up` ou `docker compose up`, verifie la CI/CD, les crons, l'archi, les scripts.
|
||||||
|
|
||||||
|
|
@ -205,7 +205,7 @@ Reseaux :
|
||||||
### Bloc 1 — Borne client (Front)
|
### Bloc 1 — Borne client (Front)
|
||||||
|
|
||||||
**IN scope :**
|
**IN scope :**
|
||||||
- Affichage dynamique menus + produits (charges par `fetch` depuis l'API `/api/*`)
|
- Affichage dynamique menus + produits (charges par Ajax depuis API ou JSON fallback)
|
||||||
- Composition panier : produits unitaires OU menus (burger + accompagnement + boisson + sauce)
|
- Composition panier : produits unitaires OU menus (burger + accompagnement + boisson + sauce)
|
||||||
- Options taille (normale / grande, +0,50 € sur grande) pour accompagnements et boissons
|
- Options taille (normale / grande, +0,50 € sur grande) pour accompagnements et boissons
|
||||||
- Options de personnalisation simples (ex : sans oignon, avec fromage)
|
- Options de personnalisation simples (ex : sans oignon, avec fromage)
|
||||||
|
|
@ -232,7 +232,7 @@ Reseaux :
|
||||||
- **Manager** : catalogue (create/update), stock (reappro + inventaire), statistiques ; utilisateurs en **lecture seule** (`user.read`, pas de creation/modification/desactivation), pas d'acces RBAC
|
- **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
|
- **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
|
- **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 : **non implemente** ; prevu (validation type MIME + taille + stockage dans volume `wakdo_uploads`)
|
- Upload images produits (validation type MIME + taille + stockage dans volume `wakdo_uploads`)
|
||||||
- Historique commandes par statut
|
- Historique commandes par statut
|
||||||
- Stats de base (commandes du jour, CA jour, produits top)
|
- Stats de base (commandes du jour, CA jour, produits top)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# ADR-0011 — POS tactile a tuiles pour la saisie comptoir/drive
|
|
||||||
|
|
||||||
- Statut : Accepte
|
|
||||||
- Date : 2026-06-25
|
|
||||||
|
|
||||||
## Contexte
|
|
||||||
La saisie de commande comptoir/drive (mlt 4.1, `CREATE_COUNTER_ORDER`) avait d'abord ete
|
|
||||||
livree comme formulaire-liste enrichi (#100) : une liste de produits avec champs de
|
|
||||||
quantite, prix, verrou du mode de service au canal drive, file des commandes recentes.
|
|
||||||
Cet ecran est destine a des equipiers non-techniques, sur tablette, dans un contexte de
|
|
||||||
caisse ou la rapidite compte. Le formulaire-liste se prete mal au tactile (petites cibles,
|
|
||||||
defilement) et ne ressemble pas au geste deja appris cote borne client. Options :
|
|
||||||
conserver et raffiner le formulaire-liste ; adopter un paradigme de caisse (POS) a tuiles
|
|
||||||
reutilisant l'UX borne ; integrer une bibliotheque de POS tierce.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
La saisie comptoir/drive devient un **POS tactile a tuiles** (#104) calque sur la borne
|
|
||||||
client : onglets categories en haut, grille de tuiles produits/menus, panneau commande
|
|
||||||
persistant a droite ; un tap ajoute le produit, un produit a modificateurs ou un menu
|
|
||||||
ouvre une modale de composition. Le panier est construit cote client en vanilla JS
|
|
||||||
CSP-safe (`'self'`, zero handler inline) a partir d'un script JSON inerte, puis serialise
|
|
||||||
dans un champ cache `items_json`. Le **serveur reste seul juge** : il revalide la forme
|
|
||||||
(RG-T18), recalcule les prix et resout les modificateurs (RG-T16) ; les prix affiches cote
|
|
||||||
client sont indicatifs. Un **seul controleur** sert les deux canaux, la `source`
|
|
||||||
(counter/drive) etant derivee du chemin de la requete.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
- (+) Geste unifie borne/comptoir : moins d'apprentissage, reutilisation des patterns
|
|
||||||
composeur deja eprouves cote borne.
|
|
||||||
- (+) Cibles tactiles larges adaptees a la tablette ; zero jargon dev a l'ecran
|
|
||||||
(utilisateurs non-techniques).
|
|
||||||
- (+) Decoupage par chemin (`/drive...` vs `/counter...`) : les canaux restent etanches,
|
|
||||||
un equipier ne peut pas requalifier sa commande via un champ falsifie.
|
|
||||||
- (+) Sans framework front (coherent ADR-0002) ; coherent avec ADR-0001 (pas de
|
|
||||||
dependance tierce, donc pas de POS externe).
|
|
||||||
- (-) Le POS est interactif par nature : sans JS, la grille ne s'affiche pas (un message
|
|
||||||
invite a activer JS). Un repli legacy `qty_<id>` reste accepte quand `items_json` est
|
|
||||||
absent, mais l'experience cible suppose JS.
|
|
||||||
- (-) Cet ecran a ete refondu deux fois a court intervalle (#100 puis #104) ; le palier
|
|
||||||
#100 est assume comme etape, pas comme dette cachee.
|
|
||||||
- Fichiers : `src/app/Controllers/CounterOrderController.php`,
|
|
||||||
`src/app/Views/admin/counter/new.php`, `src/public/admin/assets/js/counter-order.js`.
|
|
||||||
Detail : journal `docs/journal/2026-06-25--audit-remediation-et-features-94-105.md`.
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
# ADR-0012 — Page Stock en tableau de bord (alertes + reapprovisionnement en avant)
|
|
||||||
|
|
||||||
- Statut : Accepte
|
|
||||||
- Date : 2026-06-25
|
|
||||||
|
|
||||||
## Contexte
|
|
||||||
La page d'accueil Ingredients/Stock (domaine 8.8 + 9) etait une liste-CRUD exhaustive :
|
|
||||||
tous les ingredients, avec les actions creer/modifier/supprimer en premier plan. Elle
|
|
||||||
etait jugee trop chargee et opaque pour un equipier non-technique. Le besoin metier
|
|
||||||
quotidien est de voir vite ce qui manque et de reapprovisionner ; l'edition de fiches est
|
|
||||||
rare. De plus, le lien entre stock et disponibilite borne (un ingredient requis sous le
|
|
||||||
seuil critique rend indisponibles les produits qui l'utilisent, RG-T21, cf. ADR-0003)
|
|
||||||
n'etait pas visible a l'ecran. Options : garder la liste exhaustive triable ; basculer
|
|
||||||
vers un tableau de bord oriente action ; deux pages distinctes (dashboard + gestion).
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
La page Stock devient un **tableau de bord oriente action** (#105) : un bandeau explicite
|
|
||||||
le lien stock -> disponibilite borne (RG-T21) ; un resume compte les ingredients
|
|
||||||
critiques / en alerte / au-dessus du seuil ; une section "A reapprovisionner" met en avant
|
|
||||||
les ingredients bas (critiques d'abord) avec barre de niveau et bouton de
|
|
||||||
reapprovisionnement direct. La liste complete passe au second plan et le CRUD est relegue.
|
|
||||||
Les compteurs par etat sont **calcules cote serveur** dans `IngredientController::index()`
|
|
||||||
a partir du `stock_band` deja resolu par le depot, pour garder la vue declarative et la
|
|
||||||
valeur directement testable. Les sous-pages (reappro, inventaire, mouvements, creation)
|
|
||||||
restent inchangees.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
- (+) L'ecran s'aligne sur le geste quotidien (reperer le manque, reapprovisionner) plutot
|
|
||||||
que sur l'edition de fiches.
|
|
||||||
- (+) Le lien stock -> disponibilite borne (RG-T21) devient explicite a l'ecran, pas
|
|
||||||
seulement dans le code.
|
|
||||||
- (+) Compteurs testables (la logique de comptage est cote serveur, pas dans la vue) :
|
|
||||||
+4 cas de test `IngredientController` (bandeau, promotion d'un critique en section
|
|
||||||
reappro, etat vide positif, compteurs par etat).
|
|
||||||
- (-) La liste exhaustive et le CRUD sont moins immediats (un cran plus loin) : choix
|
|
||||||
assume, ces operations etant moins frequentes que la lecture d'alerte.
|
|
||||||
- (-) Le tri/filtre avance de l'ancienne liste n'est pas reconduit en premier plan.
|
|
||||||
- Coherent avec ADR-0002 (MVC rendu serveur) et ADR-0003 (disponibilite calculee depuis
|
|
||||||
le stock). Fichiers : `src/app/Controllers/IngredientController.php`,
|
|
||||||
`src/app/Views/admin/ingredients/index.php`, `src/public/admin/assets/css/admin.css`.
|
|
||||||
Detail : journal `docs/journal/2026-06-25--audit-remediation-et-features-94-105.md`.
|
|
||||||
|
|
@ -18,8 +18,6 @@ une decision revisee donne une nouvelle fiche qui *supersede* l'ancienne (statut
|
||||||
| [0008](0008-makefile-vers-compose-migrate.md) | Du Makefile a `docker compose up` (service wakdo-migrate) | Accepte |
|
| [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 |
|
| [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 |
|
| [0010](0010-cookie-secure-conditionnel-https.md) | Cookie de session Secure conditionnel au HTTPS | Accepte |
|
||||||
| [0011](0011-pos-tactile-tuiles-comptoir-drive.md) | POS tactile a tuiles pour la saisie comptoir/drive | Accepte |
|
|
||||||
| [0012](0012-page-stock-tableau-de-bord.md) | Page Stock en tableau de bord (alertes + reappro en avant) | Accepte |
|
|
||||||
|
|
||||||
## Modele de fiche
|
## Modele de fiche
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,10 @@ Code de reference : routes dans `src/public/admin/index.php`, controleurs dans
|
||||||
| Famille | Prefixe | Rendu | Authentification | Exemple |
|
| Famille | Prefixe | Rendu | Authentification | Exemple |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Pages back-office | aucun | HTML (vue serveur + `layout.php`) | session admin | `/login`, `/forgot_password` |
|
| 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` (livre) |
|
| API REST | `/api/` | JSON (enveloppe section 7) | selon la ressource (section 10) | `/api/health`, `/api/categories` (prevu) |
|
||||||
|
|
||||||
La borne (kiosk) consomme l'API REST `/api/*` en lecture pour le catalogue (voir section 8.3).
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -106,7 +107,7 @@ is_active) et d'`Authorizer` (RG-T03, permissions rechargees depuis la base). Re
|
||||||
si la session est absente, expiree ou le compte desactive. Les autorisations par operation
|
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).
|
(et le PIN des actions sensibles, RG-T13) se cablent quand les operations existent (P3).
|
||||||
|
|
||||||
### 5.2 API kiosk - lecture catalogue + commande (livre, public)
|
### 5.2 API kiosk - lecture catalogue + commande (prevu P4, public)
|
||||||
|
|
||||||
La borne est publique (aucune session) ; cf. `mlt.md` CREATE_ORDER, declencheur kiosk.
|
La borne est publique (aucune session) ; cf. `mlt.md` CREATE_ORDER, declencheur kiosk.
|
||||||
|
|
||||||
|
|
@ -269,16 +270,18 @@ Codes specifiques nommes par le MLT, en surcharge du socle : `CANNOT_CANCEL_IN_S
|
||||||
`INVALID_TRANSITION` (409) pour l'annulation (`mlt.md` 7.1, `security-sequence.md`). Meme format
|
`INVALID_TRANSITION` (409) pour l'annulation (`mlt.md` 7.1, `security-sequence.md`). Meme format
|
||||||
d'enveloppe.
|
d'enveloppe.
|
||||||
|
|
||||||
### 8.3 Nommage borne vs canonique : le rapprochement dans data.js
|
### 8.3 Divergence connue : repli JSON de la borne
|
||||||
|
|
||||||
Le front de la borne attend un nommage historique heterogene issu des sources de l'ecole
|
Le repli statique de la borne (`src/public/borne/data/categories.json`, `produits.json`) provient
|
||||||
(`title`/`nom`, `prix`, `image`, `type`). L'API sert la forme canonique de 8.1
|
des sources de l'ecole et porte un nommage different et heterogene (`title`/`nom`, `prix`, `image`,
|
||||||
(`/api/categories`, `/api/products`, `/api/menus`, `/api/allergens`). Le rapprochement se fait
|
`type`). Ce contrat est fige par le brief ecole et consomme tel quel par le JS de la borne via
|
||||||
en un point unique : la couche `data.js`, qui deballe l'enveloppe `{ data }` et mappe la forme
|
`data.js`.
|
||||||
canonique vers ce que la borne attend. Les anciens fichiers JSON statiques sous
|
|
||||||
`src/public/borne/data/` ont ete retires.
|
|
||||||
|
|
||||||
| Forme borne | Canonique API / dictionnaire |
|
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` |
|
| `title` (categorie) | `name` |
|
||||||
| `nom` (produit) | `name` |
|
| `nom` (produit) | `name` |
|
||||||
|
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
# Deploiement continu (CD) — Wakdo
|
|
||||||
|
|
||||||
Ce document decrit le deploiement automatique vers la production et la mise en place
|
|
||||||
a faire une seule fois cote serveur. Il complete `scripts/deploy.sh` et
|
|
||||||
`.forgejo/workflows/deploy.yml`.
|
|
||||||
|
|
||||||
## Topologie
|
|
||||||
|
|
||||||
| Hote | Role |
|
|
||||||
|---|---|
|
|
||||||
| **Thanos** (`git.acadenice.com`) | Forge : depot Git + Forgejo Actions |
|
|
||||||
| **Stark** | Environnement de dev ; heberge le runner Forgejo |
|
|
||||||
| **Vision** | Production : la stack Wakdo y tourne, cible du deploiement |
|
|
||||||
|
|
||||||
Le runner (sur Stark) n'a pas acces au socket Docker, par choix de securite : un job
|
|
||||||
CI ne peut pas piloter Docker sur son hote. Le deploiement vers Vision se fait donc
|
|
||||||
par SSH — ce qui correspond au schema normal d'un deploiement vers un hote distant.
|
|
||||||
|
|
||||||
## Flux
|
|
||||||
|
|
||||||
```
|
|
||||||
merge dev -> main (release, deja passee par la CI sur la PR)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Forgejo Actions: workflow Deploy (.forgejo/workflows/deploy.yml)
|
|
||||||
│ ssh deploy@vision (sans commande : forced command cote Vision)
|
|
||||||
▼
|
|
||||||
Vision: scripts/deploy.sh (git ff-only -> VERSION + deploy.log -> compose build/up)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
GET /api/health renvoie le nouveau SHA ← preuve du deploiement
|
|
||||||
```
|
|
||||||
|
|
||||||
## Ce qui est automatise (dans le depot)
|
|
||||||
|
|
||||||
- `.forgejo/workflows/deploy.yml` : sur push `main`, ouvre la session SSH vers Vision.
|
|
||||||
- `scripts/deploy.sh` : recupere `main` (fast-forward), ecrit le marqueur de version
|
|
||||||
(`src/VERSION`) et une ligne dans `deploy.log`, reconstruit et recree la stack.
|
|
||||||
Mode non-interactif via `DEPLOY_YES=1`.
|
|
||||||
- `GET /api/health` expose `version` (SHA) et `deployed_at` (date), lus depuis
|
|
||||||
`src/VERSION`.
|
|
||||||
|
|
||||||
## Mise en place cote Vision (une fois)
|
|
||||||
|
|
||||||
Prerequis : Docker + docker compose, le depot clone (ex. `/srv/wakdo`).
|
|
||||||
|
|
||||||
Le compose et le `.env` de prod ne sont pas versionnes (propres a l'hote) ; ils se
|
|
||||||
derivent des modeles fournis dans le depot :
|
|
||||||
```bash
|
|
||||||
cp docker-compose.prod.yml.example docker-compose.prod.yml
|
|
||||||
cp .env.prod.example .env # puis renseigner domaines + mots de passe + reseau Traefik
|
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
|
||||||
```
|
|
||||||
Le compose est entierement pilote par le `.env` : le meme fichier marche sur tout hote.
|
|
||||||
|
|
||||||
1. Creer un utilisateur dedie au deploiement, membre du groupe `docker` :
|
|
||||||
```bash
|
|
||||||
sudo useradd -m -G docker deploy
|
|
||||||
```
|
|
||||||
2. Lui donner le depot (ou ajuster les droits du clone existant) :
|
|
||||||
```bash
|
|
||||||
sudo chown -R deploy:deploy /srv/wakdo
|
|
||||||
```
|
|
||||||
3. Autoriser la cle CI avec une **forced command** : la cle ne peut lancer que le
|
|
||||||
deploiement, aucune autre commande. Dans `~deploy/.ssh/authorized_keys` :
|
|
||||||
```
|
|
||||||
command="cd /srv/wakdo && DEPLOY_YES=1 scripts/deploy.sh main",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAA...CLE_PUBLIQUE... deploy@wakdo-ci
|
|
||||||
```
|
|
||||||
`deploy.sh` ne lit pas `$SSH_ORIGINAL_COMMAND` : meme si un appel SSH tentait de
|
|
||||||
passer une autre commande, elle serait ignoree.
|
|
||||||
|
|
||||||
## Generer la cle et la connaitre cote forge
|
|
||||||
|
|
||||||
Sur un poste de confiance :
|
|
||||||
```bash
|
|
||||||
ssh-keygen -t ed25519 -f wakdo-deploy -C "deploy@wakdo-ci" -N ""
|
|
||||||
# wakdo-deploy -> cle PRIVEE (secret de la forge, ci-dessous)
|
|
||||||
# wakdo-deploy.pub -> cle PUBLIQUE (authorized_keys de Vision, etape 3)
|
|
||||||
|
|
||||||
ssh-keyscan -t ed25519 <hote-vision> # -> contenu du secret DEPLOY_KNOWN_HOSTS
|
|
||||||
```
|
|
||||||
|
|
||||||
## Secrets et variables a creer sur la forge
|
|
||||||
|
|
||||||
Depot -> Settings -> Actions -> Secrets / Variables :
|
|
||||||
|
|
||||||
| Type | Nom | Valeur |
|
|
||||||
|---|---|---|
|
|
||||||
| Secret | `DEPLOY_SSH_KEY` | contenu de la cle privee `wakdo-deploy` |
|
|
||||||
| Secret | `DEPLOY_KNOWN_HOSTS` | sortie de `ssh-keyscan` (cle d'hote de Vision) |
|
|
||||||
| Secret | `DEPLOY_HOST` | nom/IP de Vision |
|
|
||||||
| Variable | `DEPLOY_USER` | `deploy` |
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. Faire une release (`dev -> main`).
|
|
||||||
2. Suivre le workflow **Deploy** dans l'interface de la forge (il se declenche au push
|
|
||||||
sur `main`).
|
|
||||||
3. Interroger la sonde et lire la version deployee :
|
|
||||||
```bash
|
|
||||||
curl -s https://<fqdn-admin-prod>/api/health
|
|
||||||
# { ... "version": "<sha>", "deployed_at": "<date>" }
|
|
||||||
```
|
|
||||||
Le `version` correspond au HEAD de `main` apres la release — preuve que Vision a ete
|
|
||||||
mise a jour sans intervention manuelle.
|
|
||||||
|
|
||||||
## Notes de securite
|
|
||||||
|
|
||||||
- Cle SSH dediee au seul deploiement, **forced command** + options `no-*` qui retirent
|
|
||||||
shell, tunnels et forwarding.
|
|
||||||
- Cle d'hote **epinglee** (`DEPLOY_KNOWN_HOSTS`, `StrictHostKeyChecking=yes`) : pas de
|
|
||||||
confiance a la premiere connexion.
|
|
||||||
- Secrets stockes cote forge, hors du depot. `.env` et `docker-compose.prod.yml`
|
|
||||||
restent gitignores.
|
|
||||||
- Le runner n'a pas le socket Docker : un job ne peut pas agir sur Docker localement.
|
|
||||||
|
|
@ -23,12 +23,9 @@ Accueil
|
||||||
-> Remerciement
|
-> Remerciement
|
||||||
```
|
```
|
||||||
|
|
||||||
Le kiosk construit a desormais rejoint ce paradigme : l'ecran de commande
|
Le kiosk construit, lui, eclate cet ecran unique en **pages distinctes** et n'a
|
||||||
(`products.html`) porte un **panneau de commande persistant** a droite, les options
|
pas de panneau de commande persistant. C'est l'origine du sentiment "ca ne
|
||||||
produit et le composeur de menu s'ouvrent **en modale** par-dessus la grille, et le
|
correspond pas".
|
||||||
**chevalet** (saisie du numero de table) s'ouvre en modale au paiement sur place. Les
|
|
||||||
pages intermediaires `product.html` et `cart.html` du premier jet ont ete retirees.
|
|
||||||
Cette note garde la trace de la decomposition maquette -> code et des ecarts resorbes.
|
|
||||||
|
|
||||||
## 2. Decomposition ecran par ecran
|
## 2. Decomposition ecran par ecran
|
||||||
|
|
||||||
|
|
@ -90,29 +87,25 @@ Cette note garde la trace de la decomposition maquette -> code et des ecarts res
|
||||||
| Maquette | Kiosk construit | Verdict |
|
| Maquette | Kiosk construit | Verdict |
|
||||||
|----------|-----------------|---------|
|
|----------|-----------------|---------|
|
||||||
| 1. Accueil sur place / a emporter | `index.html` | conforme |
|
| 1. Accueil sur place / a emporter | `index.html` | conforme |
|
||||||
| 2 + 6. Ecran de commande unique (bandeau + grille + **panneau persistant**) | `products.html` : bandeau categories (`category-strip.js`) + grille + **panneau de commande persistant** a droite (`order-panel.js`) | conforme |
|
| 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) |
|
| (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` : composeur **modal pilote par les slots** de `/api/menus/{id}` (format Maxi puis 1 etape par slot) | conforme |
|
| 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-options.js` : **modale** d'options (taille R4 + stepper de quantite) au-dessus de la grille | conforme |
|
| 8. Modale d'option produit (taille + quantite) | `product.html` (page) | divergence : page au lieu de modale |
|
||||||
| 9. Ecran **chevalet** dedie (saisie numero) | **modale chevalet** au paiement sur place (`page-payment.js`), numero pose via l'API ; rappele en confirmation | conforme |
|
| 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 |
|
| (aucun ecran de paiement) | `payment.html` "Carte bancaire / Especes" | ecran **ajoute** par le build |
|
||||||
| 10. Remerciement | `confirmation.html` | conforme |
|
| 10. Remerciement | `confirmation.html` | conforme |
|
||||||
|
|
||||||
## 4. Ecarts structurants (resorbes)
|
## 4. Ecarts structurants (le fond du sujet)
|
||||||
|
|
||||||
Les ecarts structurants du premier jet ont ete realignes sur la maquette :
|
1. **Paradigme inverse.** Maquette = **mono-ecran** (un plan de commande avec
|
||||||
|
categories en bandeau et un panneau recapitulatif persistant a droite, modales
|
||||||
1. **Paradigme.** L'ecran de commande (`products.html`) suit le plan mono-ecran de
|
par-dessus). Build = **multi-pages** classiques (categories -> produits ->
|
||||||
la maquette : categories en bandeau (`category-strip.js`), grille produits, et
|
produit -> panier). C'est l'ecart structurant principal.
|
||||||
panneau recapitulatif persistant a droite ; les options et le composeur de menu
|
2. **Panneau de commande lateral absent.** La piece centrale de la maquette
|
||||||
s'ouvrent en modale par-dessus. Les pages `product.html` et `cart.html` du
|
(numero de commande, lignes editables avec corbeille, TOTAL ttc, Abandon /
|
||||||
premier jet ont ete retirees.
|
Payer, visible en permanence) n'est pas presente dans le build.
|
||||||
2. **Panneau de commande lateral.** La piece centrale de la maquette (numero de
|
3. **Composition de menu.** Maquette = assistant modal en etapes ; build =
|
||||||
commande, lignes editables avec quantite et retrait, TOTAL ttc, Abandon / Payer)
|
composition libre cote client (`page-product-menu.js`).
|
||||||
est rendue par `order-panel.js`, visible en permanence sur l'ecran de commande.
|
|
||||||
3. **Composition de menu.** Le composeur (`page-product-menu.js`) est un assistant
|
|
||||||
modal en etapes pilote par les slots de `/api/menus/{id}` (format Maxi puis une
|
|
||||||
etape par slot), conforme a l'enchainement de la maquette.
|
|
||||||
|
|
||||||
## 5. Rebrand McDonald's -> Wakdo
|
## 5. Rebrand McDonald's -> Wakdo
|
||||||
|
|
||||||
|
|
@ -123,8 +116,6 @@ note n'est donc pas le rebrand mais la **structure** des ecrans.
|
||||||
|
|
||||||
## 6. Suite
|
## 6. Suite
|
||||||
|
|
||||||
Le re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories
|
Re-alignement du kiosk sur la maquette (panneau persistant + bandeau categories +
|
||||||
+ composeur en modale + chevalet en modale) est livre. La borne lit le catalogue
|
composeur en modale) = chantier UI conduit via un cycle FD dedie. Backlog des
|
||||||
via l'API REST (`/api/categories|products|menus|allergens`). Reste a faire : la
|
divergences = section 3 ci-dessus.
|
||||||
generation dynamique de l'ecran categories depuis `GET /api/categories` (section 3,
|
|
||||||
ecran categories) et le polissage visuel du rebrand Wakdo.
|
|
||||||
|
|
|
||||||
|
|
@ -1,210 +0,0 @@
|
||||||
# 2026-06-25 — Synthese : CD prod, SMTP reel, durcissement borne, POS comptoir/drive, dashboard stock (#94-#105)
|
|
||||||
|
|
||||||
**Auteur : BYAN.** Retrospective de synthese couvrant douze PR mergees apres la session
|
|
||||||
du 2026-06-18 (front login + amorce P4 commande). Elles se regroupent en quatre fils :
|
|
||||||
mise en production reelle (#94-#97), finition du parcours borne client (#98, #99, #101,
|
|
||||||
#102, #103), et refonte de la saisie comptoir/drive et de la page stock cote back-office
|
|
||||||
(#100, #104, #105). Entree descriptive : on decrit ce qui est livre, pas ce qui est
|
|
||||||
promis.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ce qui a ete livre (PR mergees)
|
|
||||||
|
|
||||||
| PR | Bloc | Objet |
|
|
||||||
|----|------|-------|
|
|
||||||
| #94 | CD | Deploiement push-based vers Vision (prod) + preuve de version dans `GET /api/health` |
|
|
||||||
| #95 | CD | Modeles versionnes `docker-compose.prod.yml.example` + `.env.prod.example` |
|
|
||||||
| #96 | Auth | Envoi reel de l'email de reset via relais SMTP (Brevo) — client SMTP maison |
|
|
||||||
| #97 | CD | Passage des variables SMTP/MAIL au conteneur `wakdo-app` (correctif #96) |
|
|
||||||
| #98 | Borne | Menu Maxi agrandit la boisson en 50cl + transport du format choisi |
|
|
||||||
| #99 | Borne | Produit/menu en rupture de stock rendu non commandable (RG-T21) |
|
|
||||||
| #100 | Back-office | Refonte saisie comptoir/drive : prix, verrou du mode, navigation, file |
|
|
||||||
| #101 | Borne | Panier unique = panneau persistant (retrait de `cart.html` et `product.html`) |
|
|
||||||
| #102 | Borne | Confirmation avant l'abandon de la commande |
|
|
||||||
| #103 | Borne | Bascule des allergenes sur `/api/allergens` + menage des donnees/docs statiques |
|
|
||||||
| #104 | Back-office | Saisie comptoir/drive en POS tactile a tuiles (refonte de #100) |
|
|
||||||
| #105 | Back-office | Page Stock en tableau de bord (alertes + reapprovisionnement en avant) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bloc 1 — Mise en production reelle (#94, #95, #96, #97)
|
|
||||||
|
|
||||||
### Ce qui a ete fait
|
|
||||||
- **CD push-based (#94)** : `.forgejo/workflows/deploy.yml` ouvre, sur push `main`, une
|
|
||||||
session SSH vers Vision (l'hote de prod). `scripts/deploy.sh` y recupere `main` en
|
|
||||||
fast-forward, ecrit un marqueur de version (`src/VERSION` : SHA + date), journalise une
|
|
||||||
ligne dans `deploy.log`, puis reconstruit et recree la stack. `GET /api/health` expose
|
|
||||||
desormais `version` et `deployed_at`, lus depuis ce marqueur : c'est la preuve cote app
|
|
||||||
qu'un deploiement a bien repris le dernier commit. Doc : `docs/architecture/deployment.md`.
|
|
||||||
- **Modeles de prod versionnes (#95)** : `docker-compose.prod.yml.example` et
|
|
||||||
`.env.prod.example` entrent au depot comme gabarits. Le fichier reel reste gitignore
|
|
||||||
(specifique a l'hote : Traefik, reseau externe), conformement a ADR-0009 ; le `.example`
|
|
||||||
documente la forme attendue.
|
|
||||||
- **SMTP reel (#96, #97)** : la reinitialisation de mot de passe envoyait jusque-la un mail
|
|
||||||
inerte. Un client SMTP maison (`SmtpClient`, `SmtpMailer`, transport via flux PHP
|
|
||||||
`StreamSmtpTransport` derriere l'interface `SmtpTransport`) parle a un relais reel
|
|
||||||
(Brevo en l'occurrence). `PasswordResetController` s'y branche. #97 corrige un oubli :
|
|
||||||
les variables SMTP/MAIL n'etaient pas transmises au conteneur `wakdo-app` (declarees
|
|
||||||
dans les deux fichiers compose).
|
|
||||||
|
|
||||||
### Pourquoi — decisions et alternatives
|
|
||||||
- **Decision : CD par SSH, pas par Docker-in-CI.** Le runner Forgejo (sur Stark) n'a pas
|
|
||||||
acces au socket Docker, par choix de securite : un job CI ne pilote pas Docker sur son
|
|
||||||
hote. Le deploiement vers Vision se fait donc par SSH avec une *forced command* cote
|
|
||||||
serveur. *Alternative ecartee* : donner le socket Docker au runner — rejetee pour la
|
|
||||||
surface d'attaque. C'est le prolongement de la decision E2E-CI du 2026-06-18 (meme
|
|
||||||
contrainte de socket).
|
|
||||||
- **Decision : client SMTP maison plutot qu'une bibliotheque.** ADR-0001 fige le projet
|
|
||||||
sans Composer ni dependance tierce ; un client SMTP minimal (EHLO/AUTH/MAIL/RCPT/DATA
|
|
||||||
sur flux) reste coherent avec cette contrainte et reste testable via un faux transport
|
|
||||||
(`FakeSmtpTransport`). *Alternative ecartee* : `mail()` de PHP — sans relais
|
|
||||||
authentifie, la delivrabilite est aleatoire et la configuration sort du depot.
|
|
||||||
- **Decision : marqueur de version dans `src/VERSION` lu a chaud.** Le marqueur est sous
|
|
||||||
le mount du code (`./src` -> `/var/www/html`), donc relu sans rebuild. Cela donne une
|
|
||||||
preuve de deploiement observable de l'exterieur sans instrumentation supplementaire.
|
|
||||||
|
|
||||||
### Criteres RNCP couverts
|
|
||||||
- **Bloc 3 - deploiement** : chaine de livraison continue tracable (`deploy.yml`,
|
|
||||||
`deploy.sh`, `deployment.md`), preuve de version exposee.
|
|
||||||
- **Bloc 1 - securite** : separation runner/prod sans socket Docker ; secrets de prod
|
|
||||||
hors du depot (gabarits `.example` seulement).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bloc 2 — Finition du parcours borne client (#98, #99, #101, #102, #103)
|
|
||||||
|
|
||||||
### Ce qui a ete fait
|
|
||||||
- **Menu Maxi -> boisson 50cl (#98)** : choisir le format Maxi d'un menu fait passer la
|
|
||||||
boisson de 33cl a 50cl. La migration `0007_product_size_variant` et le seed
|
|
||||||
`0006_drink_maxi_variant` portent la variante en base ; le format choisi est transporte
|
|
||||||
jusqu'au paiement (`checkout.js`, `page-product-menu.js`). Tests `OrderRepository` +
|
|
||||||
tests JS du composeur.
|
|
||||||
- **Rupture non commandable (#99, RG-T21)** : un produit (ou un menu dont un composant
|
|
||||||
requis) sous le seuil critique de stock est marque indisponible et non ajoutable a la
|
|
||||||
borne ; le serveur refuse aussi la creation cote `OrderRepository` (defense en
|
|
||||||
profondeur, le client n'est pas seul juge). Visuel d'indisponibilite cote `style.css`.
|
|
||||||
- **Panier unique = panneau persistant (#101)** : suppression des pages `cart.html` et
|
|
||||||
`product.html` ; le panier devient un panneau lateral persistant (`order-panel.js`)
|
|
||||||
present sur les pages au lieu d'une page dediee. Le net du diff est negatif
|
|
||||||
(-784 lignes) : c'est une simplification de l'architecture front borne.
|
|
||||||
- **Confirmation d'abandon (#102)** : abandonner la commande ouvre une modale de
|
|
||||||
confirmation (`confirm-modal.js`) au lieu de vider le panier au premier clic.
|
|
||||||
- **Allergenes via API (#103)** : la borne lisait des fichiers JSON statiques
|
|
||||||
(`allergens.json`, `categories.json`, `produits.json`) ; elle consomme desormais
|
|
||||||
`/api/allergens` (et le catalogue par API). Les JSON statiques et la doc afferente sont
|
|
||||||
retires (menage). `docs/api/conventions.md` et `docs/design/maquette-vs-build.md` mis a
|
|
||||||
jour.
|
|
||||||
|
|
||||||
### Pourquoi — decisions et alternatives
|
|
||||||
- **Decision : rupture controlee cote serveur ET cote client (#99).** L'indisponibilite
|
|
||||||
est calculee au plus pres de la verite (RG-T21, ADR-0003) ; le client la reflete pour
|
|
||||||
l'UX, mais `OrderRepository` revalide a la creation. *Alternative ecartee* : masquer
|
|
||||||
cote client seulement — laisse une fenetre ou une commande forgee passerait.
|
|
||||||
- **Decision : panier persistant plutot que page panier (#101).** Sur une borne, l'aller-
|
|
||||||
retour vers une page panier ajoute une etape ; un panneau visible en permanence reduit
|
|
||||||
la navigation. Le retrait de deux pages reduit aussi la surface a maintenir.
|
|
||||||
- **Decision : source de verite unique pour les donnees borne (#103).** Les JSON statiques
|
|
||||||
dupliquaient le catalogue de la base ; ils pouvaient diverger silencieusement. Passer par
|
|
||||||
l'API supprime la duplication et fait converger borne et back-office sur le meme modele.
|
|
||||||
|
|
||||||
### Criteres RNCP couverts
|
|
||||||
- **Bloc 2 - front client** : parcours borne complet (composeur, panier, abandon,
|
|
||||||
indisponibilite) sur donnees reelles par API.
|
|
||||||
- **Bloc 1 - regles metier** : RG-T21 (disponibilite calculee) appliquee de bout en bout.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bloc 3 — Saisie comptoir/drive en POS tactile (#100 puis #104)
|
|
||||||
|
|
||||||
### Ce qui a ete fait
|
|
||||||
- **#100** a d'abord refondu la saisie comptoir/drive en tant que formulaire enrichi :
|
|
||||||
prix affiches, verrou du mode de service au canal drive, navigation, file des commandes
|
|
||||||
recentes. `CounterOrderController` derive la `source` (counter/drive) du chemin de la
|
|
||||||
requete ; ajout de la liste des commandes recentes par canal.
|
|
||||||
- **#104** a ensuite remplace ce formulaire-liste par un **POS tactile a tuiles** facon
|
|
||||||
borne : onglets categories en haut, grille de tuiles produits/menus, panneau commande
|
|
||||||
persistant a droite. Pense pour la tablette (grandes cibles, un tap = ajout). Le panier
|
|
||||||
est construit cote client (`counter-order.js`, CSP `'self'`, vanilla, zero handler
|
|
||||||
inline) a partir d'un script JSON inerte, puis serialise dans un champ cache
|
|
||||||
`items_json`. Le serveur revalide la forme (RG-T18), recalcule les prix (RG-T16) et
|
|
||||||
resout les modificateurs : les prix cote client sont indicatifs.
|
|
||||||
|
|
||||||
### Pourquoi — decisions et alternatives
|
|
||||||
- **Decision : POS a tuiles, reutilisant l'UX borne (#104).** Cf. ADR-0011. L'equipier
|
|
||||||
comptoir et le client borne font le meme geste (choisir des produits, composer) ; un
|
|
||||||
meme paradigme tuiles+panneau reduit l'apprentissage et reutilise les patterns deja
|
|
||||||
eprouves. *Alternative ecartee* : garder le formulaire-liste (#100) — moins adapte au
|
|
||||||
tactile et a la rapidite attendue d'une caisse.
|
|
||||||
- **Decision : serveur seul juge des prix et de la composition.** Le client propose, le
|
|
||||||
serveur fige (RG-T16). Coherent avec le reste du domaine commande.
|
|
||||||
- **Decision : un controleur pour deux canaux, source derivee du chemin.** Le decoupage
|
|
||||||
par chemin (`/drive...` vs `/counter...`) plutot que par parametre rend les deux canaux
|
|
||||||
etanches : un equipier ne peut pas requalifier sa commande en falsifiant un champ.
|
|
||||||
|
|
||||||
### Comment — points techniques cles
|
|
||||||
- `CounterOrderController` : `source()` lue depuis le chemin ; `store()` decode
|
|
||||||
`items_json`, revalide, delegue a `createStaffOrder` (commande creee directement `paid`,
|
|
||||||
encaissement immediat, sans PIN — la permission `order.create` suffit). Repli legacy
|
|
||||||
`qty_<id>` accepte quand `items_json` est absent (degradation sans JS).
|
|
||||||
- `counter-order.js` : construction des onglets/grille/panneau, modale de composition pour
|
|
||||||
produits a modificateurs et menus, serialisation a la soumission.
|
|
||||||
|
|
||||||
### Criteres RNCP couverts
|
|
||||||
- **Bloc 2 - back-office** : ecran de caisse pour equipiers non-techniques (zero jargon),
|
|
||||||
CSP-safe sans framework front.
|
|
||||||
- **Bloc 1 - regles metier** : recalcul/revalidation serveur (RG-T16, RG-T18), etancheite
|
|
||||||
des canaux.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bloc 4 — Page Stock en tableau de bord (#105)
|
|
||||||
|
|
||||||
### Ce qui a ete fait
|
|
||||||
La page d'accueil Ingredients/Stock, jugee trop chargee et opaque, est refondue en
|
|
||||||
tableau de bord. Elle porte desormais : un bandeau expliquant le lien stock ->
|
|
||||||
disponibilite borne (un ingredient requis sous le seuil critique rend indisponibles les
|
|
||||||
produits qui l'utilisent, RG-T21) ; un resume comptant les ingredients critiques / en
|
|
||||||
alerte / au-dessus du seuil ; une section "A reapprovisionner" mettant en avant les
|
|
||||||
ingredients bas (critiques d'abord) avec barre de niveau et bouton de reapprovisionnement
|
|
||||||
direct. La liste complete passe au second plan et le CRUD est relegue. Les sous-pages
|
|
||||||
(reappro, inventaire, mouvements, creation) restent inchangees. `index()` expose des
|
|
||||||
compteurs par etat, calcules cote serveur a partir de `stock_band` deja resolu par le
|
|
||||||
depot, pour garder la vue declarative et la valeur testable.
|
|
||||||
|
|
||||||
### Pourquoi — decisions et alternatives
|
|
||||||
Cf. ADR-0012. **Decision : dashboard oriente action plutot que liste-CRUD.** Le metier
|
|
||||||
quotidien d'un equipier stock est de voir vite ce qui manque et de reapprovisionner, pas
|
|
||||||
d'editer des fiches. Mettre les alertes et le bouton de reappro en avant aligne l'ecran
|
|
||||||
sur ce geste. *Alternative ecartee* : garder une liste exhaustive triable — exhaustive
|
|
||||||
mais muette sur l'urgence.
|
|
||||||
|
|
||||||
### Criteres RNCP couverts
|
|
||||||
- **Bloc 2 - back-office** : ergonomie orientee tache pour utilisateur non-technique.
|
|
||||||
- **Bloc 1 - regles metier** : lien stock -> disponibilite (RG-T21) rendu explicite a
|
|
||||||
l'ecran.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verifications
|
|
||||||
A la cloture du lot : suite JS 135 tests verte (verifiee en local), suites PHPUnit et PHPStan niveau 6 vertes en CI Forgejo,
|
|
||||||
`php -l` propre. Chaque PR est passee par la CI Forgejo (checks requis) avant merge natif.
|
|
||||||
|
|
||||||
## Points d'amelioration conscients
|
|
||||||
- **#100 puis #104** : la refonte POS (#104) remplace une premiere refonte (#100) du meme
|
|
||||||
ecran a quelques jours d'intervalle. Le formulaire enrichi de #100 a servi de palier
|
|
||||||
avant le pivot tactile ; l'iteration est assumee plutot que masquee.
|
|
||||||
- **SMTP** : le client maison couvre le cas d'usage reset (un destinataire, texte). Il
|
|
||||||
n'est pas un agent mail generaliste ; tout besoin plus large (pieces jointes, files)
|
|
||||||
serait a reevaluer.
|
|
||||||
|
|
||||||
## Liens vers artefacts
|
|
||||||
- Commits : `8c5d942` (#94) -> `03ef99d` (#105).
|
|
||||||
- ADR associes : `docs/adr/0011-pos-tactile-tuiles-comptoir-drive.md`,
|
|
||||||
`docs/adr/0012-page-stock-tableau-de-bord.md`.
|
|
||||||
- Fichiers principaux : `.forgejo/workflows/deploy.yml`, `scripts/deploy.sh`,
|
|
||||||
`src/app/Controllers/HealthController.php`, `src/app/Auth/SmtpClient.php`,
|
|
||||||
`src/app/Controllers/CounterOrderController.php`,
|
|
||||||
`src/public/admin/assets/js/counter-order.js`,
|
|
||||||
`src/app/Controllers/IngredientController.php`,
|
|
||||||
`src/app/Views/admin/ingredients/index.php`.
|
|
||||||
|
|
@ -33,13 +33,8 @@ Les fichiers sont ordonnes chronologiquement par leur nom.
|
||||||
| 2026-06-04 | [conception-prodlike-revision](2026-06-04--conception-prodlike-revision.md) | Revue d'alignement P1 + decisions prod-like du modele de donnees (drop commande_event, nommage EN, TVA par produit apres fact-check BOFiP, perso menus/ingredients, allergenes, ~16 entites) | `feat/p1-conception` |
|
| 2026-06-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-15 | [p3-throttle-pin-rg-t22](2026-06-15--p3-throttle-pin-rg-t22.md) | P3 securite : throttle du PIN d'action sensible (RG-T22) — design multi-agents + verification adversariale, dimension "utilisateur agissant", entite 22 `pin_throttle` | `feat/p3-pin-throttle` -> `dev` |
|
||||||
| 2026-06-16 | [audit-reel-livrables-p2-p3](2026-06-16--audit-reel-livrables-p2-p3.md) | Verification sur pieces des livrables du 2026-06-15 (sweep 10 dimensions + adversarial) : socle SbD confirme, miss confirmes par gravite (php.ini non deploye, CI sans tests DB, XSS kiosk, liens morts...) et remediations | `docs/journal-audit-2026-06-16` -> `dev` (PR #19/#20/#21) |
|
| 2026-06-16 | [audit-reel-livrables-p2-p3](2026-06-16--audit-reel-livrables-p2-p3.md) | Verification sur pieces des livrables du 2026-06-15 (sweep 10 dimensions + adversarial) : socle SbD confirme, miss confirmes par gravite (php.ini non deploye, CI sans tests DB, XSS kiosk, liens morts...) et remediations | `docs/journal-audit-2026-06-16` -> `dev` (PR #19/#20/#21) |
|
||||||
| 2026-06-04 | [p1-merise-v0.2-rewrite-and-forgejo-migration](2026-06-04--p1-merise-v0.2-rewrite-and-forgejo-migration.md) | P1 Merise v0.2 (prod-like) reecrit + migration vers Forgejo auto-heberge | `feat/p1-conception` |
|
|
||||||
| 2026-06-17 | [makefile-to-compose-migrate](2026-06-17--makefile-to-compose-migrate.md) | Du Makefile a `docker compose up` : service one-shot `wakdo-migrate` (migrate + seed idempotents) | `feat/compose-migrate` -> `dev` |
|
|
||||||
| 2026-06-17 | [session-infra-doc-e2e](2026-06-17--session-infra-doc-e2e.md) | Session infra compose, documentation Forge, amorce des tests E2E Playwright | `dev` (PR #37/#38/#39 et suivantes) |
|
|
||||||
| 2026-06-18 | [front-login-ui-admin-p4-commande](2026-06-18--front-login-ui-admin-p4-commande.md) | Page login, refonte UI admin (equipiers non-techniques), humanisation des libelles, amorce P4 commande (creation + encaissement) | `dev` (PR #48 a #58) |
|
|
||||||
| 2026-06-25 | [audit-remediation-et-features-94-105](2026-06-25--audit-remediation-et-features-94-105.md) | Synthese #94-#105 : CD push-based vers Vision + preuve de version, modeles compose prod, SMTP reel reset (Brevo), durcissement borne (Maxi 50cl, rupture non commandable, panier persistant, confirm abandon, allergenes /api), POS tactile comptoir/drive, page Stock en tableau de bord | `dev`/`main` (PR #94 a #105) |
|
|
||||||
|
|
||||||
*Mis a jour a chaque nouvelle entree. Les entrees sont ordonnees par leur nom de fichier (date) ; cet index les liste dans l'ordre de redaction.*
|
*Mis a jour a chaque nouvelle entree.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,6 @@ erDiagram
|
||||||
varchar name
|
varchar name
|
||||||
text description
|
text description
|
||||||
int price_cents
|
int price_cents
|
||||||
int maxi_variant_product_id FK
|
|
||||||
smallint size_cl
|
|
||||||
int base_product_id FK
|
|
||||||
smallint vat_rate
|
smallint vat_rate
|
||||||
varchar image_path
|
varchar image_path
|
||||||
tinyint is_available
|
tinyint is_available
|
||||||
|
|
@ -49,8 +46,6 @@ erDiagram
|
||||||
category ||--o{ product : "groups"
|
category ||--o{ product : "groups"
|
||||||
category ||--o{ menu : "groups"
|
category ||--o{ menu : "groups"
|
||||||
menu ||--|| product : "anchors (burger_product_id)"
|
menu ||--|| product : "anchors (burger_product_id)"
|
||||||
product ||--o{ product : "maxi_variant (maxi_variant_product_id)"
|
|
||||||
product ||--o{ product : "size_variant_of (base_product_id)"
|
|
||||||
menu ||--o{ menu_slot : "defines_slot"
|
menu ||--o{ menu_slot : "defines_slot"
|
||||||
menu_slot ||--o{ menu_slot_option : "lists"
|
menu_slot ||--o{ menu_slot_option : "lists"
|
||||||
product ||--o{ menu_slot_option : "is_eligible_for"
|
product ||--o{ menu_slot_option : "is_eligible_for"
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@ erDiagram
|
||||||
int stock_capacity
|
int stock_capacity
|
||||||
smallint pack_size
|
smallint pack_size
|
||||||
varchar pack_label
|
varchar pack_label
|
||||||
smallint energy_kcal_100g
|
|
||||||
varchar nutrition_source
|
|
||||||
datetime nutrition_fetched_at
|
|
||||||
smallint low_stock_pct
|
smallint low_stock_pct
|
||||||
smallint critical_stock_pct
|
smallint critical_stock_pct
|
||||||
tinyint is_active
|
tinyint is_active
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ erDiagram
|
||||||
enum source
|
enum source
|
||||||
int acting_user_id FK
|
int acting_user_id FK
|
||||||
enum service_mode
|
enum service_mode
|
||||||
varchar service_tag
|
|
||||||
enum status
|
enum status
|
||||||
int total_ht_cents
|
int total_ht_cents
|
||||||
int total_vat_cents
|
int total_vat_cents
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@ erDiagram
|
||||||
int category_id FK
|
int category_id FK
|
||||||
varchar name
|
varchar name
|
||||||
int price_cents
|
int price_cents
|
||||||
int maxi_variant_product_id FK
|
|
||||||
smallint size_cl
|
|
||||||
int base_product_id FK
|
|
||||||
smallint vat_rate
|
smallint vat_rate
|
||||||
tinyint is_available
|
tinyint is_available
|
||||||
smallint display_order
|
smallint display_order
|
||||||
|
|
@ -44,8 +41,6 @@ erDiagram
|
||||||
category ||--o{ product : "category_id (RESTRICT)"
|
category ||--o{ product : "category_id (RESTRICT)"
|
||||||
category ||--o{ menu : "category_id (RESTRICT)"
|
category ||--o{ menu : "category_id (RESTRICT)"
|
||||||
product ||--o{ menu : "burger_product_id (RESTRICT)"
|
product ||--o{ menu : "burger_product_id (RESTRICT)"
|
||||||
product ||--o{ product : "maxi_variant_product_id (SET NULL)"
|
|
||||||
product ||--o{ product : "base_product_id (CASCADE)"
|
|
||||||
menu ||--o{ menu_slot : "menu_id (CASCADE)"
|
menu ||--o{ menu_slot : "menu_id (CASCADE)"
|
||||||
menu_slot ||--o{ menu_slot_option : "menu_slot_id (CASCADE)"
|
menu_slot ||--o{ menu_slot_option : "menu_slot_id (CASCADE)"
|
||||||
product ||--o{ menu_slot_option : "product_id (RESTRICT)"
|
product ||--o{ menu_slot_option : "product_id (RESTRICT)"
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ erDiagram
|
||||||
int stock_quantity
|
int stock_quantity
|
||||||
int stock_capacity
|
int stock_capacity
|
||||||
smallint pack_size
|
smallint pack_size
|
||||||
smallint energy_kcal_100g
|
|
||||||
varchar nutrition_source
|
|
||||||
datetime nutrition_fetched_at
|
|
||||||
smallint low_stock_pct
|
smallint low_stock_pct
|
||||||
smallint critical_stock_pct
|
smallint critical_stock_pct
|
||||||
tinyint is_active
|
tinyint is_active
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ erDiagram
|
||||||
enum source
|
enum source
|
||||||
int acting_user_id FK
|
int acting_user_id FK
|
||||||
enum service_mode
|
enum service_mode
|
||||||
varchar service_tag
|
|
||||||
enum status
|
enum status
|
||||||
int total_ht_cents
|
int total_ht_cents
|
||||||
int total_vat_cents
|
int total_vat_cents
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
**Version** : v0.3 — prod-like, 22 entites (19 prod-like + couche security-by-design, incl. les entites `login_throttle` et `pin_throttle`)
|
**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)
|
**Date** : 2026-06-04 (ajouts security-by-design 2026-06-11)
|
||||||
**Branche** : `feat/p1-conception`
|
**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) ; colonnes additives post-v0.3 des migrations 0003/0005/0006/0007 alignees sur le deploye (voir note 14)
|
**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)
|
**Auteur** : BYAN (couche methodologie)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -114,9 +114,6 @@ Un article vendable unique, disponible a la carte ou comme composant dans un slo
|
||||||
| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | renomme depuis `nom` |
|
| `name` | VARCHAR(120) | NO | — | INDEX | `nom` | renomme depuis `nom` |
|
||||||
| `description` | TEXT | YES | NULL | — | (ajoute) | renseigne plus tard via l'admin |
|
| `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) |
|
| `price_cents` | INT UNSIGNED | NO | — | CHECK > 0 | `prix` (FLOAT) | conversion FLOAT -> INT centimes au seed (voir note 1) |
|
||||||
| `maxi_variant_product_id` | INT UNSIGNED | YES | NULL | FK -> `product(id)`, ON DELETE SET NULL | (migration 0006) | auto-reference : variante servie quand un menu est commande au format Maxi (ex. Moyenne Frite -> Grande Frite). Data-driven (la regle vit dans la donnee). SET NULL = degradation gracieuse : si la variante Grande est retiree du catalogue, le produit de base reste vendable, il perd seulement sa substitution Maxi. Voir note 14 |
|
|
||||||
| `size_cl` | SMALLINT UNSIGNED | YES | NULL | — | (migration 0007) | variante de TAILLE a la carte : volume en centilitres d'une boisson fontaine (ex. 30 / 50 cl). NULL = produit sans dimension taille (bouteille, non-boisson). La ligne de base ET la variante portent leur volume pour l'affichage du picker. Voir note 14 |
|
|
||||||
| `base_product_id` | INT UNSIGNED | YES | NULL | FK -> `product(id)`, ON DELETE CASCADE | (migration 0007) | auto-reference vers la ligne de base d'une variante de taille. NULL = produit de base ou autonome (visible dans la grille catalogue) ; NON NULL = variante de taille (masquee de la grille, atteinte via le picker). CASCADE : une variante de taille n'a pas de sens sans sa base (suppression de la base -> suppression de ses variantes). Voir note 14 |
|
|
||||||
| `vat_rate` | SMALLINT UNSIGNED | NO | 100 | CHECK IN (55, 100) | (ajoute) | taux de TVA en pour-mille : 100 = 10%, 55 = 5,5%. Defaut 10%. Voir note 9 |
|
| `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 |
|
| `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 |
|
| `is_available` | TINYINT(1) | NO | 1 | — | (ajoute) | bascule de disponibilite manuelle depuis l'admin |
|
||||||
|
|
@ -200,9 +197,6 @@ Ingredient elementaire utilise dans la composition des produits. Porte les donne
|
||||||
| `stock_capacity` | INT | NO | — | CHECK > 0 | niveau "plein" de reference en unites = les 100% servant a calculer le pourcentage de stock. Le `CHECK > 0` protege aussi la division du pourcentage contre la division par zero |
|
| `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_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") |
|
| `pack_label` | VARCHAR(80) | YES | NULL | — | libelle humain du pack (ex. "Sac 100 portions") |
|
||||||
| `energy_kcal_100g` | SMALLINT UNSIGNED | YES | NULL | — | enrichissement nutritionnel (migration 0005) : apport energetique pour 100 g, importe depuis l'API externe OpenFoodFacts (Cr 3.a.3). Nullable : un ingredient non enrichi reste valide. Voir note 14 |
|
|
||||||
| `nutrition_source` | VARCHAR(120) | YES | NULL | — | enrichissement nutritionnel (migration 0005) : provenance de la donnee (ex. "OpenFoodFacts"). Voir note 14 |
|
|
||||||
| `nutrition_fetched_at` | DATETIME | YES | NULL | — | enrichissement nutritionnel (migration 0005) : horodatage de l'import, pour tracer la fraicheur. Voir note 14 |
|
|
||||||
| `low_stock_pct` | SMALLINT UNSIGNED | NO | 10 | CHECK BETWEEN 0 AND 100 | bande d’alerte, pourcentage de capacite : `stock_quantity <= stock_capacity * low_stock_pct/100` declenche l'indicateur de stock bas |
|
| `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 |
|
| `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 |
|
| `is_active` | TINYINT(1) | NO | 1 | — | desactiver les ingredients obsoletes sans supprimer |
|
||||||
|
|
@ -287,12 +281,11 @@ Transaction client : 1 commande = 1 panier valide a un instant donne.
|
||||||
| Attribut | Type | NULL | Default | Contrainte | Notes |
|
| Attribut | Type | NULL | Default | Contrainte | Notes |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
|
| `id` | INT UNSIGNED | NO | AUTO_INCREMENT | PK | |
|
||||||
| `order_number` | VARCHAR(20) | NO | — | UNIQUE | numero lisible : prefixe canal + id sequentiel, soit `K<id>` / `C<id>` / `D<id>` (K=kiosk, C=counter, D=drive). Ecrit en deux temps (INSERT puis UPDATE avec `LAST_INSERT_ID()`). Voir note 4. |
|
| `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 |
|
| `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. |
|
| `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 |
|
| `acting_user_id` | INT UNSIGNED | YES | NULL | FK -> `user(id)`, ON DELETE SET NULL | personnel back-office (counter/drive) ayant cree la commande, capture sous PIN. NULL pour `kiosk` (anonyme). Imputabilite ciblee sans imposer un login par personne sur la borne. Voir note 13 |
|
||||||
| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | mode de consommation, conserve pour les stats/KPI uniquement. Aucun role fiscal (voir note 9). La source `drive` implique le service_mode `drive` (contrainte croisee appliquee au niveau applicatif). |
|
| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | — | — | mode de consommation, conserve pour les stats/KPI uniquement. Aucun role fiscal (voir note 9). La source `drive` implique le service_mode `drive` (contrainte croisee appliquee au niveau applicatif). |
|
||||||
| `service_tag` | VARCHAR(20) | YES | NULL | — | numero de chevalet pour le service EN SALLE (migration 0003), saisi a la borne quand le client choisit `dine_in` ; permet d'apporter la commande a la bonne table (B4). NULL pour `takeaway` / `drive`. Voir note 14 |
|
|
||||||
| `status` | ENUM('pending_payment','paid','delivered','cancelled') | NO | 'pending_payment' | INDEX | machine a 4 etats : `pending_payment -> paid -> delivered` (+ `cancelled`). Voir note 6. |
|
| `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_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_vat_cents` | INT UNSIGNED | NO | — | CHECK >= 0 | montant de TVA, snapshot |
|
||||||
|
|
@ -694,28 +687,15 @@ et evite tous les conflits.
|
||||||
`order_item` est conserve comme nom de table de ligne : `item` n'est pas reserve, et le
|
`order_item` est conserve comme nom de table de ligne : `item` n'est pas reserve, et le
|
||||||
prefixe `order_` rend claire la relation parent.
|
prefixe `order_` rend claire la relation parent.
|
||||||
|
|
||||||
### Note 4 — Prefixe de numero de commande par canal (existant : prefixe + id)
|
### Note 4 — Prefixe de numero de commande par canal
|
||||||
|
|
||||||
Format reel (decision utilisateur) : prefixe canal + id de la commande, soit `K<id>` / `C<id>` /
|
Format : `K`/`C`/`D`-YYYY-MM-DD-NNN (kiosk / counter / drive).
|
||||||
`D<id>` (kiosk / counter / drive). Implemente dans `src/app/Order/OrderRepository.php` : la commande
|
|
||||||
est inseree avec un `order_number` provisoire vide, puis l'`order_number` est ecrit en `prefix .
|
|
||||||
LAST_INSERT_ID()` (ex. `K42`, `C7`, `D13`).
|
|
||||||
|
|
||||||
Rationale : le prefixe encode le canal, utile pour une identification visuelle rapide par le personnel
|
Rationale : le prefixe encode le canal, ce qui est utile pour une identification visuelle rapide
|
||||||
cuisine et comptoir sans interroger la colonne `source`. Le suffixe est l'id sequentiel auto-incremente :
|
par le personnel cuisine et comptoir sans interroger la colonne `source`. Le compteur sequentiel NNN
|
||||||
pas de compteur quotidien a tenir ni de `service_day` a gerer cote numerotation (rasoir d'Ockham,
|
repart chaque jour par canal. Sans collision sur une journee, vu le volume attendu.
|
||||||
mantra #37).
|
|
||||||
|
|
||||||
Ecart assume avec la cible v0.x initiale `K-AAAA-MM-JJ-NNN` (compteur journalier par canal) :
|
Alternative rejetee : prefixe neutre `W-` pour tous les canaux (plus simple, mais perd la lisibilite
|
||||||
cette derniere n'a pas ete retenue a l'implementation, jugee plus lourde sans valeur metier
|
|
||||||
proportionnelle pour le volume attendu. La forme acte ici est celle qui tourne.
|
|
||||||
|
|
||||||
Compromis connu : un numero `prefixe + id` est sequentiel, donc devinable (un client peut incrementer
|
|
||||||
l'id). Couple a l'endpoint de paiement anonyme cote borne (lecture/encaissement par `order_number`
|
|
||||||
sans authentification), c'est une surface a surveiller. Piste d'amelioration : numero non sequentiel
|
|
||||||
(ex. suffixe aleatoire court) si le suivi anonyme par numero devait s'ouvrir davantage.
|
|
||||||
|
|
||||||
Alternative rejetee : prefixe neutre `W` pour tous les canaux (plus simple, mais perd la lisibilite
|
|
||||||
du canal pour le personnel).
|
du canal pour le personnel).
|
||||||
|
|
||||||
### Note 5 — `source` vs `service_mode` (canal vs mode de consommation)
|
### Note 5 — `source` vs `service_mode` (canal vs mode de consommation)
|
||||||
|
|
@ -930,49 +910,6 @@ est un backoff degressif aux bornes propres (PIN_THROTTLE_*). Meme purge cron qu
|
||||||
References : `docs/notes/revue-alignement-p1.md` §7 (decisions D), carte d'impact security-by-design
|
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).
|
(2026-06-11). Modele de menace et matrice de classification des donnees : `PROJECT_CONTEXT.md` §19 (a venir).
|
||||||
|
|
||||||
### Note 14 — Colonnes additives post-v0.3 (migrations 0003 / 0005 / 0006 / 0007)
|
|
||||||
|
|
||||||
Ces colonnes etendent le schema apres v0.3 par des migrations purement additives (ajout de colonnes
|
|
||||||
nullables et de FK auto-referentes ; aucune donnee existante a retro-remplir, aucune table nouvelle).
|
|
||||||
Le runner applique les `*.sql` dans l'ordre lexicographique via `schema_migrations`. Elles sont alignees
|
|
||||||
ici sur le schema reellement deploye.
|
|
||||||
|
|
||||||
**Migration 0003 — `customer_order.service_tag` VARCHAR(20) NULL (AFTER service_mode).** Numero de
|
|
||||||
chevalet pour le service en salle (mode `dine_in`), saisi a la borne ; NULL pour `takeaway` / `drive`.
|
|
||||||
Permet d'apporter la commande a la bonne table (B4). Entite 3.10.
|
|
||||||
|
|
||||||
**Migration 0005 — enrichissement nutritionnel de `ingredient` (AFTER pack_label).**
|
|
||||||
`energy_kcal_100g` SMALLINT UNSIGNED NULL, `nutrition_source` VARCHAR(120) NULL,
|
|
||||||
`nutrition_fetched_at` DATETIME NULL. Donnees importees depuis l'API externe OpenFoodFacts (Cr 3.a.3 :
|
|
||||||
exploitation d'informations externes dans le modele de donnees). Opt-in et egress maitrise : aucun appel
|
|
||||||
automatique au runtime borne ; la passerelle (`App\Catalogue\OpenFoodFactsGateway`) est invoquee seulement
|
|
||||||
par `IngredientController::enrich` (action explicite manager/admin). Toutes nullables : un ingredient non
|
|
||||||
enrichi reste valide. Entite 3.6.
|
|
||||||
|
|
||||||
**Migration 0006 — `product.maxi_variant_product_id` INT UNSIGNED NULL, FK -> `product(id)` ON DELETE
|
|
||||||
SET NULL (AFTER price_cents).** Auto-reference : variante servie quand un menu est commande au format
|
|
||||||
Maxi (ex. Moyenne Frite -> Grande Frite), substituee cote serveur dans `OrderRepository::resolveSelections`
|
|
||||||
sans choix supplementaire. Approche data-driven (la regle vit dans la donnee, pas dans le code), et le
|
|
||||||
decrement de stock frappe alors le bon produit. SET NULL plutot que RESTRICT : si la variante Grande est
|
|
||||||
supprimee du catalogue, le produit de base reste vendable et perd seulement sa substitution Maxi
|
|
||||||
(degradation gracieuse) ; la reference est un confort metier, pas une integrite forte de commande (les
|
|
||||||
commandes figent deja leurs snapshots). Entite 3.2.
|
|
||||||
|
|
||||||
**Migration 0007 — variante de TAILLE de `product` (AFTER price_cents).** `size_cl` SMALLINT UNSIGNED
|
|
||||||
NULL, `base_product_id` INT UNSIGNED NULL avec FK -> `product(id)` ON DELETE CASCADE. La dimension taille
|
|
||||||
des boissons fontaine (la maquette borne propose 30 / 50 cl) est modelisee en lignes produit distinctes
|
|
||||||
(meme approche que Moyenne/Grande Frite) : le domaine commande facture deja par `product_id`, le flux reste
|
|
||||||
inchange, la borne resout la taille choisie en `product_id`. `base_product_id` NULL = produit de base ou
|
|
||||||
autonome (visible dans la grille catalogue) ; NON NULL = variante de taille (masquee de la grille, atteinte
|
|
||||||
via le picker). CASCADE plutot que SET NULL (a la difference de 0006) : une variante de taille n'a aucun
|
|
||||||
sens sans sa base (une "Coca Cola 50 cl" orpheline n'est pas commercialisable), donc supprimer la base
|
|
||||||
emporte ses variantes de taille. Les deux groupings coexistent sur une boisson sans se confondre :
|
|
||||||
`base_product_id` pilote la selection de taille a la carte (picker 30/50 cl) ; `maxi_variant_product_id`
|
|
||||||
(0006) pilote la substitution Maxi en menu. Entite 3.2.
|
|
||||||
|
|
||||||
References : `db/migrations/0003_order_service_tag.sql`, `0005_ingredient_nutrition.sql`,
|
|
||||||
`0006_product_maxi_variant.sql`, `0007_product_size_variant.sql`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Synthese du decompte des entites
|
## 5. Synthese du decompte des entites
|
||||||
|
|
|
||||||
|
|
@ -111,9 +111,6 @@ erDiagram
|
||||||
varchar name
|
varchar name
|
||||||
text description
|
text description
|
||||||
int price_cents
|
int price_cents
|
||||||
int maxi_variant_product_id FK
|
|
||||||
smallint size_cl
|
|
||||||
int base_product_id FK
|
|
||||||
smallint vat_rate
|
smallint vat_rate
|
||||||
varchar image_path
|
varchar image_path
|
||||||
tinyint is_available
|
tinyint is_available
|
||||||
|
|
@ -147,8 +144,6 @@ erDiagram
|
||||||
category ||--o{ product : "groups"
|
category ||--o{ product : "groups"
|
||||||
category ||--o{ menu : "groups"
|
category ||--o{ menu : "groups"
|
||||||
menu ||--|| product : "anchors (burger_product_id)"
|
menu ||--|| product : "anchors (burger_product_id)"
|
||||||
product ||--o{ product : "maxi_variant (maxi_variant_product_id)"
|
|
||||||
product ||--o{ product : "size_variant_of (base_product_id)"
|
|
||||||
menu ||--o{ menu_slot : "defines_slot"
|
menu ||--o{ menu_slot : "defines_slot"
|
||||||
menu_slot ||--o{ menu_slot_option : "lists"
|
menu_slot ||--o{ menu_slot_option : "lists"
|
||||||
product ||--o{ menu_slot_option : "is_eligible_for"
|
product ||--o{ menu_slot_option : "is_eligible_for"
|
||||||
|
|
@ -164,8 +159,6 @@ erDiagram
|
||||||
| C4 | defines_slot | menu | (1,N) | menu_slot | (1,1) | Un menu doit definir au moins un slot (boisson, accompagnement, sauce) pour avoir une composition personnalisable. Un slot appartient a exactement un menu. |
|
| 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. |
|
| 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. |
|
| C6 | is_eligible_for | product | (0,N) | menu_slot_option | (1,1) | Un produit peut etre eligible pour un nombre quelconque de slots a travers tous les menus, ou aucun s'il n'est vendu qu'a la carte. Chaque ligne d'option reference exactement un produit. |
|
||||||
| C7 | maxi_variant (migration 0006) | product (base) | (0,1) | product (variante Maxi) | (0,N) | Auto-reference : un produit pointe vers 0 ou 1 variante servie en menu Maxi (`maxi_variant_product_id`, nullable) ; un produit peut etre la variante Maxi de plusieurs autres. ON DELETE SET NULL (degradation gracieuse). |
|
|
||||||
| C8 | size_variant_of (migration 0007) | product (base) | (0,N) | product (variante de taille) | (0,1) | Auto-reference : une variante de taille pointe vers sa ligne de base (`base_product_id`, nullable ; NULL = base/autonome) ; un produit de base peut avoir plusieurs variantes de taille (30/50 cl). ON DELETE CASCADE (une variante de taille n'a pas de sens sans sa base). |
|
|
||||||
|
|
||||||
### 4.3 Notes sur le sous-domaine Catalogue
|
### 4.3 Notes sur le sous-domaine Catalogue
|
||||||
|
|
||||||
|
|
@ -175,8 +168,6 @@ erDiagram
|
||||||
|
|
||||||
**Format Normal / Maxi** : deux prix (`price_normal_cents`, `price_maxi_cents`) sur `menu` ; format enregistre au niveau de `order_item.format`. Aucun differentiel de prix au niveau du slot individuel n'est stocke (voir note 7 du dictionnaire).
|
**Format Normal / Maxi** : deux prix (`price_normal_cents`, `price_maxi_cents`) sur `menu` ; format enregistre au niveau de `order_item.format`. Aucun differentiel de prix au niveau du slot individuel n'est stocke (voir note 7 du dictionnaire).
|
||||||
|
|
||||||
**Variantes de produit (migrations 0006 / 0007, voir note 14 du dictionnaire)** : deux auto-references sur `product`, distinctes par leur role et leur comportement `ON DELETE`. `maxi_variant_product_id` (SET NULL) designe la variante servie quand un menu est commande au format Maxi (ex. Moyenne Frite -> Grande Frite), substituee cote serveur. `size_cl` + `base_product_id` (CASCADE) modelisent une variante de TAILLE a la carte (boisson 30/50 cl) en lignes produit : `base_product_id` NULL = produit de base/autonome visible au catalogue, NON NULL = variante masquee de la grille et atteinte via le picker. Les deux groupings coexistent sur une boisson sans se confondre.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Sous-domaine : Ingredients & Stock
|
## 5. Sous-domaine : Ingredients & Stock
|
||||||
|
|
@ -197,9 +188,6 @@ erDiagram
|
||||||
int stock_capacity
|
int stock_capacity
|
||||||
smallint pack_size
|
smallint pack_size
|
||||||
varchar pack_label
|
varchar pack_label
|
||||||
smallint energy_kcal_100g
|
|
||||||
varchar nutrition_source
|
|
||||||
datetime nutrition_fetched_at
|
|
||||||
smallint low_stock_pct
|
smallint low_stock_pct
|
||||||
smallint critical_stock_pct
|
smallint critical_stock_pct
|
||||||
tinyint is_active
|
tinyint is_active
|
||||||
|
|
@ -274,8 +262,6 @@ erDiagram
|
||||||
|
|
||||||
**Disponibilite produit calculee (regle RG-T21, voir `mlt.md`)** : la commandabilite effective est derivee, pas stockee. Un produit est commandable quand `product.is_available = 1` ET que chaque ingredient non retirable (`is_removable = 0`) de son `product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct / 100`. Un ingredient requis atteignant la bande critique met le produit en rupture automatique sans ecriture et sans cascade ; un retrait manuel (`product.is_available = 0`) est une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le produit commandable a nouveau de lui-meme. Un ingredient retirable/optionnel a la bande critique ne bloque pas le produit (seul son supplement devient indisponible). Le tableau de bord distingue un retrait manuel d'une rupture pilotee par le stock.
|
**Disponibilite produit calculee (regle RG-T21, voir `mlt.md`)** : la commandabilite effective est derivee, pas stockee. Un produit est commandable quand `product.is_available = 1` ET que chaque ingredient non retirable (`is_removable = 0`) de son `product_ingredient` a `stock_quantity > stock_capacity * critical_stock_pct / 100`. Un ingredient requis atteignant la bande critique met le produit en rupture automatique sans ecriture et sans cascade ; un retrait manuel (`product.is_available = 0`) est une surcharge forte ; un reapprovisionnement au-dessus de la bande critique rend le produit commandable a nouveau de lui-meme. Un ingredient retirable/optionnel a la bande critique ne bloque pas le produit (seul son supplement devient indisponible). Le tableau de bord distingue un retrait manuel d'une rupture pilotee par le stock.
|
||||||
|
|
||||||
**Enrichissement nutritionnel (migration 0005, voir note 14 du dictionnaire)** : `energy_kcal_100g`, `nutrition_source` et `nutrition_fetched_at` (toutes nullables) stockent une donnee importee depuis l'API externe OpenFoodFacts (Cr 3.a.3 : exploitation d'informations externes dans le modele de donnees). Opt-in : l'import est declenche par `IngredientController::enrich` (action manager/admin), pas au runtime borne ; un ingredient non enrichi reste valide.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Sous-domaine : Order
|
## 6. Sous-domaine : Order
|
||||||
|
|
@ -291,7 +277,6 @@ erDiagram
|
||||||
enum source
|
enum source
|
||||||
int acting_user_id FK
|
int acting_user_id FK
|
||||||
enum service_mode
|
enum service_mode
|
||||||
varchar service_tag
|
|
||||||
enum status
|
enum status
|
||||||
int total_ht_cents
|
int total_ht_cents
|
||||||
int total_vat_cents
|
int total_vat_cents
|
||||||
|
|
@ -397,11 +382,6 @@ enregistre l'employe de comptoir/drive qui a pris la commande sous PIN ; NULL po
|
||||||
Cela ajoute une association `customer_order |o--o| user : "taken_by"` (cardinalite : une commande est
|
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.
|
prise par (0,1) user ; un user prend (0,N) commandes). Voir note 13 du dictionnaire.
|
||||||
|
|
||||||
**Numero de commande (existant) et service en salle (migration 0003)** : `order_number` est un attribut
|
|
||||||
non cle (UNIQUE) de forme `prefixe canal + id` (`K<id>` / `C<id>` / `D<id>`) ; pas une association. Voir
|
|
||||||
note 4 du dictionnaire. `service_tag` (VARCHAR(20), nullable) porte le numero de chevalet du service en
|
|
||||||
salle (mode `dine_in`), saisi a la borne ; NULL pour `takeaway` / `drive`. Voir note 14 du dictionnaire.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Sous-domaine : RBAC
|
## 7. Sous-domaine : RBAC
|
||||||
|
|
@ -600,7 +580,7 @@ Le MCD reste au niveau conceptuel. Les decisions suivantes sont reportees au MLD
|
||||||
des PK composites.
|
des PK composites.
|
||||||
2. **PK technique vs identifiant metier** : `id INT UNSIGNED AUTO_INCREMENT` sur toutes les entites principales.
|
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,
|
`customer_order` porte en plus `order_number VARCHAR(20) UNIQUE` (lisible par un humain,
|
||||||
format prefixe canal + id : `K<id>` / `C<id>` / `D<id>`).
|
format `K/C/D-YYYY-MM-DD-NNN` par canal).
|
||||||
3. **Regles ON DELETE** : CASCADE vs RESTRICT vs SET NULL. Detaillees dans le MLD.
|
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
|
4. **Contraintes CHECK** : exclusivite de polymorphisme sur `order_item`, contrainte croisee
|
||||||
`source/service_mode` sur `customer_order`, invariant arithmetique sur les totaux.
|
`source/service_mode` sur `customer_order`, invariant arithmetique sur les totaux.
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ Pour chaque operation, le document fournit :
|
||||||
| **Synchronisation** | AND (les deux actions requises) |
|
| **Synchronisation** | AND (les deux actions requises) |
|
||||||
| **Condition** | Le panier contient au moins 1 article. Le numero de commande saisi est non vide. |
|
| **Condition** | Le panier contient au moins 1 article. Le numero de commande saisi est non vide. |
|
||||||
| **Operation** | CREATE_ORDER |
|
| **Operation** | CREATE_ORDER |
|
||||||
| **Description** | Creation atomique de la commande : INSERT `customer_order` avec statut `pending_payment`, source `kiosk`, snapshot des totaux HT/TVA/TTC (calcules ligne par ligne en utilisant `vat_rate` snapshote par article). INSERT des lignes `order_item` avec `label_snapshot`, `unit_price_cents_snapshot`, `vat_rate_snapshot`. INSERT `order_item_selection` pour chaque slot rempli dans un article de menu. INSERT `order_item_modifier` pour chaque modification d'ingredient. Decrement de `ingredient.stock_quantity` pour chaque ingredient consomme (ajuste par les modificateurs : retrait => pas de decrement ; ajout => decrement supplementaire) ; INSERT d'une ligne `stock_movement` de type `sale` par unite d'ingredient affectee. Les decrements de stock et l'insertion de la commande sont dans la meme transaction. Apres que le client a saisi son numero de commande, le statut passe `pending_payment -> paid` dans la meme transaction ; `paid_at` est positionne. Le systeme genere le numero de commande au format prefixe canal + id (`K<id>`, voir dictionnaire note 4). |
|
| **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) |
|
| **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 |
|
| **Resultat** | Commande creee (statut `paid` en fin d'operation), numero de commande affiche au client, evenement logique ORDER_CREATED emis vers le domaine de preparation |
|
||||||
|
|
||||||
|
|
@ -175,7 +175,7 @@ Pour chaque operation, le document fournit :
|
||||||
| **Synchronisation** | Aucune |
|
| **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`). |
|
| **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 |
|
| **Operation** | CREATE_COUNTER_ORDER |
|
||||||
| **Description** | Composition manuelle de la commande via le back-office : selectionner produits et menus, choisir le mode de service (`dine_in`/`takeaway`/`drive`), remplir les slots de menu, ajouter des modificateurs d'ingredient. Logique de creation identique a CREATE_ORDER (snapshot, decrement de stock dans la meme transaction, transition atomique `pending_payment -> paid`). La `source` est auto-taggee depuis `role.order_source` (counter -> `counter`, drive -> `drive`). Format du numero de commande : prefixe canal + id (`C<id>` comptoir, `D<id>` drive). Contrainte croisee : si `source = 'drive'` alors `service_mode = 'drive'` (verifie a la creation). |
|
| **Description** | Composition manuelle de la commande via le back-office : selectionner produits et menus, choisir le mode de service (`dine_in`/`takeaway`/`drive`), remplir les slots de menu, ajouter des modificateurs d'ingredient. Logique de creation identique a CREATE_ORDER (snapshot, decrement de stock dans la meme transaction, transition atomique `pending_payment -> paid`). La `source` est auto-taggee depuis `role.order_source` (counter -> `counter`, drive -> `drive`). Format du numero de commande : `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`) |
|
| **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 |
|
| **Resultat** | Commande creee (statut `paid`), numero de commande communique au client |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,9 +125,6 @@ erDiagram
|
||||||
int category_id FK
|
int category_id FK
|
||||||
varchar name
|
varchar name
|
||||||
int price_cents
|
int price_cents
|
||||||
int maxi_variant_product_id FK
|
|
||||||
smallint size_cl
|
|
||||||
int base_product_id FK
|
|
||||||
smallint vat_rate
|
smallint vat_rate
|
||||||
tinyint is_available
|
tinyint is_available
|
||||||
smallint display_order
|
smallint display_order
|
||||||
|
|
@ -158,8 +155,6 @@ erDiagram
|
||||||
category ||--o{ product : "category_id (RESTRICT)"
|
category ||--o{ product : "category_id (RESTRICT)"
|
||||||
category ||--o{ menu : "category_id (RESTRICT)"
|
category ||--o{ menu : "category_id (RESTRICT)"
|
||||||
product ||--o{ menu : "burger_product_id (RESTRICT)"
|
product ||--o{ menu : "burger_product_id (RESTRICT)"
|
||||||
product ||--o{ product : "maxi_variant_product_id (SET NULL)"
|
|
||||||
product ||--o{ product : "base_product_id (CASCADE)"
|
|
||||||
menu ||--o{ menu_slot : "menu_id (CASCADE)"
|
menu ||--o{ menu_slot : "menu_id (CASCADE)"
|
||||||
menu_slot ||--o{ menu_slot_option : "menu_slot_id (CASCADE)"
|
menu_slot ||--o{ menu_slot_option : "menu_slot_id (CASCADE)"
|
||||||
product ||--o{ menu_slot_option : "product_id (RESTRICT)"
|
product ||--o{ menu_slot_option : "product_id (RESTRICT)"
|
||||||
|
|
@ -176,9 +171,6 @@ erDiagram
|
||||||
int stock_quantity
|
int stock_quantity
|
||||||
int stock_capacity
|
int stock_capacity
|
||||||
smallint pack_size
|
smallint pack_size
|
||||||
smallint energy_kcal_100g
|
|
||||||
varchar nutrition_source
|
|
||||||
datetime nutrition_fetched_at
|
|
||||||
smallint low_stock_pct
|
smallint low_stock_pct
|
||||||
smallint critical_stock_pct
|
smallint critical_stock_pct
|
||||||
tinyint is_active
|
tinyint is_active
|
||||||
|
|
@ -243,7 +235,6 @@ erDiagram
|
||||||
enum source
|
enum source
|
||||||
int acting_user_id FK
|
int acting_user_id FK
|
||||||
enum service_mode
|
enum service_mode
|
||||||
varchar service_tag
|
|
||||||
enum status
|
enum status
|
||||||
int total_ht_cents
|
int total_ht_cents
|
||||||
int total_vat_cents
|
int total_vat_cents
|
||||||
|
|
@ -419,14 +410,11 @@ Pas de FK. Table racine du sous-domaine Catalogue.
|
||||||
### 4.2 `product`
|
### 4.2 `product`
|
||||||
|
|
||||||
```
|
```
|
||||||
product (id, #category_id, name, [description], price_cents,
|
product (id, #category_id, name, [description], price_cents, vat_rate,
|
||||||
[#maxi_variant_product_id], [size_cl], [#base_product_id], vat_rate,
|
|
||||||
[image_path], is_available, display_order, created_at, updated_at)
|
[image_path], is_available, display_order, created_at, updated_at)
|
||||||
|
|
||||||
PK : id
|
PK : id
|
||||||
FK : category_id -> category(id) ON DELETE RESTRICT
|
FK : category_id -> category(id) ON DELETE RESTRICT
|
||||||
FK : maxi_variant_product_id -> product(id) ON DELETE SET NULL
|
|
||||||
FK : base_product_id -> product(id) ON DELETE CASCADE
|
|
||||||
IDX : (category_id, is_available, display_order)
|
IDX : (category_id, is_available, display_order)
|
||||||
CHK : price_cents > 0
|
CHK : price_cents > 0
|
||||||
CHK : vat_rate IN (55, 100)
|
CHK : vat_rate IN (55, 100)
|
||||||
|
|
@ -439,9 +427,6 @@ product (id, #category_id, name, [description], price_cents,
|
||||||
| `name` | VARCHAR(120) | NO | Libelle du produit |
|
| `name` | VARCHAR(120) | NO | Libelle du produit |
|
||||||
| `description` | TEXT | YES | Description longue optionnelle |
|
| `description` | TEXT | YES | Description longue optionnelle |
|
||||||
| `price_cents` | INT UNSIGNED | NO | Prix a la carte, TVA incluse, en centimes |
|
| `price_cents` | INT UNSIGNED | NO | Prix a la carte, TVA incluse, en centimes |
|
||||||
| `maxi_variant_product_id` | INT UNSIGNED | YES | FK -> product (auto-reference), ON DELETE SET NULL ; variante servie en menu Maxi (migration 0006, voir note de table) |
|
|
||||||
| `size_cl` | SMALLINT UNSIGNED | YES | Volume en cl d'une variante de taille de boisson ; NULL si pas de dimension taille (migration 0007) |
|
|
||||||
| `base_product_id` | INT UNSIGNED | YES | FK -> product (auto-reference), ON DELETE CASCADE ; ligne de base d'une variante de taille, NULL = base/autonome (migration 0007) |
|
|
||||||
| `vat_rate` | SMALLINT UNSIGNED | NO | Pour-mille : 100 = 10%, 55 = 5.5% |
|
| `vat_rate` | SMALLINT UNSIGNED | NO | Pour-mille : 100 = 10%, 55 = 5.5% |
|
||||||
| `image_path` | VARCHAR(255) | YES | Chemin relatif depuis la racine publique |
|
| `image_path` | VARCHAR(255) | YES | Chemin relatif depuis la racine publique |
|
||||||
| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Bascule de disponibilite manuelle |
|
| `is_available` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Bascule de disponibilite manuelle |
|
||||||
|
|
@ -452,19 +437,6 @@ product (id, #category_id, name, [description], price_cents,
|
||||||
**ON DELETE RESTRICT** sur `category_id` : une categorie avec des produits ne peut pas etre supprimee. Empeche les
|
**ON DELETE RESTRICT** sur `category_id` : une categorie avec des produits ne peut pas etre supprimee. Empeche les
|
||||||
produits orphelins.
|
produits orphelins.
|
||||||
|
|
||||||
**Auto-references de variante (migrations 0006 / 0007, voir note 14 du dictionnaire)** : deux groupings
|
|
||||||
distincts, tous deux pointant vers `product(id)`.
|
|
||||||
- `maxi_variant_product_id` (ON DELETE SET NULL) : variante servie quand un menu est commande au format
|
|
||||||
Maxi (ex. Moyenne Frite -> Grande Frite). SET NULL = degradation gracieuse, le produit de base reste
|
|
||||||
vendable si la variante Grande est supprimee.
|
|
||||||
- `size_cl` + `base_product_id` (ON DELETE CASCADE) : variante de TAILLE a la carte (boisson 30/50 cl)
|
|
||||||
modelisee en lignes produit. `base_product_id` NULL = produit de base/autonome (visible catalogue) ;
|
|
||||||
NON NULL = variante de taille (masquee de la grille, atteinte via le picker). CASCADE car une variante
|
|
||||||
de taille n'a pas de sens sans sa base.
|
|
||||||
|
|
||||||
Les deux coexistent sur une boisson sans se confondre : `base_product_id` pilote la selection de taille a
|
|
||||||
la carte ; `maxi_variant_product_id` pilote la substitution Maxi en menu.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4.3 `menu`
|
### 4.3 `menu`
|
||||||
|
|
@ -557,7 +529,6 @@ Pas d'horodatages. Table de jointure pure.
|
||||||
|
|
||||||
```
|
```
|
||||||
ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_label],
|
ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_label],
|
||||||
[energy_kcal_100g], [nutrition_source], [nutrition_fetched_at],
|
|
||||||
low_stock_pct, critical_stock_pct, is_active, created_at, updated_at)
|
low_stock_pct, critical_stock_pct, is_active, created_at, updated_at)
|
||||||
|
|
||||||
PK : id
|
PK : id
|
||||||
|
|
@ -578,9 +549,6 @@ ingredient (id, name, unit, stock_quantity, stock_capacity, pack_size, [pack_lab
|
||||||
| `stock_capacity` | INT NOT NULL | NO | Niveau "plein" de reference en unites = le 100% utilise pour calculer le pourcentage de stock ; CHECK > 0 protege aussi la division du pourcentage contre la division par zero |
|
| `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_size` | SMALLINT UNSIGNED NOT NULL DEFAULT 1 | NO | Unites par lot de reapprovisionnement |
|
||||||
| `pack_label` | VARCHAR(80) | YES | Libelle humain du lot |
|
| `pack_label` | VARCHAR(80) | YES | Libelle humain du lot |
|
||||||
| `energy_kcal_100g` | SMALLINT UNSIGNED | YES | Apport energetique pour 100 g, importe depuis l'API externe OpenFoodFacts (migration 0005) |
|
|
||||||
| `nutrition_source` | VARCHAR(120) | YES | Provenance de la donnee nutritionnelle, ex. "OpenFoodFacts" (migration 0005) |
|
|
||||||
| `nutrition_fetched_at` | DATETIME | YES | Horodatage de l'import nutritionnel, trace la fraicheur (migration 0005) |
|
|
||||||
| `low_stock_pct` | SMALLINT UNSIGNED NOT NULL DEFAULT 10 | NO | Bande d’alerte, pourcentage de la capacite (CHECK BETWEEN 0 AND 100) |
|
| `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`) |
|
| `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 |
|
| `is_active` | TINYINT(1) NOT NULL DEFAULT 1 | NO | Desactiver les ingredients obsoletes |
|
||||||
|
|
@ -609,11 +577,6 @@ bloque pas le produit (seul son supplement devient indisponible). Le tableau de
|
||||||
retrait manuel (`is_available=0`) d'une rupture pilotee par le stock (`is_available=1` mais un ingredient
|
retrait manuel (`is_available=0`) d'une rupture pilotee par le stock (`is_available=1` mais un ingredient
|
||||||
requis est critique).
|
requis est critique).
|
||||||
|
|
||||||
**Enrichissement nutritionnel (migration 0005, voir note 14 du dictionnaire)** : `energy_kcal_100g`,
|
|
||||||
`nutrition_source` et `nutrition_fetched_at` (toutes nullables) stockent une donnee importee depuis l'API
|
|
||||||
externe OpenFoodFacts (Cr 3.a.3). Opt-in : l'import est declenche par `IngredientController::enrich`
|
|
||||||
(action manager/admin), pas au runtime borne ; un ingredient non enrichi reste valide.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4.7 `product_ingredient`
|
### 4.7 `product_ingredient`
|
||||||
|
|
@ -848,7 +811,7 @@ Pas d'horodatages. Table de jointure pure.
|
||||||
|
|
||||||
```
|
```
|
||||||
customer_order (id, order_number, [idempotency_key], source, [#acting_user_id],
|
customer_order (id, order_number, [idempotency_key], source, [#acting_user_id],
|
||||||
service_mode, [service_tag], status,
|
service_mode, status,
|
||||||
total_ht_cents, total_vat_cents, total_ttc_cents,
|
total_ht_cents, total_vat_cents, total_ttc_cents,
|
||||||
[paid_at], [delivered_at], [cancelled_at],
|
[paid_at], [delivered_at], [cancelled_at],
|
||||||
created_at, updated_at)
|
created_at, updated_at)
|
||||||
|
|
@ -870,12 +833,11 @@ customer_order (id, order_number, [idempotency_key], source, [#acting_user_id],
|
||||||
| Colonne | Type | NULL | Notes |
|
| Colonne | Type | NULL | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
| `id` | INT UNSIGNED AUTO_INCREMENT | NO | PK |
|
||||||
| `order_number` | VARCHAR(20) | NO | Prefixe canal + id sequentiel : `K<id>`/`C<id>`/`D<id>` (existant, voir note de table) |
|
| `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) |
|
| `idempotency_key` | VARCHAR(36) | YES | UUID client, UNIQUE ; deduplique un POST reessaye (security-by-design) |
|
||||||
| `source` | ENUM('kiosk','counter','drive') | NO | Canal de saisie |
|
| `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 |
|
| `acting_user_id` | INT UNSIGNED | YES | FK -> user ; personnel counter/drive sous PIN ; NULL pour kiosk |
|
||||||
| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Mode de consommation (stats uniquement, pas de role fiscal) |
|
| `service_mode` | ENUM('dine_in','takeaway','drive') | NO | Mode de consommation (stats uniquement, pas de role fiscal) |
|
||||||
| `service_tag` | VARCHAR(20) | YES | Numero de chevalet du service en salle (`dine_in`), saisi a la borne ; NULL pour takeaway/drive (migration 0003) |
|
|
||||||
| `status` | ENUM('pending_payment','paid','delivered','cancelled') NOT NULL DEFAULT 'pending_payment' | NO | Machine a 4 etats |
|
| `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_ht_cents` | INT UNSIGNED | NO | Snapshot du total HT |
|
||||||
| `total_vat_cents` | INT UNSIGNED | NO | Snapshot du montant de TVA |
|
| `total_vat_cents` | INT UNSIGNED | NO | Snapshot du montant de TVA |
|
||||||
|
|
@ -886,17 +848,6 @@ customer_order (id, order_number, [idempotency_key], source, [#acting_user_id],
|
||||||
| `created_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | NO | Utilise comme base de `service_day` |
|
| `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 |
|
| `updated_at` | DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | NO | Audit |
|
||||||
|
|
||||||
**Numero de commande (existant, voir note 4 du dictionnaire)** : `order_number` = prefixe canal + id
|
|
||||||
sequentiel (`K<id>` / `C<id>` / `D<id>`), ecrit en deux temps (INSERT avec numero provisoire vide, puis
|
|
||||||
UPDATE en `prefix . LAST_INSERT_ID()`) dans `OrderRepository`. La cible initiale `K-AAAA-MM-JJ-NNN`
|
|
||||||
(compteur journalier) n'a pas ete retenue : la forme `prefixe + id` evite un compteur quotidien. Compromis
|
|
||||||
connu : numero sequentiel donc devinable, couple a l'endpoint de paiement anonyme cote borne (piste
|
|
||||||
d'amelioration : numero non sequentiel).
|
|
||||||
|
|
||||||
**Service en salle (migration 0003)** : `service_tag` (VARCHAR(20), nullable) porte le numero de chevalet
|
|
||||||
saisi a la borne pour le mode `dine_in` ; NULL pour `takeaway` / `drive`. Colonne additive, sans contrainte
|
|
||||||
de BD (la coherence avec `service_mode` est appliquee au niveau applicatif).
|
|
||||||
|
|
||||||
**Attribution du personnel (security-by-design)** : `acting_user_id` (FK -> `user`, ON DELETE SET NULL)
|
**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.
|
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
|
Les commandes kiosk restent anonymes par conception. `stock_movement.user_id` couvre l'attribution des actions
|
||||||
|
|
@ -1284,11 +1235,11 @@ et que toutes les tables se rattachent au MCD.
|
||||||
| Entite MCD | Table MLD | Type de mapping | Notes |
|
| Entite MCD | Table MLD | Type de mapping | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `category` (C1) | `category` (4.1) | entite 1:1 | |
|
| `category` (C1) | `category` (4.1) | entite 1:1 | |
|
||||||
| `product` (C2) | `product` (4.2) | entite 1:1 | Additif post-v0.3 : `maxi_variant_product_id` (0006), `size_cl` + `base_product_id` (0007) |
|
| `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` (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` (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) |
|
| `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) ; additif post-v0.3 : `energy_kcal_100g`, `nutrition_source`, `nutrition_fetched_at` (0005) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `ingredient_allergen` (C9) | `ingredient_allergen` (4.9) | Table de jointure (PK composite) | Nouvelle entite (v0.2) |
|
||||||
|
|
@ -1297,7 +1248,7 @@ et que toutes les tables se rattachent au MCD.
|
||||||
| `role_visible_source` (C12) | `role_visible_source` (4.12) | Table de jointure (PK composite) | Nouvelle entite (v0.2) |
|
| `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 | |
|
| `permission` (C13) | `permission` (4.13) | entite 1:1 | |
|
||||||
| `role_permission` (C14) | `role_permission` (4.14) | Table de jointure (PK composite) | |
|
| `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 ; additif post-v0.3 : `service_tag` (0003) |
|
| `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` (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_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) |
|
| `order_item_modifier` (C18) | `order_item_modifier` (4.18) | entite 1:1 | Nouvelle entite (v0.2) |
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ Ces regles s'appliquent a plusieurs operations et sont centralisees ici pour evi
|
||||||
| **[PRE-3]** | Le corps JSON du POST est valide (validation de schema a la couche API) |
|
| **[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-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-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 : prefixe canal + id auto-incremente, soit `K<id>` pour la source `kiosk` (ex. `K42`). Genere en deux temps dans la transaction : INSERT avec `order_number` provisoire vide, puis UPDATE `prefix . LAST_INSERT_ID()`. Pas de compteur par service_day (voir dictionnaire note 4). La source est `kiosk` (derivee de l'endpoint public). Le numero provisoire vide partage avant l'UPDATE reste une surface de robustesse a durcir sous forte concurrence (suivi au backlog). |
|
| **[RG-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-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-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-6 — cross-constraint]** | La source `kiosk` n'implique aucune contrainte particuliere de service_mode ; le client selectionne `dine_in` ou `takeaway`. La contrainte croisee drive (RG-T09) ne s'applique pas aux commandes provenant du kiosk. |
|
||||||
|
|
@ -163,7 +163,7 @@ Ces regles s'appliquent a plusieurs operations et sont centralisees ici pour evi
|
||||||
| **[PRE-3]** | Le panier contient au moins 1 article |
|
| **[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-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-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 : prefixe canal + id auto-incremente, soit `C<id>` (comptoir) ou `D<id>` (drive). Meme generation en deux temps que CREATE_ORDER RG-3. Pas de compteur par service_day (voir dictionnaire note 4). |
|
| **[RG-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-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. |
|
| **[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-1]** | Une ligne `customer_order` avec `status = 'paid'`, `source = 'counter'` ou `'drive'`, `paid_at` defini, `acting_user_id` defini. |
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ flowchart LR
|
||||||
|
|
||||||
| Cas | Operation MCT | Permission | Description | Entites |
|
| 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 prefixe canal + id (`C<id>`/`D<id>`, voir dictionnaire note 4). | `customer_order`, `order_item`, `order_item_selection`, `order_item_modifier`, `ingredient`, `stock_movement` |
|
| Saisir une commande comptoir/drive | 4.1 CREATE_COUNTER_ORDER | `order.create` | Composer une commande pour un client au comptoir (`counter`) ou au drive (`drive`). Logique identique a CREATE_ORDER ; `source` auto-tague depuis `role.order_source`. Numero `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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
||||||
|
|
|
||||||
|
|
@ -52,40 +52,25 @@ if ! command -v docker >/dev/null 2>&1; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE"
|
echo "Deploiement Wakdo : branche '$BRANCH' depuis '$REMOTE' via $COMPOSE_FILE"
|
||||||
# Mode non-interactif pour le CD : DEPLOY_YES=1 saute la confirmation (la forced
|
printf 'Confirmer le deploiement en production ? [oui/NON] '
|
||||||
# command SSH le pose). On NE lit PAS $SSH_ORIGINAL_COMMAND : la cle CI ne peut
|
read -r answer
|
||||||
# influencer ni la branche ni le compose, seulement declencher CE script.
|
if [ "$answer" != "oui" ]; then
|
||||||
if [ "${DEPLOY_YES:-}" = "1" ] || [ "${DEPLOY_YES:-}" = "oui" ]; then
|
echo "deploy: annule."
|
||||||
echo "deploy: confirmation automatique (DEPLOY_YES)."
|
exit 1
|
||||||
else
|
|
||||||
printf 'Confirmer le deploiement en production ? [oui/NON] '
|
|
||||||
read -r answer
|
|
||||||
if [ "$answer" != "oui" ]; then
|
|
||||||
echo "deploy: annule."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[1/5] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
|
echo "[1/4] recuperation de '$BRANCH' depuis '$REMOTE' (fast-forward only)"
|
||||||
git fetch --prune "$REMOTE" "$BRANCH"
|
git fetch --prune "$REMOTE" "$BRANCH"
|
||||||
git checkout "$BRANCH"
|
git checkout "$BRANCH"
|
||||||
git merge --ff-only "$REMOTE/$BRANCH"
|
git merge --ff-only "$REMOTE/$BRANCH"
|
||||||
|
|
||||||
echo "[2/5] marqueur de version (preuve CD cote app)"
|
echo "[2/4] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
|
||||||
SHA="$(git rev-parse --short HEAD)"
|
|
||||||
NOW="$(date --iso-8601=seconds)"
|
|
||||||
# Sous src/ pour etre visible dans le conteneur (mount ./src -> /var/www/html),
|
|
||||||
# lu a chaud par GET /api/health. Journal d'historique a la racine du depot.
|
|
||||||
printf '%s %s\n' "$SHA" "$NOW" > src/VERSION
|
|
||||||
printf '[%s] deploy %s (branche %s)\n' "$NOW" "$SHA" "$BRANCH" >> deploy.log
|
|
||||||
|
|
||||||
echo "[3/5] reconstruction des images depuis les Dockerfiles (--pull rafraichit les bases)"
|
|
||||||
docker compose -f "$COMPOSE_FILE" build --pull
|
docker compose -f "$COMPOSE_FILE" build --pull
|
||||||
|
|
||||||
echo "[4/5] demarrage de la stack (migrate + seed idempotents puis app)"
|
echo "[3/4] demarrage de la stack (migrate + seed idempotents puis app)"
|
||||||
docker compose -f "$COMPOSE_FILE" up -d
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
echo "[5/5] etat des services"
|
echo "[4/4] etat des services"
|
||||||
docker compose -f "$COMPOSE_FILE" ps
|
docker compose -f "$COMPOSE_FILE" ps
|
||||||
|
|
||||||
echo "Deploiement termine ($SHA)."
|
echo "Deploiement termine."
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client SMTP minimal (sans dependance) : ESMTP + STARTTLS + AUTH LOGIN, suffisant
|
|
||||||
* pour un relais authentifie type Brevo. Conduit la conversation contre un
|
|
||||||
* SmtpTransport injecte ; chaque etape verifie le code de reponse attendu et leve
|
|
||||||
* en cas d'ecart. La construction du message est laissee a l'appelant (SmtpMailer).
|
|
||||||
*/
|
|
||||||
final class SmtpClient
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly SmtpTransport $transport,
|
|
||||||
private readonly string $heloName = 'wakdo',
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ouvre la session, s'authentifie, transmet un message deja assemble
|
|
||||||
* (en-tetes + corps, lignes en CRLF, dot-stuffing applique) puis ferme.
|
|
||||||
*/
|
|
||||||
public function send(
|
|
||||||
string $host,
|
|
||||||
int $port,
|
|
||||||
string $user,
|
|
||||||
string $password,
|
|
||||||
string $from,
|
|
||||||
string $to,
|
|
||||||
string $message,
|
|
||||||
): void {
|
|
||||||
// Defense en profondeur : un CRLF dans une adresse injecterait une commande
|
|
||||||
// SMTP (RCPT supplementaire) ou un en-tete. On refuse avant toute connexion.
|
|
||||||
$this->assertNoInjection($from, 'expediteur');
|
|
||||||
$this->assertNoInjection($to, 'destinataire');
|
|
||||||
|
|
||||||
$t = $this->transport;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$t->open($host, $port, 15);
|
|
||||||
$this->expect($t->readReply(), 220, 'greeting');
|
|
||||||
|
|
||||||
$this->command('EHLO ' . $this->heloName, 250, 'EHLO');
|
|
||||||
$this->command('STARTTLS', 220, 'STARTTLS');
|
|
||||||
$t->enableCrypto();
|
|
||||||
// Re-EHLO obligatoire apres bascule TLS (la session repart de zero).
|
|
||||||
$this->command('EHLO ' . $this->heloName, 250, 'EHLO TLS');
|
|
||||||
|
|
||||||
$this->command('AUTH LOGIN', 334, 'AUTH LOGIN');
|
|
||||||
$this->command(base64_encode($user), 334, 'AUTH user');
|
|
||||||
$this->command(base64_encode($password), 235, 'AUTH password');
|
|
||||||
|
|
||||||
$this->command('MAIL FROM:<' . $from . '>', 250, 'MAIL FROM');
|
|
||||||
$this->command('RCPT TO:<' . $to . '>', 250, 'RCPT TO');
|
|
||||||
$this->command('DATA', 354, 'DATA');
|
|
||||||
|
|
||||||
// Corps + terminateur "<CRLF>.<CRLF>".
|
|
||||||
$t->write($message . "\r\n.\r\n");
|
|
||||||
$this->expect($t->readReply(), 250, 'corps du message');
|
|
||||||
|
|
||||||
$t->write("QUIT\r\n");
|
|
||||||
// La fermeture (221) n'est pas bloquante : le message est deja accepte.
|
|
||||||
$t->readReply();
|
|
||||||
} finally {
|
|
||||||
$t->close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function command(string $line, int $expected, string $stage): void
|
|
||||||
{
|
|
||||||
$this->transport->write($line . "\r\n");
|
|
||||||
$this->expect($this->transport->readReply(), $expected, $stage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assertNoInjection(string $address, string $label): void
|
|
||||||
{
|
|
||||||
if (preg_match('/[\r\n]/', $address) === 1) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
sprintf('SMTP : adresse %s invalide (saut de ligne interdit)', $label),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function expect(string $reply, int $code, string $stage): void
|
|
||||||
{
|
|
||||||
$got = (int) substr(ltrim($reply), 0, 3);
|
|
||||||
if ($got !== $code) {
|
|
||||||
// On ne journalise pas le corps : il peut contenir le lien de reset.
|
|
||||||
throw new RuntimeException(
|
|
||||||
sprintf('SMTP %s : attendu %d, recu "%s"', $stage, $code, trim($reply)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mailer SMTP reel (relais authentifie type Brevo). Implemente l'interface Mailer
|
|
||||||
* a la place de LogMailer quand le SMTP est configure (voir PasswordResetController).
|
|
||||||
* Assemble un message texte/plain UTF-8 conforme puis delegue l'envoi a SmtpClient.
|
|
||||||
*/
|
|
||||||
final class SmtpMailer implements Mailer
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly SmtpClient $client,
|
|
||||||
private readonly string $host,
|
|
||||||
private readonly int $port,
|
|
||||||
private readonly string $user,
|
|
||||||
private readonly string $password,
|
|
||||||
private readonly string $fromEmail,
|
|
||||||
private readonly string $fromName,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function sendPasswordReset(string $email, string $resetUrl): void
|
|
||||||
{
|
|
||||||
// Garde destinataire : une adresse valide ne contient ni CRLF ni structure
|
|
||||||
// d'injection (verrou en plus de la garde transport de SmtpClient).
|
|
||||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
|
||||||
throw new RuntimeException('SmtpMailer : adresse destinataire invalide');
|
|
||||||
}
|
|
||||||
|
|
||||||
$subject = 'Reinitialisation de votre mot de passe Wakdo';
|
|
||||||
$body = "Bonjour,\r\n\r\n"
|
|
||||||
. "Une reinitialisation de mot de passe a ete demandee pour ce compte.\r\n"
|
|
||||||
. "Pour definir un nouveau mot de passe, ouvrez ce lien :\r\n\r\n"
|
|
||||||
. $resetUrl . "\r\n\r\n"
|
|
||||||
. "Ce lien expire rapidement. Si vous n'etes pas a l'origine de la demande, "
|
|
||||||
. "ignorez cet email.\r\n";
|
|
||||||
|
|
||||||
$message = $this->buildMessage($email, $subject, $body);
|
|
||||||
|
|
||||||
$this->client->send(
|
|
||||||
$this->host,
|
|
||||||
$this->port,
|
|
||||||
$this->user,
|
|
||||||
$this->password,
|
|
||||||
$this->fromEmail,
|
|
||||||
$email,
|
|
||||||
$message,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Assemble en-tetes + corps en CRLF, avec dot-stuffing pour la phase DATA. */
|
|
||||||
private function buildMessage(string $to, string $subject, string $body): string
|
|
||||||
{
|
|
||||||
$headers = [
|
|
||||||
'From: ' . $this->encodeHeader($this->fromName) . ' <' . $this->fromEmail . '>',
|
|
||||||
'To: <' . $to . '>',
|
|
||||||
'Subject: ' . $this->encodeHeader($subject),
|
|
||||||
'MIME-Version: 1.0',
|
|
||||||
'Content-Type: text/plain; charset=UTF-8',
|
|
||||||
'Content-Transfer-Encoding: 8bit',
|
|
||||||
];
|
|
||||||
|
|
||||||
$raw = implode("\r\n", $headers) . "\r\n\r\n" . $this->normalizeEol($body);
|
|
||||||
|
|
||||||
return $this->dotStuff($raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** RFC 2047 (encoded-word base64) si la valeur sort de l'ASCII imprimable. */
|
|
||||||
private function encodeHeader(string $value): string
|
|
||||||
{
|
|
||||||
if (preg_match('/^[\x20-\x7E]*$/', $value) === 1) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '=?UTF-8?B?' . base64_encode($value) . '?=';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalise toutes les fins de ligne en CRLF (LF ou CR isoles -> CRLF). */
|
|
||||||
private function normalizeEol(string $text): string
|
|
||||||
{
|
|
||||||
return (string) preg_replace('/\r\n|\r|\n/', "\r\n", $text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Double un point en debut de ligne (RFC 5321 transparency). */
|
|
||||||
private function dotStuff(string $message): string
|
|
||||||
{
|
|
||||||
$lines = explode("\r\n", $message);
|
|
||||||
foreach ($lines as $i => $line) {
|
|
||||||
if (isset($line[0]) && $line[0] === '.') {
|
|
||||||
$lines[$i] = '.' . $line;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode("\r\n", $lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Couche transport d'une session SMTP : abstrait le socket reel pour que la
|
|
||||||
* logique du protocole (SmtpClient) soit testable sans reseau (double en test).
|
|
||||||
*/
|
|
||||||
interface SmtpTransport
|
|
||||||
{
|
|
||||||
public function open(string $host, int $port, int $timeoutSeconds): void;
|
|
||||||
|
|
||||||
/** Ecrit exactement $raw sur la connexion (CRLF inclus par l'appelant). */
|
|
||||||
public function write(string $raw): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lit une reponse SMTP complete. Gere le multiligne (RFC 5321 : les lignes
|
|
||||||
* de continuation ont un '-' en 4e position, la derniere un espace).
|
|
||||||
*/
|
|
||||||
public function readReply(): string;
|
|
||||||
|
|
||||||
/** Bascule la connexion en TLS (apres STARTTLS). */
|
|
||||||
public function enableCrypto(): void;
|
|
||||||
|
|
||||||
public function close(): void;
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Auth;
|
|
||||||
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transport SMTP reel sur socket TCP (stream_socket_client + STARTTLS). Aucune
|
|
||||||
* dependance externe. Non teste unitairement (effet de bord reseau) : la logique
|
|
||||||
* du protocole est couverte via SmtpClient + un transport double.
|
|
||||||
*/
|
|
||||||
final class StreamSmtpTransport implements SmtpTransport
|
|
||||||
{
|
|
||||||
/** @var resource|null */
|
|
||||||
private $stream = null;
|
|
||||||
|
|
||||||
public function open(string $host, int $port, int $timeoutSeconds): void
|
|
||||||
{
|
|
||||||
$errno = 0;
|
|
||||||
$errstr = '';
|
|
||||||
$stream = @stream_socket_client(
|
|
||||||
sprintf('tcp://%s:%d', $host, $port),
|
|
||||||
$errno,
|
|
||||||
$errstr,
|
|
||||||
$timeoutSeconds,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($stream === false) {
|
|
||||||
throw new RuntimeException(sprintf('SMTP : connexion echouee (%s)', $errstr));
|
|
||||||
}
|
|
||||||
|
|
||||||
stream_set_timeout($stream, $timeoutSeconds);
|
|
||||||
$this->stream = $stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function write(string $raw): void
|
|
||||||
{
|
|
||||||
fwrite($this->requireStream(), $raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function readReply(): string
|
|
||||||
{
|
|
||||||
$stream = $this->requireStream();
|
|
||||||
$data = '';
|
|
||||||
$lines = 0;
|
|
||||||
|
|
||||||
while (($line = fgets($stream, 515)) !== false) {
|
|
||||||
$data .= $line;
|
|
||||||
|
|
||||||
// Bornes anti-boucle sur reponse malformee (ni ligne finale, ni EOF).
|
|
||||||
if (++$lines > 100 || strlen($data) > 65536) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continuation UNIQUEMENT si '-' en 4e position ; toute autre ligne
|
|
||||||
// (y compris trop courte) termine la reponse.
|
|
||||||
if (!(strlen($line) >= 4 && $line[3] === '-')) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($data === '') {
|
|
||||||
throw new RuntimeException('SMTP : aucune reponse du serveur');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function enableCrypto(): void
|
|
||||||
{
|
|
||||||
if (!stream_socket_enable_crypto($this->requireStream(), true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
|
||||||
throw new RuntimeException('SMTP : echec de la negociation TLS (STARTTLS)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function close(): void
|
|
||||||
{
|
|
||||||
if (is_resource($this->stream)) {
|
|
||||||
fclose($this->stream);
|
|
||||||
}
|
|
||||||
$this->stream = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return resource */
|
|
||||||
private function requireStream()
|
|
||||||
{
|
|
||||||
if (!is_resource($this->stream)) {
|
|
||||||
throw new RuntimeException('SMTP : transport non ouvert');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->stream;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,16 +18,12 @@ final class UserDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* order_source : canal de saisie du role ('counter' | 'drive' | '' pour les
|
* @return array{name: string, role_label: string, email: string}
|
||||||
* roles globaux admin/manager/kitchen). Sert au layout a router le lien
|
|
||||||
* "Saisie commande" vers la landing du bon canal sans une requete dediee.
|
|
||||||
*
|
|
||||||
* @return array{name: string, role_label: string, email: string, order_source: string}
|
|
||||||
*/
|
*/
|
||||||
public function displayInfo(int $userId): array
|
public function displayInfo(int $userId): array
|
||||||
{
|
{
|
||||||
$row = $this->db->fetch(
|
$row = $this->db->fetch(
|
||||||
'SELECT u.first_name, u.last_name, u.email, r.label AS role_label, r.order_source '
|
'SELECT u.first_name, u.last_name, u.email, r.label AS role_label '
|
||||||
. 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id',
|
. 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id',
|
||||||
['id' => $userId],
|
['id' => $userId],
|
||||||
);
|
);
|
||||||
|
|
@ -37,10 +33,9 @@ final class UserDirectory
|
||||||
$name = trim($first . ' ' . $last);
|
$name = trim($first . ' ' . $last);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => $name !== '' ? $name : 'Utilisateur',
|
'name' => $name !== '' ? $name : 'Utilisateur',
|
||||||
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
|
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
|
||||||
'email' => is_string($row['email'] ?? null) ? $row['email'] : '',
|
'email' => is_string($row['email'] ?? null) ? $row['email'] : '',
|
||||||
'order_source' => is_string($row['order_source'] ?? null) ? $row['order_source'] : '',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,8 @@ use App\Core\DatabaseInterface;
|
||||||
/**
|
/**
|
||||||
* Lecture des allergenes a declaration obligatoire (INCO) : info GENERALE (les 14
|
* Lecture des allergenes a declaration obligatoire (INCO) : info GENERALE (les 14
|
||||||
* categories), pas un calcul par produit (le mapping ingredient_allergen reste
|
* categories), pas un calcul par produit (le mapping ingredient_allergen reste
|
||||||
* differe). Sert l'endpoint public anonyme /api/allergens. Le schema porte
|
* differe). Sert l'endpoint public anonyme /api/allergens. Le schema ne porte que
|
||||||
* code + name + description ; la description (texte INCO seede) est exposee par
|
* code + name ; les descriptions riches restent cote borne (data/allergens.json).
|
||||||
* l'API et consommee par la borne via /api/allergens.
|
|
||||||
*
|
*
|
||||||
* Non `final` : seam de test (sous-classe -> double sans base).
|
* Non `final` : seam de test (sous-classe -> double sans base).
|
||||||
*/
|
*/
|
||||||
|
|
@ -28,6 +27,6 @@ class AllergenRepository
|
||||||
*/
|
*/
|
||||||
public function all(): array
|
public function all(): array
|
||||||
{
|
{
|
||||||
return $this->db->fetchAll('SELECT id, code, name, description FROM allergen ORDER BY id');
|
return $this->db->fetchAll('SELECT id, code, name FROM allergen ORDER BY id');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,23 +135,6 @@ final class IngredientRepository
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reglage rapide des seuils depuis le tableau de bord stock (F13). Cible UNIQUEMENT
|
|
||||||
* les trois colonnes de calibrage (capacite = reference 100 %, seuils alerte/critique
|
|
||||||
* en %), distinctes de update() qui exige aussi name/unit/pack. stock_quantity n'est
|
|
||||||
* jamais touche : le niveau ne bouge que via restock/inventoryCount (ledger). Les
|
|
||||||
* bornes (capacite >= 1, % 0-100, critique < alerte strict) sont validees par
|
|
||||||
* l'appelant (controleur, RG-T18), pas ici.
|
|
||||||
*/
|
|
||||||
public function updateThresholds(int $id, int $capacity, int $low, int $critical): void
|
|
||||||
{
|
|
||||||
$this->db->execute(
|
|
||||||
'UPDATE ingredient SET stock_capacity = :cap, low_stock_pct = :low, '
|
|
||||||
. 'critical_stock_pct = :crit WHERE id = :id',
|
|
||||||
['cap' => $capacity, 'low' => $low, 'crit' => $critical, 'id' => $id],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suppression dure. Bloquee par FK RESTRICT (product_ingredient / stock_movement)
|
* Suppression dure. Bloquee par FK RESTRICT (product_ingredient / stock_movement)
|
||||||
* des qu'une recette ou un mouvement reference l'ingredient ; le controleur
|
* des qu'une recette ou un mouvement reference l'ingredient ; le controleur
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,8 @@ final class MenuRepository
|
||||||
* disponibles (is_available = 1) ET en categorie active (c.is_active = 1).
|
* disponibles (is_available = 1) ET en categorie active (c.is_active = 1).
|
||||||
* Projection enrichie (description, image_path) absente de all() back-office.
|
* Projection enrichie (description, image_path) absente de all() back-office.
|
||||||
* Liste LEGERE : sans les slots (le detail /api/menus/{id} les porte). La
|
* Liste LEGERE : sans les slots (le detail /api/menus/{id} les porte). La
|
||||||
* disponibilite du burger impose (B1, RG-T21) est calculee par CatalogueController
|
* disponibilite du burger impose (B1) reste un raffinement de la dispo calculee
|
||||||
* (croisement avec ProductRepository::autoUnavailableIds) et exposee en is_orderable :
|
* RG-T21, differe au seed des recettes.
|
||||||
* un menu dont le burger est en rupture est grise par la borne (granularite burger seul).
|
|
||||||
*
|
*
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
|
|
@ -147,44 +146,6 @@ final class MenuRepository
|
||||||
return $this->db->fetch('SELECT id FROM product WHERE id = :id', ['id' => $id]) !== null;
|
return $this->db->fetch('SELECT id FROM product WHERE id = :id', ['id' => $id]) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Le produit existe-t-il ET est-il un produit de BASE (base_product_id IS NULL,
|
|
||||||
* R4) ? Garde serveur de l'eligibilite au menu (F9-2) : un menu ne peut prendre
|
|
||||||
* comme burger principal NI comme option de slot une VARIANTE de taille (ex.
|
|
||||||
* "Coca Cola 50cl"), qui n'est pas un produit autonome. Predicat plus strict que
|
|
||||||
* productExists() : il rejette une variante meme si l'UI est contournee. Le
|
|
||||||
* formulaire menu n'expose deja que des bases (ProductRepository::basesOnly),
|
|
||||||
* cette garde verrouille le chemin serveur en plus.
|
|
||||||
*/
|
|
||||||
public function productIsBase(int $id): bool
|
|
||||||
{
|
|
||||||
return $this->db->fetch(
|
|
||||||
'SELECT id FROM product WHERE id = :id AND base_product_id IS NULL',
|
|
||||||
['id' => $id],
|
|
||||||
) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Slug de categorie d'un produit, ou null si l'id est inconnu. Garde serveur F12 :
|
|
||||||
* une option de slot doit appartenir a une categorie autorisee pour le slot_type
|
|
||||||
* du slot (mapping unique cote MenuController). Le controleur croise ce slug avec
|
|
||||||
* la liste autorisee et rejette (422) une option hors categorie meme si l'UI de
|
|
||||||
* filtrage est contournee -- defense en profondeur (RG-T18), par-dessus la garde
|
|
||||||
* base-only existante (productIsBase, F9).
|
|
||||||
*/
|
|
||||||
public function productCategorySlug(int $id): ?string
|
|
||||||
{
|
|
||||||
$row = $this->db->fetch(
|
|
||||||
'SELECT c.slug AS category_slug FROM product p '
|
|
||||||
. 'JOIN category c ON c.id = p.category_id WHERE p.id = :id',
|
|
||||||
['id' => $id],
|
|
||||||
);
|
|
||||||
|
|
||||||
$slug = $row['category_slug'] ?? null;
|
|
||||||
|
|
||||||
return is_string($slug) ? $slug : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-verification FK-safe (mlt 8.6 RG-1) : le menu est-il reference par une
|
* 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.
|
* ligne de commande historique ? La FK order_item.menu_id est RESTRICT.
|
||||||
|
|
|
||||||
|
|
@ -28,16 +28,7 @@ final class ProductRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste pour le back-office, avec le libelle de categorie et, pour une VARIANTE
|
* Liste pour le back-office, avec le libelle de categorie.
|
||||||
* de taille (base_product_id non nul, R4), le nom de sa base. La liste admin
|
|
||||||
* affiche AINSI toutes les lignes produit -- bases ET variantes -- mais marque
|
|
||||||
* chaque variante "Variante de X" : l'admin la voit, comprend qu'elle n'est pas
|
|
||||||
* un produit autonome, et peut la delier/relier via le formulaire. La projection
|
|
||||||
* remonte base_product_id pour que la vue distingue les deux.
|
|
||||||
*
|
|
||||||
* Cette methode N'ALIMENTE PLUS les selects du formulaire menu (qui doivent etre
|
|
||||||
* base-only, R4/F9-1) : ceux-ci passent par basesOnly(). all() peut donc porter
|
|
||||||
* le LEFT JOIN d'enrichissement sans fausser une liste deroulante.
|
|
||||||
*
|
*
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
|
|
@ -45,53 +36,8 @@ final class ProductRepository
|
||||||
{
|
{
|
||||||
return $this->db->fetchAll(
|
return $this->db->fetchAll(
|
||||||
'SELECT p.id, p.category_id, p.name, p.price_cents, p.vat_rate, p.is_available, '
|
'SELECT p.id, p.category_id, p.name, p.price_cents, p.vat_rate, p.is_available, '
|
||||||
. 'p.display_order, p.size_cl, p.base_product_id, c.name AS category_name, '
|
. 'p.display_order, c.name AS category_name '
|
||||||
. 'b.name AS base_name '
|
|
||||||
. 'FROM product p JOIN category c ON c.id = p.category_id '
|
. 'FROM product p JOIN category c ON c.id = p.category_id '
|
||||||
. 'LEFT JOIN product b ON b.id = p.base_product_id '
|
|
||||||
. 'ORDER BY p.display_order, p.name',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Produits de BASE uniquement (base_product_id IS NULL, R4), pour alimenter les
|
|
||||||
* listes deroulantes du formulaire menu (burger principal + options de slot,
|
|
||||||
* F9-1) et le select base_product_id du formulaire produit. Une VARIANTE de
|
|
||||||
* taille (ex. "Coca Cola 50cl") n'est jamais un produit autonome : la proposer
|
|
||||||
* comme burger/option/base ferait apparaitre la variante comme un produit a part
|
|
||||||
* entiere. Le predicat anti-variante vit ici (cote requete), miroir de la garde
|
|
||||||
* serveur MenuRepository::productIsBase(). Projection minimale {id, name} : seules
|
|
||||||
* colonnes utiles a un <option>.
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function basesOnly(): array
|
|
||||||
{
|
|
||||||
return $this->db->fetchAll(
|
|
||||||
'SELECT id, name FROM product WHERE base_product_id IS NULL '
|
|
||||||
. 'ORDER BY display_order, name',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Produits de BASE (base_product_id IS NULL, R4) avec le slug de leur CATEGORIE,
|
|
||||||
* pour alimenter les OPTIONS de slot du formulaire menu (F12). Le formulaire doit
|
|
||||||
* filtrer les options proposees selon le type de slot (drink -> boissons, etc.) ;
|
|
||||||
* il lui faut donc la categorie de chaque produit, que basesOnly() (projection
|
|
||||||
* stricte {id, name}) ne porte pas. Methode dediee plutot qu'extension de
|
|
||||||
* basesOnly() : ce dernier alimente aussi le select base_product_id du formulaire
|
|
||||||
* produit (ProductController), qui n'a pas besoin de la categorie -- garder son
|
|
||||||
* contrat minimal evite un couplage inutile. Meme predicat anti-variante que
|
|
||||||
* basesOnly(), miroir de la garde serveur MenuRepository::productIsBase().
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function baseOptionsWithCategory(): array
|
|
||||||
{
|
|
||||||
return $this->db->fetchAll(
|
|
||||||
'SELECT p.id, p.name, c.slug AS category_slug '
|
|
||||||
. 'FROM product p JOIN category c ON c.id = p.category_id '
|
|
||||||
. 'WHERE p.base_product_id IS NULL '
|
|
||||||
. 'ORDER BY p.display_order, p.name',
|
. 'ORDER BY p.display_order, p.name',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -104,12 +50,9 @@ final class ProductRepository
|
||||||
// maxi_variant_product_id : expose la variante Grande de l'accompagnement
|
// maxi_variant_product_id : expose la variante Grande de l'accompagnement
|
||||||
// pour que OrderRepository::resolveSelections puisse substituer au format
|
// pour que OrderRepository::resolveSelections puisse substituer au format
|
||||||
// Maxi (cote serveur uniquement ; la borne n'en a pas besoin).
|
// Maxi (cote serveur uniquement ; la borne n'en a pas besoin).
|
||||||
// size_cl + base_product_id (R4) : remontes pour que le formulaire produit
|
|
||||||
// pre-remplisse les champs de variante a l'edition (F9-3).
|
|
||||||
return $this->db->fetch(
|
return $this->db->fetch(
|
||||||
'SELECT id, category_id, name, description, price_cents, size_cl, base_product_id, '
|
'SELECT id, category_id, name, description, price_cents, maxi_variant_product_id, '
|
||||||
. 'maxi_variant_product_id, vat_rate, image_path, is_available, display_order '
|
. 'vat_rate, image_path, is_available, display_order FROM product WHERE id = :id',
|
||||||
. 'FROM product WHERE id = :id',
|
|
||||||
['id' => $id],
|
['id' => $id],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -120,10 +63,8 @@ final class ProductRepository
|
||||||
* (c.is_active = 1), pour ne jamais proposer un produit dont l'onglet de
|
* (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
|
* 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
|
* vit cote serveur a la commande, la borne ne l'affiche pas. Filtre de
|
||||||
* disponibilite = flag is_available (retrait manuel) ; la dispo CALCULEE RG-T21
|
* disponibilite = flag is_available ; la dispo CALCULEE RG-T21 (exclusion des
|
||||||
* (rupture par stock) n'exclut PAS la ligne ici : CatalogueController la croise
|
* ruptures auto via autoUnavailableIds) se branchera au seed des recettes.
|
||||||
* avec autoUnavailableIds() pour exposer is_orderable, et la borne grise la tuile
|
|
||||||
* (visible mais non commandable) au lieu de la masquer.
|
|
||||||
*
|
*
|
||||||
* base_product_id IS NULL (R4) : les VARIANTES de taille (ex. "Coca Cola 50cl")
|
* 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
|
* ne sont jamais des tuiles catalogue autonomes ; elles sont atteintes via le
|
||||||
|
|
@ -141,11 +82,11 @@ final class ProductRepository
|
||||||
// un libelle d'affichage seulement.
|
// un libelle d'affichage seulement.
|
||||||
return $this->db->fetchAll(
|
return $this->db->fetchAll(
|
||||||
'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, p.size_cl, '
|
'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, p.size_cl, '
|
||||||
. 'p.image_path, p.display_order, c.name AS category_name, mv.name AS maxi_variant_name '
|
. 'p.image_path, p.display_order, mv.name AS maxi_variant_name '
|
||||||
. 'FROM product p JOIN category c ON c.id = p.category_id '
|
. 'FROM product p JOIN category c ON c.id = p.category_id '
|
||||||
. 'LEFT JOIN product mv ON mv.id = p.maxi_variant_product_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 '
|
. 'WHERE p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL '
|
||||||
. 'ORDER BY c.display_order, c.name, p.display_order, p.name',
|
. 'ORDER BY p.display_order, p.name',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,49 +181,27 @@ final class ProductRepository
|
||||||
return $this->db->fetch('SELECT id FROM category WHERE id = :id', ['id' => $categoryId]) !== null;
|
return $this->db->fetch('SELECT id FROM category WHERE id = :id', ['id' => $categoryId]) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function productExists(int $id): bool
|
|
||||||
{
|
|
||||||
return $this->db->fetch('SELECT id FROM product WHERE id = :id', ['id' => $id]) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Le produit existe-t-il ET est-il une BASE (base_product_id IS NULL, R4) ?
|
* @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
|
||||||
* Sert a valider les FK de variante du formulaire produit (F9-3) : une base ne
|
|
||||||
* peut pointer vers une AUTRE variante (pas de chaine de variantes), et la cible
|
|
||||||
* d'une variante de taille doit elle-meme etre une base. Retourne false si l'id
|
|
||||||
* est inconnu OU si la ligne est deja une variante.
|
|
||||||
*/
|
|
||||||
public function productIsBase(int $id): bool
|
|
||||||
{
|
|
||||||
return $this->db->fetch(
|
|
||||||
'SELECT id FROM product WHERE id = :id AND base_product_id IS NULL',
|
|
||||||
['id' => $id],
|
|
||||||
) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
|
|
||||||
*/
|
*/
|
||||||
public function create(array $data): void
|
public function create(array $data): void
|
||||||
{
|
{
|
||||||
$this->db->execute(
|
$this->db->execute(
|
||||||
'INSERT INTO product (category_id, name, description, price_cents, size_cl, base_product_id, '
|
'INSERT INTO product (category_id, name, description, price_cents, vat_rate, image_path, is_available, display_order) '
|
||||||
. 'maxi_variant_product_id, vat_rate, image_path, is_available, display_order) '
|
. 'VALUES (:category, :name, :description, :price, :vat, :image, :available, :ord)',
|
||||||
. 'VALUES (:category, :name, :description, :price, :size, :base, :maxi, :vat, :image, :available, :ord)',
|
|
||||||
$this->bind($data),
|
$this->bind($data),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
|
* @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
|
public function update(int $id, array $data): void
|
||||||
{
|
{
|
||||||
$this->db->execute(
|
$this->db->execute(
|
||||||
'UPDATE product SET category_id = :category, name = :name, description = :description, '
|
'UPDATE product SET category_id = :category, name = :name, description = :description, '
|
||||||
. 'price_cents = :price, size_cl = :size, base_product_id = :base, '
|
. 'price_cents = :price, vat_rate = :vat, image_path = :image, is_available = :available, '
|
||||||
. 'maxi_variant_product_id = :maxi, vat_rate = :vat, image_path = :image, '
|
. 'display_order = :ord WHERE id = :id',
|
||||||
. 'is_available = :available, display_order = :ord WHERE id = :id',
|
|
||||||
$this->bind($data) + ['id' => $id],
|
$this->bind($data) + ['id' => $id],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -422,7 +341,7 @@ final class ProductRepository
|
||||||
/**
|
/**
|
||||||
* Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees.
|
* Allowlist d'affectation de masse (RG-T16) : seules ces colonnes sont liees.
|
||||||
*
|
*
|
||||||
* @param array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int} $data
|
* @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<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function bind(array $data): array
|
private function bind(array $data): array
|
||||||
|
|
@ -432,11 +351,6 @@ final class ProductRepository
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
'description' => $data['description'],
|
'description' => $data['description'],
|
||||||
'price' => $data['price_cents'],
|
'price' => $data['price_cents'],
|
||||||
// Champs de variante (R4/0006-0007), tous nullables : null = produit de
|
|
||||||
// base/autonome sans dimension taille ni substitution Maxi.
|
|
||||||
'size' => $data['size_cl'],
|
|
||||||
'base' => $data['base_product_id'],
|
|
||||||
'maxi' => $data['maxi_variant_product_id'],
|
|
||||||
'vat' => $data['vat_rate'],
|
'vat' => $data['vat_rate'],
|
||||||
'image' => $data['image_path'],
|
'image' => $data['image_path'],
|
||||||
'available' => $data['is_available'],
|
'available' => $data['is_available'],
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,6 @@ abstract class AdminController extends AuthenticatedController
|
||||||
'permissions' => $this->authorizer()->permissionsFor($roleId),
|
'permissions' => $this->authorizer()->permissionsFor($roleId),
|
||||||
'csrfToken' => Csrf::token($this->sessionManager()),
|
'csrfToken' => Csrf::token($this->sessionManager()),
|
||||||
'activeNav' => '',
|
'activeNav' => '',
|
||||||
// Canal de saisie du role courant ('counter' | 'drive') pour que le lien
|
|
||||||
// "Saisie commande" du layout envoie un equipier drive vers /drive/orders
|
|
||||||
// et un equipier comptoir vers /counter/orders. Derive de role.order_source
|
|
||||||
// (remonte par displayInfo, qui joint deja la table role).
|
|
||||||
'orderChannel' => $info['order_source'] === 'drive' ? 'drive' : 'counter',
|
|
||||||
'flash' => $this->takeFlash(),
|
'flash' => $this->takeFlash(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,16 +56,8 @@ class CatalogueController extends Controller
|
||||||
// requete (sizesByBase), pas une par produit -> /api/products reste un seul
|
// requete (sizesByBase), pas une par produit -> /api/products reste un seul
|
||||||
// aller-retour cache-friendly cote borne (data.js memoise la liste).
|
// aller-retour cache-friendly cote borne (data.js memoise la liste).
|
||||||
$sizesByBase = $repo->sizesByBase();
|
$sizesByBase = $repo->sizesByBase();
|
||||||
// RG-T21 : rupture calculee par le stock, en UNE requete (set d'ids). Un
|
|
||||||
// produit liste (is_available=1) mais en rupture devient non commandable ->
|
|
||||||
// la borne le grise au lieu de laisser composer une commande vouee a echouer.
|
|
||||||
$unavailable = array_fill_keys($repo->autoUnavailableIds(), true);
|
|
||||||
$rows = array_map(
|
$rows = array_map(
|
||||||
fn (array $row): array => $this->presentProduct(
|
fn (array $row): array => $this->presentProduct($row, $sizesByBase[(int) ($row['id'] ?? 0)] ?? []),
|
||||||
$row,
|
|
||||||
$sizesByBase[(int) ($row['id'] ?? 0)] ?? [],
|
|
||||||
!isset($unavailable[(int) ($row['id'] ?? 0)]),
|
|
||||||
),
|
|
||||||
$repo->availableForCatalogue(),
|
$repo->availableForCatalogue(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -92,10 +84,8 @@ class CatalogueController extends Controller
|
||||||
// au moins une VARIANTE (sinon sizesForProduct ne remonte que la base, et la
|
// 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).
|
// base seule n'est pas une dimension de taille -> sizes vide cote presentation).
|
||||||
$sizes = $repo->sizesForProduct($id);
|
$sizes = $repo->sizesForProduct($id);
|
||||||
// RG-T21 : meme dispo calculee qu'en liste, pour ce produit (membership du set).
|
|
||||||
$orderable = !in_array($id, $repo->autoUnavailableIds(), true);
|
|
||||||
|
|
||||||
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [], $orderable)]);
|
return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -103,15 +93,8 @@ class CatalogueController extends Controller
|
||||||
*/
|
*/
|
||||||
public function menus(array $params = []): Response
|
public function menus(array $params = []): Response
|
||||||
{
|
{
|
||||||
// RG-T21 (granularite : burger impose seul) : un menu dont le burger principal
|
|
||||||
// est en rupture calculee n'est plus commandable. Set d'ids produits en rupture
|
|
||||||
// reutilise pour tous les menus (pas de N+1).
|
|
||||||
$unavailable = array_fill_keys($this->productsRepo()->autoUnavailableIds(), true);
|
|
||||||
$rows = array_map(
|
$rows = array_map(
|
||||||
fn (array $row): array => $this->presentMenu(
|
fn (array $row): array => $this->presentMenu($row),
|
||||||
$row,
|
|
||||||
!isset($unavailable[(int) ($row['burger_product_id'] ?? 0)]),
|
|
||||||
),
|
|
||||||
$this->menusRepo()->availableForCatalogue(),
|
$this->menusRepo()->availableForCatalogue(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -134,10 +117,8 @@ class CatalogueController extends Controller
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// RG-T21 (burger impose seul) : dispo calculee du menu = burger non en rupture.
|
|
||||||
$orderable = !in_array((int) ($row['burger_product_id'] ?? 0), $this->productsRepo()->autoUnavailableIds(), true);
|
|
||||||
// Detail = menu + ses slots de composition (B1 burger impose, B2 Normal/Maxi).
|
// Detail = menu + ses slots de composition (B1 burger impose, B2 Normal/Maxi).
|
||||||
$menu = $this->presentMenu($row, $orderable) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
|
$menu = $this->presentMenu($row) + ['slots' => $this->presentSlots($repo->slotsWithOptions($id))];
|
||||||
|
|
||||||
return $this->json(['data' => $menu]);
|
return $this->json(['data' => $menu]);
|
||||||
}
|
}
|
||||||
|
|
@ -187,15 +168,14 @@ class CatalogueController extends Controller
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $row
|
* @param array<string, mixed> $row
|
||||||
* @return array{id: int, code: string, name: string, description: ?string}
|
* @return array{id: int, code: string, name: string}
|
||||||
*/
|
*/
|
||||||
private function presentAllergen(array $row): array
|
private function presentAllergen(array $row): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => (int) ($row['id'] ?? 0),
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
'code' => (string) ($row['code'] ?? ''),
|
'code' => (string) ($row['code'] ?? ''),
|
||||||
'name' => (string) ($row['name'] ?? ''),
|
'name' => (string) ($row['name'] ?? ''),
|
||||||
'description' => $this->nullableString($row['description'] ?? null),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,9 +200,9 @@ class CatalogueController extends Controller
|
||||||
* variantes ; vide si le produit n'a pas de dimension taille. Chaque entree
|
* 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
|
* 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.
|
* derive du volume ("30 cl") -- aucun slug/enum ne fuit a l'ecran.
|
||||||
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list<array{product_id: int, size_cl: int, price_cents: int, label: string}>, is_orderable: bool}
|
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list<array{product_id: int, size_cl: int, price_cents: int, label: string}>}
|
||||||
*/
|
*/
|
||||||
private function presentProduct(array $row, array $sizes = [], bool $isOrderable = true): array
|
private function presentProduct(array $row, array $sizes = []): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => (int) ($row['id'] ?? 0),
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
|
@ -249,19 +229,14 @@ class CatalogueController extends Controller
|
||||||
},
|
},
|
||||||
array_values($sizes),
|
array_values($sizes),
|
||||||
),
|
),
|
||||||
// is_orderable : false si rupture calculee par le stock (RG-T21). La borne
|
|
||||||
// grise la tuile (echo UX) ; l'enforcement qui fait foi est cote serveur a la
|
|
||||||
// creation de commande (OrderRepository::resolveLine refuse un item en
|
|
||||||
// rupture). Le retrait manuel (is_available=0) est deja exclu en amont.
|
|
||||||
'is_orderable' => $isOrderable,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $row
|
* @param array<string, mixed> $row
|
||||||
* @return array{id: int, category_id: int, burger_product_id: int, name: string, description: ?string, price_normal_cents: int, price_maxi_cents: int, image_path: ?string, display_order: int, is_orderable: bool}
|
* @return array{id: int, category_id: int, burger_product_id: int, name: string, description: ?string, price_normal_cents: int, price_maxi_cents: int, image_path: ?string, display_order: int}
|
||||||
*/
|
*/
|
||||||
private function presentMenu(array $row, bool $isOrderable = true): array
|
private function presentMenu(array $row): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => (int) ($row['id'] ?? 0),
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
|
|
@ -273,9 +248,6 @@ class CatalogueController extends Controller
|
||||||
'price_maxi_cents' => (int) ($row['price_maxi_cents'] ?? 0),
|
'price_maxi_cents' => (int) ($row['price_maxi_cents'] ?? 0),
|
||||||
'image_path' => $this->nullableString($row['image_path'] ?? null),
|
'image_path' => $this->nullableString($row['image_path'] ?? null),
|
||||||
'display_order' => (int) ($row['display_order'] ?? 0),
|
'display_order' => (int) ($row['display_order'] ?? 0),
|
||||||
// is_orderable : false si le burger impose est en rupture calculee (RG-T21,
|
|
||||||
// granularite burger seul). La borne grise le menu.
|
|
||||||
'is_orderable' => $isOrderable,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,25 +56,18 @@ class CounterOrderController extends AdminController
|
||||||
}
|
}
|
||||||
|
|
||||||
$source = $this->source();
|
$source = $this->source();
|
||||||
$orderQuery = $this->orderQuery();
|
|
||||||
|
|
||||||
// RG-1 (5.1, source filter) : ne lister que les commandes du canal. recent()
|
// 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
|
// 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.
|
// chemin pour que le comptoir ne voie pas le drive et inversement.
|
||||||
$orders = array_values(array_filter(
|
$orders = array_values(array_filter(
|
||||||
$orderQuery->recent(50),
|
$this->orderQuery()->recent(50),
|
||||||
static fn (array $o): bool => (string) ($o['source'] ?? '') === $source,
|
static fn (array $o): bool => (string) ($o['source'] ?? '') === $source,
|
||||||
));
|
));
|
||||||
|
|
||||||
// File "En cours" (RG-T12) : commandes du canal au statut paid non livrees,
|
|
||||||
// la plus ancienne d'abord (tri paid_at croissant fait par paidQueue). Filtree
|
|
||||||
// a la SEULE source du canal pour que l'equipier ne voie que ce qu'il sert.
|
|
||||||
$inProgress = $orderQuery->paidQueue([$source]);
|
|
||||||
|
|
||||||
return $this->channelView('admin/counter/index', $source, [
|
return $this->channelView('admin/counter/index', $source, [
|
||||||
'title' => $this->channelTitle($source) . ' - Wakdo Admin',
|
'title' => $this->channelTitle($source) . ' - Wakdo Admin',
|
||||||
'orders' => $orders,
|
'orders' => $orders,
|
||||||
'inProgress' => $inProgress,
|
|
||||||
], $guard);
|
], $guard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,11 +115,6 @@ class CounterOrderController extends AdminController
|
||||||
$source = $this->source();
|
$source = $this->source();
|
||||||
$serviceMode = (string) ($form['service_mode'] ?? '');
|
$serviceMode = (string) ($form['service_mode'] ?? '');
|
||||||
|
|
||||||
// Numero de table (confort comptoir) : ne porte de sens qu'en sur place. On ne
|
|
||||||
// le transmet qu'en dine_in ; persist() le rejette de toute facon hors dine_in,
|
|
||||||
// mais ne pas le passer evite un INVALID_SERVICE_TAG sur une saisie residuelle.
|
|
||||||
$serviceTag = $serviceMode === 'dine_in' ? trim((string) ($form['service_tag'] ?? '')) : '';
|
|
||||||
|
|
||||||
// Chemin unifie : le panier construit par counter-order.js arrive serialise
|
// Chemin unifie : le panier construit par counter-order.js arrive serialise
|
||||||
// dans items_json. Quand il est present, il fait foi ; les quantites legacy
|
// dans items_json. Quand il est present, il fait foi ; les quantites legacy
|
||||||
// qty_<id> ne servent qu'au repli sans JS (degradation gracieuse).
|
// qty_<id> ne servent qu'au repli sans JS (degradation gracieuse).
|
||||||
|
|
@ -139,14 +127,9 @@ class CounterOrderController extends AdminController
|
||||||
return $this->renderForm($guard, $source, $form, 'Ajoutez au moins un produit ou un menu.', 422);
|
return $this->renderForm($guard, $source, $form, 'Ajoutez au moins un produit ou un menu.', 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$req = ['service_mode' => $serviceMode, 'items' => $items];
|
|
||||||
if ($serviceTag !== '') {
|
|
||||||
$req['service_tag'] = $serviceTag;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$order = $this->orders()->createStaffOrder(
|
$order = $this->orders()->createStaffOrder(
|
||||||
$req,
|
['service_mode' => $serviceMode, 'items' => $items],
|
||||||
$guard->userId ?? 0,
|
$guard->userId ?? 0,
|
||||||
$source,
|
$source,
|
||||||
);
|
);
|
||||||
|
|
@ -364,18 +347,10 @@ class CounterOrderController extends AdminController
|
||||||
$productRepository = $this->productRepository();
|
$productRepository = $this->productRepository();
|
||||||
$products = $productRepository->availableForCatalogue();
|
$products = $productRepository->availableForCatalogue();
|
||||||
|
|
||||||
// RG-T21 (parite borne) : rupture calculee par le stock, en UNE requete (set
|
|
||||||
// d'ids). availableForCatalogue() ne filtre que le retrait manuel (is_available)
|
|
||||||
// ; un produit liste mais en rupture devient non commandable -> le POS grise la
|
|
||||||
// tuile au lieu de laisser composer une commande vouee a echouer (meme echo UX
|
|
||||||
// que CatalogueController cote borne ; l'enforcement reste serveur a la commande).
|
|
||||||
$unavailable = array_fill_keys($productRepository->autoUnavailableIds(), true);
|
|
||||||
|
|
||||||
// Modificateurs proposables par produit a la carte : seuls les produits dont la
|
// Modificateurs proposables par produit a la carte : seuls les produits dont la
|
||||||
// recette offre au moins un ingredient retirable/ajoutable portent une compo.
|
// recette offre au moins un ingredient retirable/ajoutable portent une compo.
|
||||||
$products = array_map(function (array $product) use ($productRepository, $unavailable): array {
|
$products = array_map(function (array $product) use ($productRepository): array {
|
||||||
$product['modifiers'] = $this->proposableModifiers($productRepository, (int) ($product['id'] ?? 0));
|
$product['modifiers'] = $this->proposableModifiers($productRepository, (int) ($product['id'] ?? 0));
|
||||||
$product['is_orderable'] = !isset($unavailable[(int) ($product['id'] ?? 0)]);
|
|
||||||
|
|
||||||
return $product;
|
return $product;
|
||||||
}, $products);
|
}, $products);
|
||||||
|
|
@ -383,9 +358,8 @@ class CounterOrderController extends AdminController
|
||||||
return $this->channelView('admin/counter/new', $source, [
|
return $this->channelView('admin/counter/new', $source, [
|
||||||
'title' => 'Nouvelle commande ' . ($source === 'drive' ? 'drive' : 'comptoir') . ' - Wakdo Admin',
|
'title' => 'Nouvelle commande ' . ($source === 'drive' ? 'drive' : 'comptoir') . ' - Wakdo Admin',
|
||||||
'products' => $products,
|
'products' => $products,
|
||||||
'menus' => $this->menusWithSlots($productRepository, $unavailable),
|
'menus' => $this->menusWithSlots($productRepository),
|
||||||
'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')),
|
'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')),
|
||||||
'serviceTag' => (string) ($values['service_tag'] ?? ''),
|
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
], $guard, $status);
|
], $guard, $status);
|
||||||
}
|
}
|
||||||
|
|
@ -400,21 +374,16 @@ class CounterOrderController extends AdminController
|
||||||
* cote borne ; `burger_modifiers` calque proposableModifiers() (la selection de
|
* cote borne ; `burger_modifiers` calque proposableModifiers() (la selection de
|
||||||
* modificateurs d'un menu cible le burger, comme resolveModifiers cote serveur).
|
* modificateurs d'un menu cible le burger, comme resolveModifiers cote serveur).
|
||||||
*
|
*
|
||||||
* @param array<int, true> $unavailable set d'ids produits en rupture calculee (RG-T21),
|
|
||||||
* partage avec renderForm pour ne pas refaire la requete autoUnavailableIds.
|
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
private function menusWithSlots(ProductRepository $productRepository, array $unavailable): array
|
private function menusWithSlots(ProductRepository $productRepository): array
|
||||||
{
|
{
|
||||||
$menuRepository = $this->menuRepository();
|
$menuRepository = $this->menuRepository();
|
||||||
$menus = $menuRepository->availableForCatalogue();
|
$menus = $menuRepository->availableForCatalogue();
|
||||||
|
|
||||||
return array_map(function (array $menu) use ($menuRepository, $productRepository, $unavailable): array {
|
return array_map(function (array $menu) use ($menuRepository, $productRepository): array {
|
||||||
$menu['slots'] = $menuRepository->slotsWithOptions((int) ($menu['id'] ?? 0));
|
$menu['slots'] = $menuRepository->slotsWithOptions((int) ($menu['id'] ?? 0));
|
||||||
$menu['burger_modifiers'] = $this->proposableModifiers($productRepository, (int) ($menu['burger_product_id'] ?? 0));
|
$menu['burger_modifiers'] = $this->proposableModifiers($productRepository, (int) ($menu['burger_product_id'] ?? 0));
|
||||||
// RG-T21 (granularite burger impose seul, parite borne) : un menu dont le
|
|
||||||
// burger principal est en rupture calculee n'est plus commandable -> grise.
|
|
||||||
$menu['is_orderable'] = !isset($unavailable[(int) ($menu['burger_product_id'] ?? 0)]);
|
|
||||||
|
|
||||||
return $menu;
|
return $menu;
|
||||||
}, $menus);
|
}, $menus);
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,9 @@ use App\Core\Response;
|
||||||
* Sonde de sante. GET /api/health.
|
* Sonde de sante. GET /api/health.
|
||||||
*
|
*
|
||||||
* Le comptage des categories prouve la chaine complete (autoloader -> routeur
|
* Le comptage des categories prouve la chaine complete (autoloader -> routeur
|
||||||
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. Expose aussi
|
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond.
|
||||||
* la version deployee (SHA + date), ecrite par scripts/deploy.sh : c'est la preuve
|
|
||||||
* cote app du CD (apres un deploiement, ce champ reflete le nouveau commit).
|
|
||||||
*
|
|
||||||
* Non-final : seam de test (la sous-classe redirige versionFilePath sur une fixture).
|
|
||||||
*/
|
*/
|
||||||
class HealthController extends Controller
|
final class HealthController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $params
|
* @param array<string, string> $params
|
||||||
|
|
@ -39,8 +35,6 @@ class HealthController extends Controller
|
||||||
$httpStatus = 503;
|
$httpStatus = 503;
|
||||||
}
|
}
|
||||||
|
|
||||||
$version = $this->readVersion();
|
|
||||||
|
|
||||||
return $this->json(
|
return $this->json(
|
||||||
[
|
[
|
||||||
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
|
'status' => $dbStatus === 'ok' ? 'ok' : 'degraded',
|
||||||
|
|
@ -48,45 +42,8 @@ class HealthController extends Controller
|
||||||
'php_version' => PHP_VERSION,
|
'php_version' => PHP_VERSION,
|
||||||
'db' => $dbStatus,
|
'db' => $dbStatus,
|
||||||
'categories' => $categories,
|
'categories' => $categories,
|
||||||
'version' => $version['version'],
|
|
||||||
'deployed_at' => $version['deployed_at'],
|
|
||||||
],
|
],
|
||||||
$httpStatus,
|
$httpStatus,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Chemin du marqueur de version. Sous le mount du code (./src -> /var/www/html),
|
|
||||||
* donc lisible a chaud par l'app sans rebuild.
|
|
||||||
*/
|
|
||||||
protected function versionFilePath(): string
|
|
||||||
{
|
|
||||||
return dirname(__DIR__, 2) . '/VERSION';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lit "SHA<espace>date" ecrit par deploy.sh. Absence toleree (dev / avant 1er
|
|
||||||
* deploiement) : les deux champs retombent a null.
|
|
||||||
*
|
|
||||||
* @return array{version: ?string, deployed_at: ?string}
|
|
||||||
*/
|
|
||||||
private function readVersion(): array
|
|
||||||
{
|
|
||||||
$path = $this->versionFilePath();
|
|
||||||
if (!is_file($path) || !is_readable($path)) {
|
|
||||||
return ['version' => null, 'deployed_at' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
$line = trim((string) @file_get_contents($path));
|
|
||||||
if ($line === '') {
|
|
||||||
return ['version' => null, 'deployed_at' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = explode(' ', $line, 2);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'version' => $parts[0] !== '' ? $parts[0] : null,
|
|
||||||
'deployed_at' => isset($parts[1]) && $parts[1] !== '' ? $parts[1] : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,38 +47,14 @@ class IngredientController extends AdminController
|
||||||
return $guard;
|
return $guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->renderIndex($guard);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rend le tableau de bord stock. Factorise pour servir la lecture (index, 200) et le
|
|
||||||
* re-rendu en erreur du reglage rapide de seuils (updateThresholds, 422). $error est
|
|
||||||
* affiche en bandeau si present. Les drapeaux de permission pilotent l'affichage des
|
|
||||||
* actions (la garde reelle reste par-route).
|
|
||||||
*/
|
|
||||||
private function renderIndex(GuardResult $guard, ?string $error = null, int $status = 200): Response
|
|
||||||
{
|
|
||||||
$ingredients = $this->ingredientRepository()->all();
|
|
||||||
|
|
||||||
// Compteurs par bande pour le resume du tableau de bord (3 pastilles).
|
|
||||||
// Calcules cote serveur a partir de stock_band deja resolu par le depot,
|
|
||||||
// pour que la vue reste declarative et la valeur testable directement.
|
|
||||||
$counts = ['critical' => 0, 'low' => 0, 'normal' => 0];
|
|
||||||
foreach ($ingredients as $row) {
|
|
||||||
$band = (string) ($row['stock_band'] ?? 'normal');
|
|
||||||
$counts[$band] = ($counts[$band] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->adminView('admin/ingredients/index', [
|
return $this->adminView('admin/ingredients/index', [
|
||||||
'title' => 'Stock - Wakdo Admin',
|
'title' => 'Stock - Wakdo Admin',
|
||||||
'activeNav' => 'stock',
|
'activeNav' => 'stock',
|
||||||
'ingredients' => $ingredients,
|
'ingredients' => $this->ingredientRepository()->all(),
|
||||||
'bandCounts' => $counts,
|
'canManage' => $this->may($guard, 'ingredient.manage'),
|
||||||
'canManage' => $this->may($guard, 'ingredient.manage'),
|
'canRestock' => $this->may($guard, 'stock.manage'),
|
||||||
'canRestock' => $this->may($guard, 'stock.manage'),
|
'canCount' => $this->may($guard, 'stock.count'),
|
||||||
'canCount' => $this->may($guard, 'stock.count'),
|
], $guard);
|
||||||
'thresholdError' => $error,
|
|
||||||
], $guard, $status);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -315,49 +291,6 @@ class IngredientController extends AdminController
|
||||||
return $this->redirect('/admin/ingredients');
|
return $this->redirect('/admin/ingredients');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reglage rapide des seuils depuis le tableau de bord stock (F13). Endpoint LEGER :
|
|
||||||
* ne reutilise PAS update() (qui exige name/unit/pack), il ne valide et n'ecrit que
|
|
||||||
* capacite + seuils alerte/critique. Permission stock.manage (calibrage du stock, pas
|
|
||||||
* du catalogue), CSRF, SANS PIN (config, pas un comptage d'inventaire RG-T13). Succes
|
|
||||||
* -> redirect liste + flash ; erreur de validation -> 422 re-rendu de la liste avec un
|
|
||||||
* bandeau d'erreur (convention restock/inventory : 422 + message, pas de redirect muet).
|
|
||||||
*
|
|
||||||
* @param array<string, string> $params
|
|
||||||
*/
|
|
||||||
public function updateThresholds(array $params): Response
|
|
||||||
{
|
|
||||||
$guard = $this->guard('stock.manage');
|
|
||||||
if ($guard instanceof Response) {
|
|
||||||
return $guard;
|
|
||||||
}
|
|
||||||
|
|
||||||
$form = $this->request->formBody();
|
|
||||||
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
|
|
||||||
return $this->invalidCsrf();
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = (int) ($params['id'] ?? 0);
|
|
||||||
$ingredient = $this->ingredientRepository()->find($id);
|
|
||||||
if ($ingredient === null) {
|
|
||||||
return $this->notFound($guard);
|
|
||||||
}
|
|
||||||
|
|
||||||
[$data, $errors] = $this->validateThresholds($form);
|
|
||||||
if ($errors !== []) {
|
|
||||||
// Premier message d'erreur en bandeau : la modale est rouverte cote client si
|
|
||||||
// besoin ; l'equipier voit la raison du rejet sans jargon de champ.
|
|
||||||
$messages = array_values($errors);
|
|
||||||
|
|
||||||
return $this->renderIndex($guard, $messages[0], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->ingredientRepository()->updateThresholds($id, $data['stock_capacity'], $data['low_stock_pct'], $data['critical_stock_pct']);
|
|
||||||
$this->setFlash('Seuils mis a jour.');
|
|
||||||
|
|
||||||
return $this->redirect('/admin/ingredients');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $params
|
* @param array<string, string> $params
|
||||||
*/
|
*/
|
||||||
|
|
@ -617,6 +550,12 @@ class IngredientController extends AdminController
|
||||||
$errors['unit'] = 'L unite est requise (40 caracteres max).';
|
$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'] ?? '');
|
$packRaw = trim($form['pack_size'] ?? '');
|
||||||
$packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535;
|
$packValid = ctype_digit($packRaw) && (int) $packRaw >= 1 && (int) $packRaw <= 65535;
|
||||||
if (!$packValid) {
|
if (!$packValid) {
|
||||||
|
|
@ -628,45 +567,6 @@ class IngredientController extends AdminController
|
||||||
$errors['pack_label'] = 'Libelle de pack trop long (80 caracteres max).';
|
$errors['pack_label'] = 'Libelle de pack trop long (80 caracteres max).';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capacite + seuils : meme regle (capacite >= 1, % 0-100, critique < alerte strict)
|
|
||||||
// que le reglage rapide F13 -> source unique validateThresholds(), pas de copie
|
|
||||||
// divergente. Les messages restent indexes par champ pour le formulaire complet.
|
|
||||||
[$thresholds, $thresholdErrors] = $this->validateThresholds($form);
|
|
||||||
$errors = array_merge($errors, $thresholdErrors);
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'name' => $name,
|
|
||||||
'unit' => $unit,
|
|
||||||
'stock_capacity' => $thresholds['stock_capacity'],
|
|
||||||
'pack_size' => $packValid ? (int) $packRaw : 0,
|
|
||||||
'pack_label' => $label !== '' ? $label : null,
|
|
||||||
'low_stock_pct' => $thresholds['low_stock_pct'],
|
|
||||||
'critical_stock_pct' => $thresholds['critical_stock_pct'],
|
|
||||||
];
|
|
||||||
|
|
||||||
return [$data, $errors];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation des trois reglages de calibrage du stock, partagee par le formulaire
|
|
||||||
* complet (validate) et l'endpoint leger F13 (updateThresholds) pour qu'une seule
|
|
||||||
* regle existe : capacite (reference 100 %) >= 1 ; seuils alerte/critique entiers
|
|
||||||
* 0-100 ; critique STRICTEMENT inferieur a alerte (RG-CREATE-ING, garanti aussi par
|
|
||||||
* un CHECK de table). Renvoie [valeurs normalisees, erreurs indexees par champ].
|
|
||||||
*
|
|
||||||
* @param array<string, string> $form
|
|
||||||
* @return array{0: array{stock_capacity: int, low_stock_pct: int, critical_stock_pct: int}, 1: array<string, string>}
|
|
||||||
*/
|
|
||||||
private function validateThresholds(array $form): array
|
|
||||||
{
|
|
||||||
$errors = [];
|
|
||||||
|
|
||||||
$capRaw = trim($form['stock_capacity'] ?? '');
|
|
||||||
$capValid = ctype_digit($capRaw) && (int) $capRaw >= 1 && (int) $capRaw <= 2147483647;
|
|
||||||
if (!$capValid) {
|
|
||||||
$errors['stock_capacity'] = 'La capacite (reference 100%) doit etre un entier >= 1.';
|
|
||||||
}
|
|
||||||
|
|
||||||
$lowRaw = trim($form['low_stock_pct'] ?? '');
|
$lowRaw = trim($form['low_stock_pct'] ?? '');
|
||||||
$lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100;
|
$lowValid = ctype_digit($lowRaw) && (int) $lowRaw <= 100;
|
||||||
if (!$lowValid) {
|
if (!$lowValid) {
|
||||||
|
|
@ -679,12 +579,17 @@ class IngredientController extends AdminController
|
||||||
$errors['critical_stock_pct'] = 'Le seuil critique doit etre un entier entre 0 et 100.';
|
$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) {
|
if ($lowValid && $critValid && (int) $critRaw >= (int) $lowRaw) {
|
||||||
$errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.';
|
$errors['critical_stock_pct'] = 'Le seuil critique doit etre strictement inferieur au seuil d alerte.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
|
'name' => $name,
|
||||||
|
'unit' => $unit,
|
||||||
'stock_capacity' => $capValid ? (int) $capRaw : 0,
|
'stock_capacity' => $capValid ? (int) $capRaw : 0,
|
||||||
|
'pack_size' => $packValid ? (int) $packRaw : 0,
|
||||||
|
'pack_label' => $label !== '' ? $label : null,
|
||||||
'low_stock_pct' => $lowValid ? (int) $lowRaw : 0,
|
'low_stock_pct' => $lowValid ? (int) $lowRaw : 0,
|
||||||
'critical_stock_pct' => $critValid ? (int) $critRaw : 0,
|
'critical_stock_pct' => $critValid ? (int) $critRaw : 0,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,10 @@ class KitchenController extends AdminController
|
||||||
|
|
||||||
$sources = $this->orderQuery()->visibleSources($guard->roleId ?? 0);
|
$sources = $this->orderQuery()->visibleSources($guard->roleId ?? 0);
|
||||||
|
|
||||||
// paidQueueWithDetail : memes commandes que paidQueue, enrichies du detail des
|
|
||||||
// articles (selections + modificateurs) et d'une bande SLA derivee de paid_at,
|
|
||||||
// pour que le KDS soit exploitable pour PREPARER (et pas seulement lister).
|
|
||||||
return $this->adminView('admin/kitchen/display', [
|
return $this->adminView('admin/kitchen/display', [
|
||||||
'title' => 'Cuisine - Wakdo Admin',
|
'title' => 'Cuisine - Wakdo Admin',
|
||||||
'activeNav' => 'kitchen',
|
'activeNav' => 'kitchen',
|
||||||
'orders' => $this->orderQuery()->paidQueueWithDetail($sources),
|
'orders' => $this->orderQuery()->paidQueue($sources),
|
||||||
'canDeliver' => $this->may($guard, 'order.deliver'),
|
'canDeliver' => $this->may($guard, 'order.deliver'),
|
||||||
], $guard);
|
], $guard);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,32 +36,6 @@ class MenuController extends AdminController
|
||||||
{
|
{
|
||||||
private const SLOT_TYPES = ['drink', 'side', 'sauce', 'dessert', 'extra'];
|
private const SLOT_TYPES = ['drink', 'side', 'sauce', 'dessert', 'extra'];
|
||||||
|
|
||||||
/**
|
|
||||||
* F12 : SOURCE UNIQUE du mapping slot_type -> categorie(s) eligibles (slugs FR du
|
|
||||||
* seed 0002). Une option de slot ne peut etre qu'un produit dont la categorie est
|
|
||||||
* dans la liste de son slot_type. Ce meme tableau est (a) passe a la vue puis au
|
|
||||||
* builder JS (filtrage UI dynamique), et (b) reutilise par la garde serveur
|
|
||||||
* parseSlots() (rejet 422 d'une option hors categorie) -- pas de double definition
|
|
||||||
* divergente.
|
|
||||||
*
|
|
||||||
* Regle metier (decidee avec l'utilisateur) :
|
|
||||||
* - drink/sauce/dessert : leur categorie homonyme ;
|
|
||||||
* - side : les accompagnements (frites + encas + salades) ;
|
|
||||||
* - extra : slot LIBRE = toute categorie SAUF 'menus' (pas de menu dans un menu)
|
|
||||||
* et 'burgers' (le burger est l'ancre du menu, champ separe, jamais une option).
|
|
||||||
* 'menus' n'apparait dans aucune liste : un menu ne se compose pas d'un autre menu.
|
|
||||||
* Le slug 'wraps' n'est eligible que via 'extra' (slot libre).
|
|
||||||
*
|
|
||||||
* @var array<string, list<string>>
|
|
||||||
*/
|
|
||||||
private const SLOT_CATEGORIES = [
|
|
||||||
'drink' => ['boissons'],
|
|
||||||
'sauce' => ['sauces'],
|
|
||||||
'dessert' => ['desserts'],
|
|
||||||
'side' => ['frites', 'encas', 'salades'],
|
|
||||||
'extra' => ['boissons', 'frites', 'encas', 'wraps', 'salades', 'desserts', 'sauces'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $params
|
* @param array<string, string> $params
|
||||||
*/
|
*/
|
||||||
|
|
@ -335,13 +309,10 @@ class MenuController extends AdminController
|
||||||
$errors['category_id'] = 'Categorie requise et valide.';
|
$errors['category_id'] = 'Categorie requise et valide.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// F9-2 : le burger principal doit etre un produit de BASE (R4). productIsBase
|
|
||||||
// rejette une variante de taille meme si l'UI (base-only) est contournee :
|
|
||||||
// une variante n'est pas un produit autonome commercialisable en menu.
|
|
||||||
$burgerRaw = trim($form['burger_product_id'] ?? '');
|
$burgerRaw = trim($form['burger_product_id'] ?? '');
|
||||||
$burgerId = ctype_digit($burgerRaw) ? (int) $burgerRaw : 0;
|
$burgerId = ctype_digit($burgerRaw) ? (int) $burgerRaw : 0;
|
||||||
if ($burgerId === 0 || !$this->menuRepository()->productIsBase($burgerId)) {
|
if ($burgerId === 0 || !$this->menuRepository()->productExists($burgerId)) {
|
||||||
$errors['burger_product_id'] = 'Le produit burger de base est requis et doit etre un produit de base (pas une variante de taille).';
|
$errors['burger_product_id'] = 'Le produit burger de base est requis et doit exister.';
|
||||||
}
|
}
|
||||||
|
|
||||||
$name = trim($form['name'] ?? '');
|
$name = trim($form['name'] ?? '');
|
||||||
|
|
@ -414,37 +385,12 @@ class MenuController extends AdminController
|
||||||
$slotType = is_string($raw['slot_type'] ?? null) ? $raw['slot_type'] : '';
|
$slotType = is_string($raw['slot_type'] ?? null) ? $raw['slot_type'] : '';
|
||||||
$required = !empty($raw['is_required']) ? 1 : 0;
|
$required = !empty($raw['is_required']) ? 1 : 0;
|
||||||
|
|
||||||
// Categories autorisees pour ce slot_type (F12, mapping unique). Tableau
|
|
||||||
// vide pour un slot_type inconnu : aucune option n'y est alors eligible,
|
|
||||||
// mais le type invalide est rejete plus bas avant d'utiliser ce resultat.
|
|
||||||
$allowedCategories = self::SLOT_CATEGORIES[$slotType] ?? [];
|
|
||||||
|
|
||||||
// F9-2 : une option de slot doit etre un produit de BASE (R4). Un id de
|
|
||||||
// variante de taille (base_product_id non nul) est REJETE explicitement
|
|
||||||
// (422) plutot que filtre en silence : choisir une variante comme option
|
|
||||||
// serait un contournement de l'UI base-only, et un drop muet ferait perdre
|
|
||||||
// un choix sans message clair. Un id inconnu reste filtre (allowlist).
|
|
||||||
// F12 : par-dessus, l'option doit appartenir a une categorie autorisee pour
|
|
||||||
// le slot_type ; une option hors categorie est REJETEE (422), pas filtree,
|
|
||||||
// pour la meme raison (contournement de l'UI de filtrage = erreur visible).
|
|
||||||
$optionIds = [];
|
$optionIds = [];
|
||||||
$hasVariantOption = false;
|
|
||||||
$hasWrongCategoryOption = false;
|
|
||||||
foreach (is_array($raw['options'] ?? null) ? $raw['options'] : [] as $opt) {
|
foreach (is_array($raw['options'] ?? null) ? $raw['options'] : [] as $opt) {
|
||||||
$pid = is_numeric($opt) ? (int) $opt : 0;
|
$pid = is_numeric($opt) ? (int) $opt : 0;
|
||||||
if ($pid <= 0 || !$this->menuRepository()->productExists($pid)) {
|
if ($pid > 0 && $this->menuRepository()->productExists($pid)) {
|
||||||
continue; // id inconnu : filtre (allowlist), pas une erreur
|
$optionIds[] = $pid;
|
||||||
}
|
}
|
||||||
if (!$this->menuRepository()->productIsBase($pid)) {
|
|
||||||
$hasVariantOption = true;
|
|
||||||
continue; // variante de taille : non eligible comme option de menu
|
|
||||||
}
|
|
||||||
$categorySlug = $this->menuRepository()->productCategorySlug($pid);
|
|
||||||
if ($categorySlug === null || !in_array($categorySlug, $allowedCategories, true)) {
|
|
||||||
$hasWrongCategoryOption = true;
|
|
||||||
continue; // hors categorie pour ce slot_type : non eligible
|
|
||||||
}
|
|
||||||
$optionIds[] = $pid;
|
|
||||||
}
|
}
|
||||||
$optionIds = array_values(array_unique($optionIds));
|
$optionIds = array_values(array_unique($optionIds));
|
||||||
|
|
||||||
|
|
@ -456,14 +402,6 @@ class MenuController extends AdminController
|
||||||
$errors['slots'] = 'Type de slot invalide.';
|
$errors['slots'] = 'Type de slot invalide.';
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($hasVariantOption) {
|
|
||||||
$errors['slots'] = 'Une variante de taille ne peut pas etre proposee comme option de menu (choisissez le produit de base).';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($hasWrongCategoryOption) {
|
|
||||||
$errors['slots'] = 'Une option proposee n\'appartient pas a une categorie compatible avec le type de ce slot.';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($optionIds === []) {
|
if ($optionIds === []) {
|
||||||
$errors['slots'] = 'Chaque slot doit proposer au moins une option valide.';
|
$errors['slots'] = 'Chaque slot doit proposer au moins une option valide.';
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -521,18 +459,8 @@ class MenuController extends AdminController
|
||||||
'activeNav' => 'menus',
|
'activeNav' => 'menus',
|
||||||
'menuId' => $id,
|
'menuId' => $id,
|
||||||
'categories' => $this->categoryRepository()->all(),
|
'categories' => $this->categoryRepository()->all(),
|
||||||
// F9-1 : listes deroulantes base-only (burger principal + options de
|
'products' => $this->productRepository()->all(),
|
||||||
// slot). basesOnly() exclut les variantes de taille (R4) ; all() les
|
|
||||||
// inclut (liste admin), il ne doit donc pas alimenter ces selects. Le
|
|
||||||
// burger principal (select dedie) consomme cette liste {id, name}.
|
|
||||||
'products' => $this->productRepository()->basesOnly(),
|
|
||||||
// F12 : options de slot, base-only ENRICHIES du slug de categorie, pour
|
|
||||||
// que le builder JS filtre les choix proposes selon le type de slot.
|
|
||||||
'slotProducts' => $this->productRepository()->baseOptionsWithCategory(),
|
|
||||||
'slotTypes' => self::SLOT_TYPES,
|
'slotTypes' => self::SLOT_TYPES,
|
||||||
// F12 : mapping slot_type -> categories, source unique partagee avec la
|
|
||||||
// garde serveur (self::SLOT_CATEGORIES) et exposee au builder JS.
|
|
||||||
'slotCategories' => self::SLOT_CATEGORIES,
|
|
||||||
'values' => [
|
'values' => [
|
||||||
'category_id' => (string) ($values['category_id'] ?? ''),
|
'category_id' => (string) ($values['category_id'] ?? ''),
|
||||||
'burger_product_id' => (string) ($values['burger_product_id'] ?? ''),
|
'burger_product_id' => (string) ($values['burger_product_id'] ?? ''),
|
||||||
|
|
|
||||||
|
|
@ -53,15 +53,6 @@ class OrderAdminController extends AdminController
|
||||||
* Remise au client : paid -> delivered (mlt 6.1). POST + CSRF, garde order.deliver.
|
* 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.
|
* Pas de PIN (geste routinier). Issue affichee en flash, retour a la liste.
|
||||||
*
|
*
|
||||||
* PRE-3 / ERR-2 (6.1) : au-dela de la permission, la SOURCE de la commande doit
|
|
||||||
* etre VISIBLE par le role de l'acteur (role_visible_source). Sans ce controle,
|
|
||||||
* tout role detenant order.deliver pourrait remettre une commande de n'importe
|
|
||||||
* quel canal (ex. un equipier drive remettant une commande comptoir). La file
|
|
||||||
* KDS (KitchenController) est deja filtree par visibleSources a l'affichage ;
|
|
||||||
* cette garde rejoue la regle cote ecriture (defense en profondeur, meme posture
|
|
||||||
* que cancel() qui ne se fie pas a l'affichage de la liste). Hors des sources
|
|
||||||
* visibles -> 403 FORBIDDEN (memes statut/vue que la garde de permission).
|
|
||||||
*
|
|
||||||
* @param array<string, string> $params
|
* @param array<string, string> $params
|
||||||
*/
|
*/
|
||||||
public function deliver(array $params = []): Response
|
public function deliver(array $params = []): Response
|
||||||
|
|
@ -76,22 +67,8 @@ class OrderAdminController extends AdminController
|
||||||
return $this->invalidCsrf();
|
return $this->invalidCsrf();
|
||||||
}
|
}
|
||||||
|
|
||||||
$number = (string) ($params['number'] ?? '');
|
|
||||||
|
|
||||||
// PRE-3 (6.1) : refuse la remise d'une commande dont la source n'est pas dans
|
|
||||||
// les sources visibles du role agissant. visibleSources reutilise
|
|
||||||
// role_visible_source (memes donnees que KitchenController) : liste vide en base
|
|
||||||
// = vue globale (admin/manager voient les trois sources). Le numero inconnu
|
|
||||||
// (source null) retombe sur le chemin "non visible" -> 403 ; la branche
|
|
||||||
// ORDER_NOT_FOUND ci-dessous reste atteignable pour une course (commande
|
|
||||||
// supprimee entre la lecture et la transition).
|
|
||||||
$source = $this->orderSource($number);
|
|
||||||
if ($source === null || !in_array($source, $this->orderQuery()->visibleSources($guard->roleId ?? 0), true)) {
|
|
||||||
return $this->forbidden($guard);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->orders()->deliver($number);
|
$this->orders()->deliver((string) ($params['number'] ?? ''));
|
||||||
$this->setFlash('Commande remise (livree).');
|
$this->setFlash('Commande remise (livree).');
|
||||||
} catch (OrderValidationException $exception) {
|
} catch (OrderValidationException $exception) {
|
||||||
$this->setFlash(
|
$this->setFlash(
|
||||||
|
|
@ -201,28 +178,6 @@ class OrderAdminController extends AdminController
|
||||||
return new OrderQueryRepository($this->db());
|
return new OrderQueryRepository($this->db());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Source (canal) d'une commande par son numero, pour la garde de visibilite
|
|
||||||
* PRE-3 (6.1). Lecture ciblee d'une seule colonne : OrderRepository::findByNumber
|
|
||||||
* renvoie une forme typee {id, order_number, total_ttc_cents, status} qui n'expose
|
|
||||||
* pas la source ; plutot que d'elargir ce contrat partage, on lit ici le seul champ
|
|
||||||
* requis (meme couture db() que logFailedPin). Renvoie null si le numero est
|
|
||||||
* inconnu (traite comme non visible par l'appelant).
|
|
||||||
*/
|
|
||||||
protected function orderSource(string $number): ?string
|
|
||||||
{
|
|
||||||
if ($number === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
$row = $this->db()->fetch(
|
|
||||||
'SELECT source FROM customer_order WHERE order_number = :n',
|
|
||||||
['n' => $number],
|
|
||||||
);
|
|
||||||
$source = is_string($row['source'] ?? null) ? (string) $row['source'] : '';
|
|
||||||
|
|
||||||
return $source === '' ? null : $source;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function orders(): OrderRepository
|
protected function orders(): OrderRepository
|
||||||
{
|
{
|
||||||
$db = $this->db();
|
$db = $this->db();
|
||||||
|
|
@ -292,16 +247,6 @@ class OrderAdminController extends AdminController
|
||||||
return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'orders'], $guard, 404);
|
return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'orders'], $guard, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refus de visibilite (ERR-2, 6.1) : la source de la commande n'est pas visible
|
|
||||||
* par le role agissant. Memes statut (403) et vue (admin/forbidden) que la garde
|
|
||||||
* de permission d'AdminController::guard, pour une convention de refus homogene.
|
|
||||||
*/
|
|
||||||
private function forbidden(GuardResult $guard): Response
|
|
||||||
{
|
|
||||||
return $this->adminView('admin/forbidden', ['title' => 'Acces refuse', 'activeNav' => 'orders'], $guard, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function redirect(string $location): Response
|
private function redirect(string $location): Response
|
||||||
{
|
{
|
||||||
return Response::make('', 302, ['Location' => $location]);
|
return Response::make('', 302, ['Location' => $location]);
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,9 @@ namespace App\Controllers;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
use App\Auth\Csrf;
|
use App\Auth\Csrf;
|
||||||
use App\Auth\LogMailer;
|
use App\Auth\LogMailer;
|
||||||
use App\Auth\Mailer;
|
|
||||||
use App\Auth\PasswordHasher;
|
use App\Auth\PasswordHasher;
|
||||||
use App\Auth\PasswordResetService;
|
use App\Auth\PasswordResetService;
|
||||||
use App\Auth\SessionManager;
|
use App\Auth\SessionManager;
|
||||||
use App\Auth\SmtpClient;
|
|
||||||
use App\Auth\SmtpMailer;
|
|
||||||
use App\Auth\StreamSmtpTransport;
|
|
||||||
use App\Core\Controller;
|
use App\Core\Controller;
|
||||||
use App\Core\Response;
|
use App\Core\Response;
|
||||||
|
|
||||||
|
|
@ -128,33 +124,7 @@ class PasswordResetController extends Controller
|
||||||
$this->database,
|
$this->database,
|
||||||
$this->config,
|
$this->config,
|
||||||
new PasswordHasher($this->config),
|
new PasswordHasher($this->config),
|
||||||
$this->mailer(),
|
new LogMailer(),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SMTP reel si configure (SMTP_HOST + SMTP_USER + SMTP_PASSWORD presents),
|
|
||||||
* sinon repli sur LogMailer (le lien est journalise, pas d'envoi) : le dev
|
|
||||||
* reste sans infra mail, la prod envoie via le relais.
|
|
||||||
*/
|
|
||||||
protected function mailer(): Mailer
|
|
||||||
{
|
|
||||||
$host = $this->config->get('SMTP_HOST');
|
|
||||||
$user = $this->config->get('SMTP_USER');
|
|
||||||
$password = $this->config->get('SMTP_PASSWORD');
|
|
||||||
|
|
||||||
if ($host === null || $user === null || $password === null) {
|
|
||||||
return new LogMailer();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SmtpMailer(
|
|
||||||
new SmtpClient(new StreamSmtpTransport()),
|
|
||||||
$host,
|
|
||||||
(int) ($this->config->get('SMTP_PORT', '587') ?? '587'),
|
|
||||||
$user,
|
|
||||||
$password,
|
|
||||||
$this->config->get('MAIL_FROM_EMAIL', 'noreply@localhost') ?? 'noreply@localhost',
|
|
||||||
$this->config->get('MAIL_FROM_NAME', 'Wakdo') ?? 'Wakdo',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,7 @@ class ProductController extends AdminController
|
||||||
return $this->invalidCsrf();
|
return $this->invalidCsrf();
|
||||||
}
|
}
|
||||||
|
|
||||||
// id = 0 a la creation : pas d'auto-reference possible (le produit n'existe
|
[$data, $errors] = $this->validate($form);
|
||||||
// pas encore), validate() le sait par le 2e argument.
|
|
||||||
[$data, $errors] = $this->validate($form, 0);
|
|
||||||
if ($errors !== []) {
|
if ($errors !== []) {
|
||||||
return $this->renderForm($guard, 0, $form, $errors, 422);
|
return $this->renderForm($guard, 0, $form, $errors, 422);
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +130,7 @@ class ProductController extends AdminController
|
||||||
return $this->notFound($guard);
|
return $this->notFound($guard);
|
||||||
}
|
}
|
||||||
|
|
||||||
[$data, $errors] = $this->validate($form, $id);
|
[$data, $errors] = $this->validate($form);
|
||||||
if ($errors !== []) {
|
if ($errors !== []) {
|
||||||
return $this->renderForm($guard, $id, $form, $errors, 422);
|
return $this->renderForm($guard, $id, $form, $errors, 422);
|
||||||
}
|
}
|
||||||
|
|
@ -385,13 +383,11 @@ class ProductController extends AdminController
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validation serveur (RG-T18) + allowlist (RG-T16). Renvoie [donnees, erreurs].
|
* Validation serveur (RG-T18) + allowlist (RG-T16). Renvoie [donnees, erreurs].
|
||||||
* $currentId = id du produit edite (0 a la creation), pour interdire l'auto-
|
|
||||||
* reference des FK de variante (F9-3).
|
|
||||||
*
|
*
|
||||||
* @param array<string, string> $form
|
* @param array<string, string> $form
|
||||||
* @return array{0: array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}, 1: array<string, string>}
|
* @return array{0: array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}, 1: array<string, string>}
|
||||||
*/
|
*/
|
||||||
private function validate(array $form, int $currentId): array
|
private function validate(array $form): array
|
||||||
{
|
{
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
|
|
@ -429,72 +425,15 @@ class ProductController extends AdminController
|
||||||
|
|
||||||
$description = trim($form['description'] ?? '');
|
$description = trim($form['description'] ?? '');
|
||||||
|
|
||||||
// --- Champs de variante (F9-3, R4 / migrations 0006-0007) ---
|
|
||||||
// Tous nullables : un champ vide signifie "produit de base / autonome, sans
|
|
||||||
// dimension taille ni substitution Maxi". Bornes refletant les colonnes :
|
|
||||||
// size_cl SMALLINT UNSIGNED (0..65535), base/maxi FK INT UNSIGNED.
|
|
||||||
|
|
||||||
// size_cl : volume en cl, entier >= 0 si fourni (vide = NULL).
|
|
||||||
$sizeRaw = trim($form['size_cl'] ?? '');
|
|
||||||
$sizeCl = null;
|
|
||||||
if ($sizeRaw !== '') {
|
|
||||||
if (!ctype_digit($sizeRaw) || (int) $sizeRaw > 65535) {
|
|
||||||
$errors['size_cl'] = 'La taille (en cl) doit etre un entier entre 0 et 65535.';
|
|
||||||
} else {
|
|
||||||
$sizeCl = (int) $sizeRaw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// base_product_id : ce produit devient une VARIANTE de taille de la base
|
|
||||||
// designee. La base doit exister, etre differente de soi (pas d'auto-
|
|
||||||
// reference), et etre elle-meme une BASE (productIsBase) : on interdit une
|
|
||||||
// chaine de variantes (une variante ne peut pointer vers une autre variante).
|
|
||||||
$baseRaw = trim($form['base_product_id'] ?? '');
|
|
||||||
$baseId = null;
|
|
||||||
if ($baseRaw !== '') {
|
|
||||||
if (!ctype_digit($baseRaw)) {
|
|
||||||
$errors['base_product_id'] = 'Le produit de base doit etre un produit existant.';
|
|
||||||
} elseif ((int) $baseRaw === $currentId) {
|
|
||||||
$errors['base_product_id'] = 'Un produit ne peut pas etre sa propre base.';
|
|
||||||
} elseif (!$this->productRepository()->productExists((int) $baseRaw)) {
|
|
||||||
$errors['base_product_id'] = 'Le produit de base doit etre un produit existant.';
|
|
||||||
} elseif (!$this->productRepository()->productIsBase((int) $baseRaw)) {
|
|
||||||
$errors['base_product_id'] = 'Le produit de base doit lui-meme etre un produit de base (pas une variante).';
|
|
||||||
} else {
|
|
||||||
$baseId = (int) $baseRaw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// maxi_variant_product_id : la variante Grande servie quand un MENU est
|
|
||||||
// commande en Maxi. Doit exister et etre differente de soi (auto-reference
|
|
||||||
// directe interdite). Pas de contrainte de base ici : la cible Maxi est elle
|
|
||||||
// aussi un produit a part entiere (ex. "Grande Frite"), pas une base de taille.
|
|
||||||
$maxiRaw = trim($form['maxi_variant_product_id'] ?? '');
|
|
||||||
$maxiId = null;
|
|
||||||
if ($maxiRaw !== '') {
|
|
||||||
if (!ctype_digit($maxiRaw)) {
|
|
||||||
$errors['maxi_variant_product_id'] = 'La variante Maxi doit etre un produit existant.';
|
|
||||||
} elseif ((int) $maxiRaw === $currentId) {
|
|
||||||
$errors['maxi_variant_product_id'] = 'Un produit ne peut pas etre sa propre variante Maxi.';
|
|
||||||
} elseif (!$this->productRepository()->productExists((int) $maxiRaw)) {
|
|
||||||
$errors['maxi_variant_product_id'] = 'La variante Maxi doit etre un produit existant.';
|
|
||||||
} else {
|
|
||||||
$maxiId = (int) $maxiRaw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'category_id' => $categoryId,
|
'category_id' => $categoryId,
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'description' => $description !== '' ? $description : null,
|
'description' => $description !== '' ? $description : null,
|
||||||
'price_cents' => $priceValid ? (int) $priceRaw : 0,
|
'price_cents' => $priceValid ? (int) $priceRaw : 0,
|
||||||
'size_cl' => $sizeCl,
|
'vat_rate' => ($vat === 55 || $vat === 100) ? $vat : 100,
|
||||||
'base_product_id' => $baseId,
|
'image_path' => $image !== '' ? $image : null,
|
||||||
'maxi_variant_product_id' => $maxiId,
|
'is_available' => ($form['is_available'] ?? '') !== '' ? 1 : 0,
|
||||||
'vat_rate' => ($vat === 55 || $vat === 100) ? $vat : 100,
|
'display_order' => (ctype_digit($orderRaw) && (int) $orderRaw <= 65535) ? (int) $orderRaw : 0,
|
||||||
'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];
|
return [$data, $errors];
|
||||||
|
|
@ -649,38 +588,23 @@ class ProductController extends AdminController
|
||||||
*/
|
*/
|
||||||
private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response
|
private function renderForm(GuardResult $guard, int $id, array $values, array $errors, int $status = 200): Response
|
||||||
{
|
{
|
||||||
// F9-3 : selects base_product_id (de quelle base ce produit est-il la
|
|
||||||
// variante de taille ?) et maxi_variant_product_id (quelle variante Grande
|
|
||||||
// servir en menu Maxi ?). On ne propose que des produits de BASE
|
|
||||||
// (basesOnly, R4) -- une variante ne peut etre ni une base ni, par
|
|
||||||
// simplicite, une cible Maxi -- et on exclut le produit lui-meme de la liste
|
|
||||||
// (pas d'auto-reference), garde miroir de validate().
|
|
||||||
$baseCandidates = array_values(array_filter(
|
|
||||||
$this->productRepository()->basesOnly(),
|
|
||||||
static fn (array $p): bool => (int) ($p['id'] ?? 0) !== $id,
|
|
||||||
));
|
|
||||||
|
|
||||||
return $this->adminView('admin/products/form', [
|
return $this->adminView('admin/products/form', [
|
||||||
'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' produit - Wakdo Admin',
|
'title' => ($id !== 0 ? 'Modifier' : 'Nouveau') . ' produit - Wakdo Admin',
|
||||||
'activeNav' => 'products',
|
'activeNav' => 'products',
|
||||||
'productId' => $id,
|
'productId' => $id,
|
||||||
'categories' => $this->categoryRepository()->all(),
|
'categories' => $this->categoryRepository()->all(),
|
||||||
'baseCandidates' => $baseCandidates,
|
|
||||||
'values' => [
|
'values' => [
|
||||||
'category_id' => (string) ($values['category_id'] ?? ''),
|
'category_id' => (string) ($values['category_id'] ?? ''),
|
||||||
'name' => (string) ($values['name'] ?? ''),
|
'name' => (string) ($values['name'] ?? ''),
|
||||||
'description' => (string) ($values['description'] ?? ''),
|
'description' => (string) ($values['description'] ?? ''),
|
||||||
'price_cents' => (string) ($values['price_cents'] ?? ''),
|
'price_cents' => (string) ($values['price_cents'] ?? ''),
|
||||||
'size_cl' => (string) ($values['size_cl'] ?? ''),
|
'vat_rate' => (string) ($values['vat_rate'] ?? '100'),
|
||||||
'base_product_id' => (string) ($values['base_product_id'] ?? ''),
|
'image_path' => (string) ($values['image_path'] ?? ''),
|
||||||
'maxi_variant_product_id' => (string) ($values['maxi_variant_product_id'] ?? ''),
|
|
||||||
'vat_rate' => (string) ($values['vat_rate'] ?? '100'),
|
|
||||||
'image_path' => (string) ($values['image_path'] ?? ''),
|
|
||||||
// Defaut coche a la creation (errors vide + values vide) ; sur un
|
// Defaut coche a la creation (errors vide + values vide) ; sur un
|
||||||
// re-rendu POST (erreurs), refleter la presence reelle du champ
|
// re-rendu POST (erreurs), refleter la presence reelle du champ
|
||||||
// (case decochee = absente = non cochee), pas le defaut a 1.
|
// (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),
|
'is_available' => $errors === [] ? ((int) ($values['is_available'] ?? 1) === 1) : array_key_exists('is_available', $values),
|
||||||
'display_order' => (string) ($values['display_order'] ?? '0'),
|
'display_order' => (string) ($values['display_order'] ?? '0'),
|
||||||
],
|
],
|
||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
], $guard, $status);
|
], $guard, $status);
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,6 @@ use App\Core\Response;
|
||||||
* actions sensibles, RG-T13). Accessible a tout utilisateur authentifie ; aucune
|
* actions sensibles, RG-T13). Accessible a tout utilisateur authentifie ; aucune
|
||||||
* permission specifique (on n'agit que sur son propre compte = session userId).
|
* permission specifique (on n'agit que sur son propre compte = session userId).
|
||||||
*
|
*
|
||||||
* Le PIN est un credential sensible : le (re)definir exige le mot de passe COURANT
|
|
||||||
* (re-verification d'identite sur poste a session partagee, meme posture que la
|
|
||||||
* verification PIN d'ADR-0004) ET ecrit une ligne `audit_log` (ADR-0004, RG-T14).
|
|
||||||
* Le SET du PIN n'est PAS throttle : la surface de brute-force est la VERIFICATION
|
|
||||||
* du PIN (couverte par pin_throttle / RG-T22, ADR-0005), pas sa definition par un
|
|
||||||
* utilisateur deja authentifie. L'audit ne porte que l'evenement (set vs change),
|
|
||||||
* jamais le PIN ni un hash.
|
|
||||||
*
|
|
||||||
* Non `final` : les tests sous-classent pour injecter des doubles.
|
* Non `final` : les tests sous-classent pour injecter des doubles.
|
||||||
*/
|
*/
|
||||||
class ProfileController extends AdminController
|
class ProfileController extends AdminController
|
||||||
|
|
@ -74,7 +66,6 @@ class ProfileController extends AdminController
|
||||||
|
|
||||||
$pin = $form['pin'] ?? '';
|
$pin = $form['pin'] ?? '';
|
||||||
$confirm = $form['pin_confirm'] ?? '';
|
$confirm = $form['pin_confirm'] ?? '';
|
||||||
$currentPassword = $form['current_password'] ?? '';
|
|
||||||
$error = null;
|
$error = null;
|
||||||
|
|
||||||
if (!$this->pinVerifier()->meetsLengthPolicy($pin)) {
|
if (!$this->pinVerifier()->meetsLengthPolicy($pin)) {
|
||||||
|
|
@ -87,30 +78,12 @@ class ProfileController extends AdminController
|
||||||
return $this->renderPinForm($guard, $userId, $error, 422);
|
return $this->renderPinForm($guard, $userId, $error, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-verification d'identite : (re)definir un credential sensible exige le mot
|
|
||||||
// de passe courant. Message generique (ne distingue pas mot de passe vide /
|
|
||||||
// faux) ; verify paie le cout argon2id, sans leurre dedie ici car l'utilisateur
|
|
||||||
// est deja authentifie (l'enumeration de comptes ne s'applique pas a sa propre
|
|
||||||
// session). Echec -> 422 (requete bien formee, semantiquement refusee).
|
|
||||||
if (!$this->passwordHasher()->verify($currentPassword, $this->currentPasswordHash($userId))) {
|
|
||||||
return $this->renderPinForm($guard, $userId, 'Mot de passe actuel incorrect.', 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
// `pinIsSet` AVANT l'ecriture : distingue une premiere definition d'un changement
|
|
||||||
// pour le libelle d'audit (aucune valeur sensible n'est tracee).
|
|
||||||
$wasSet = $this->userRepository()->pinIsSet($userId);
|
|
||||||
|
|
||||||
// Gate sur 1 ligne affectee : une cible inexistante (0 ligne) ne doit pas
|
// Gate sur 1 ligne affectee : une cible inexistante (0 ligne) ne doit pas
|
||||||
// produire un faux "PIN enregistre" (defense en profondeur).
|
// produire un faux "PIN enregistre" (defense en profondeur).
|
||||||
if ($this->userRepository()->setPinHash($userId, $this->passwordHasher()->hash($pin)) !== 1) {
|
if ($this->userRepository()->setPinHash($userId, $this->passwordHasher()->hash($pin)) !== 1) {
|
||||||
return $this->renderPinForm($guard, $userId, 'Echec de l enregistrement du PIN.', 500);
|
return $this->renderPinForm($guard, $userId, 'Echec de l enregistrement du PIN.', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trace d'audit (ADR-0004, RG-T14) : l'acteur est l'utilisateur de session
|
|
||||||
// (action self-service, pas de PIN equipier tiers). Le summary ne porte que
|
|
||||||
// l'evenement set/change, jamais le PIN ni un hash.
|
|
||||||
$this->writePinAudit($userId, $guard->roleId ?? 0, $wasSet);
|
|
||||||
|
|
||||||
$this->setFlash('PIN enregistre.');
|
$this->setFlash('PIN enregistre.');
|
||||||
|
|
||||||
return Response::make('', 302, ['Location' => '/admin/profile/pin']);
|
return Response::make('', 302, ['Location' => '/admin/profile/pin']);
|
||||||
|
|
@ -140,45 +113,4 @@ class ProfileController extends AdminController
|
||||||
{
|
{
|
||||||
return new PasswordHasher($this->config);
|
return new PasswordHasher($this->config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hash du mot de passe courant de l'utilisateur de session, pour la
|
|
||||||
* re-verification d'identite. Lecture ciblee d'une colonne (UserRepository
|
|
||||||
* n'expose pas le hash : son allowlist d'ecriture ne le lie jamais) ; un compte
|
|
||||||
* absent/inactif renvoie une chaine vide -> verify echoue (refus generique).
|
|
||||||
* is_active = 1 : un compte desactive ne peut pas (re)definir son PIN.
|
|
||||||
*/
|
|
||||||
protected function currentPasswordHash(int $userId): string
|
|
||||||
{
|
|
||||||
$row = $this->db()->fetch(
|
|
||||||
'SELECT password_hash FROM user WHERE id = :id AND is_active = 1',
|
|
||||||
['id' => $userId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return is_string($row['password_hash'] ?? null) ? (string) $row['password_hash'] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ecrit la trace d'audit du set/change de PIN (ADR-0004, RG-T14). action_code
|
|
||||||
* `pin.set` pour les deux cas (definition ET changement) ; le summary distingue
|
|
||||||
* via $wasSet. entity = l'utilisateur agissant (self-service). Aucune valeur
|
|
||||||
* sensible (PIN, hash) n'est journalisee. Hors transaction : l'ecriture du PIN est
|
|
||||||
* un seul UPDATE deja committe ; l'audit suit immediatement (pas d'effet composite
|
|
||||||
* a rendre atomique, a la difference de l'annulation OrderRepository::cancel).
|
|
||||||
*/
|
|
||||||
protected function writePinAudit(int $userId, int $roleId, bool $wasSet): void
|
|
||||||
{
|
|
||||||
$this->db()->execute(
|
|
||||||
'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) '
|
|
||||||
. 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)',
|
|
||||||
[
|
|
||||||
'uid' => $userId,
|
|
||||||
'rid' => $roleId,
|
|
||||||
'code' => 'pin.set',
|
|
||||||
'etype' => 'user',
|
|
||||||
'eid' => $userId,
|
|
||||||
'summary' => $wasSet ? 'PIN modifie (self-service)' : 'PIN defini (self-service)',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,6 @@ use App\Core\DatabaseInterface;
|
||||||
*/
|
*/
|
||||||
class OrderQueryRepository
|
class OrderQueryRepository
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Seuils de la bande SLA du KDS (RG-4 de 5.1 ; seuil cible ~10 min, Note 6).
|
|
||||||
* Constantes plutot qu'env : un seul reglage, simple a relire et a tester ;
|
|
||||||
* a externaliser en configuration si le besoin de variation par site apparait.
|
|
||||||
*/
|
|
||||||
private const SLA_WARN_SECONDS = 300; // 5 min : passage vert -> ambre.
|
|
||||||
private const SLA_LATE_SECONDS = 600; // 10 min (seuil cible) : ambre -> rouge.
|
|
||||||
|
|
||||||
public function __construct(private readonly DatabaseInterface $db)
|
public function __construct(private readonly DatabaseInterface $db)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -98,173 +90,6 @@ class OrderQueryRepository
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* File de preparation enrichie pour le KDS (LIST_ORDERS_DISPLAY, mlt 5.1) :
|
|
||||||
* memes commandes `paid` que paidQueue (meme filtre de sources, meme tri
|
|
||||||
* paid_at croissant), mais chaque commande porte en plus :
|
|
||||||
* - `items` : ses lignes order_item (label_snapshot, quantity, format),
|
|
||||||
* chacune avec ses `selections` (choix de slot, label_snapshot)
|
|
||||||
* et ses `modifiers` (ingredient + action remove/add) ;
|
|
||||||
* - `sla_band`: la bande SLA derivee de (now - paid_at) -- fresh / warn / late.
|
|
||||||
*
|
|
||||||
* RG-3 (5.1) : l'affichage s'appuie sur les SNAPSHOTS persistes ; aucune
|
|
||||||
* re-jointure sur product/menu n'est faite. RG-4 : la couleur est calculee au
|
|
||||||
* rendu, sans etat stocke (Note 6 du dictionnaire).
|
|
||||||
*
|
|
||||||
* Anti N+1 : 4 requetes au total quel que soit le nombre de commandes (la file
|
|
||||||
* + un fetch groupe pour items / selections / modifiers via IN (...)), plutot
|
|
||||||
* qu'un fetch par commande. L'horloge est injectable ($now) pour des bandes SLA
|
|
||||||
* deterministes en test (meme couture ?int $now que SessionGuard / PinThrottle).
|
|
||||||
*
|
|
||||||
* @param list<string> $sources
|
|
||||||
* @param int|null $now epoch de reference pour la bande SLA ; null => time()
|
|
||||||
* @return list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function paidQueueWithDetail(array $sources, ?int $now = null): array
|
|
||||||
{
|
|
||||||
$now ??= time();
|
|
||||||
|
|
||||||
if ($sources === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$placeholders = [];
|
|
||||||
$params = [];
|
|
||||||
foreach (array_values($sources) as $i => $source) {
|
|
||||||
$key = 's' . $i;
|
|
||||||
$placeholders[] = ':' . $key;
|
|
||||||
$params[$key] = $source;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `id` est selectionne ici (a la difference de paidQueue) : il sert de cle de
|
|
||||||
// jointure pour le fetch groupe des lignes, sans etre expose tel quel a la vue.
|
|
||||||
$orders = $this->db->fetchAll(
|
|
||||||
'SELECT id, order_number, source, service_mode, service_tag, total_ttc_cents, paid_at '
|
|
||||||
. 'FROM customer_order WHERE status = \'paid\' AND source IN (' . implode(', ', $placeholders) . ') '
|
|
||||||
. 'ORDER BY paid_at ASC, id ASC',
|
|
||||||
$params,
|
|
||||||
);
|
|
||||||
if ($orders === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$orderIds = array_map(static fn (array $o): int => (int) $o['id'], $orders);
|
|
||||||
$items = $this->itemsForOrders($orderIds);
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
foreach ($orders as $order) {
|
|
||||||
$order['items'] = $items[(int) $order['id']] ?? [];
|
|
||||||
$order['sla_band'] = $this->slaBand((string) ($order['paid_at'] ?? ''), $now);
|
|
||||||
unset($order['id']); // l'id technique ne sert qu'a la jointure, pas a la vue.
|
|
||||||
$out[] = $order;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bande SLA d'une commande a partir de l'ecart (now - paid_at), Note 6 / RG-4 (5.1).
|
|
||||||
* Bandes : `fresh` si < 5 min, `warn` si 5-10 min, `late` au-dela (seuil cible
|
|
||||||
* 10 min). Un paid_at vide ou non parsable retombe sur `fresh` (pas d'alerte sur
|
|
||||||
* une donnee absente). Calcul pur (pas d'I/O) : la vue ne fait que mapper la bande
|
|
||||||
* vers une classe CSS ; cf. kitchen/display.php.
|
|
||||||
*/
|
|
||||||
public function slaBand(string $paidAt, ?int $now = null): string
|
|
||||||
{
|
|
||||||
$now ??= time();
|
|
||||||
$paid = $paidAt !== '' ? strtotime($paidAt) : false;
|
|
||||||
if ($paid === false) {
|
|
||||||
return 'fresh';
|
|
||||||
}
|
|
||||||
|
|
||||||
$elapsed = $now - $paid;
|
|
||||||
if ($elapsed >= self::SLA_LATE_SECONDS) {
|
|
||||||
return 'late';
|
|
||||||
}
|
|
||||||
if ($elapsed >= self::SLA_WARN_SECONDS) {
|
|
||||||
return 'warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'fresh';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Charge en lot les lignes des commandes donnees (order_item + selections +
|
|
||||||
* modifiers), regroupees par order_id puis structurees par ligne. Trois requetes
|
|
||||||
* groupees (IN (...)) au lieu d'un fetch par commande : borne le cout a O(1)
|
|
||||||
* aller-retours quel que soit le volume de la file. Les ids viennent de la liste
|
|
||||||
* interne (entiers surs), interpoles comme entiers : LIMIT/IN ne lient pas avec
|
|
||||||
* ATTR_EMULATE_PREPARES=false, et un cast (int) ferme l'injection.
|
|
||||||
*
|
|
||||||
* @param list<int> $orderIds
|
|
||||||
* @return array<int, list<array<string, mixed>>> items par order_id
|
|
||||||
*/
|
|
||||||
private function itemsForOrders(array $orderIds): array
|
|
||||||
{
|
|
||||||
$ids = array_values(array_unique(array_map('intval', $orderIds)));
|
|
||||||
if ($ids === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
$inOrders = implode(', ', $ids);
|
|
||||||
|
|
||||||
$itemRows = $this->db->fetchAll(
|
|
||||||
'SELECT id, order_id, item_type, format, label_snapshot, quantity '
|
|
||||||
. 'FROM order_item WHERE order_id IN (' . $inOrders . ') ORDER BY id ASC',
|
|
||||||
);
|
|
||||||
if ($itemRows === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$itemIds = array_map(static fn (array $r): int => (int) $r['id'], $itemRows);
|
|
||||||
$inItems = implode(', ', array_values(array_unique($itemIds)));
|
|
||||||
|
|
||||||
$selectionsByItem = $this->groupByItem(
|
|
||||||
$this->db->fetchAll(
|
|
||||||
'SELECT order_item_id, label_snapshot FROM order_item_selection '
|
|
||||||
. 'WHERE order_item_id IN (' . $inItems . ') ORDER BY id ASC',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// order_item_modifier ne stocke PAS de libelle (uniquement ingredient_id) :
|
|
||||||
// a la difference des selections (label_snapshot present), le nom lisible vient
|
|
||||||
// d'une jointure sur `ingredient`. Seule re-jointure necessaire (RG-3 ne
|
|
||||||
// l'exclut que pour product/menu). Le nom d'ingredient est relativement stable ;
|
|
||||||
// a defaut de snapshot c'est la source disponible.
|
|
||||||
$modifiersByItem = $this->groupByItem(
|
|
||||||
$this->db->fetchAll(
|
|
||||||
'SELECT oim.order_item_id, oim.action, i.name AS ingredient_name '
|
|
||||||
. 'FROM order_item_modifier oim JOIN ingredient i ON i.id = oim.ingredient_id '
|
|
||||||
. 'WHERE oim.order_item_id IN (' . $inItems . ') ORDER BY oim.id ASC',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$itemsByOrder = [];
|
|
||||||
foreach ($itemRows as $row) {
|
|
||||||
$itemId = (int) $row['id'];
|
|
||||||
$row['selections'] = $selectionsByItem[$itemId] ?? [];
|
|
||||||
$row['modifiers'] = $modifiersByItem[$itemId] ?? [];
|
|
||||||
$itemsByOrder[(int) $row['order_id']][] = $row;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $itemsByOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regroupe des lignes filles par leur order_item_id (cle de jointure commune
|
|
||||||
* aux selections et aux modificateurs).
|
|
||||||
*
|
|
||||||
* @param list<array<string, mixed>> $rows
|
|
||||||
* @return array<int, list<array<string, mixed>>>
|
|
||||||
*/
|
|
||||||
private function groupByItem(array $rows): array
|
|
||||||
{
|
|
||||||
$grouped = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$grouped[(int) ($row['order_item_id'] ?? 0)][] = $row;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $grouped;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KPIs de vente : CA encaisse (statuts paid + delivered), nombre de commandes
|
* KPIs de vente : CA encaisse (statuts paid + delivered), nombre de commandes
|
||||||
* encaissees, panier moyen, CA et nombre du JOUR, total de commandes, et la
|
* encaissees, panier moyen, CA et nombre du JOUR, total de commandes, et la
|
||||||
|
|
|
||||||
|
|
@ -184,14 +184,8 @@ class OrderRepository
|
||||||
throw new OrderValidationException('EMPTY_ORDER');
|
throw new OrderValidationException('EMPTY_ORDER');
|
||||||
}
|
}
|
||||||
|
|
||||||
// RG-T21 : garde a la creation de commande. Un produit (ou le burger d'un menu)
|
|
||||||
// en rupture calculee par le stock est REFUSE, quel que soit le canal et meme par
|
|
||||||
// acces direct (la borne ne fait que griser l'affichage, F2). C'est la couche qui
|
|
||||||
// fait foi. Set calcule UNE seule fois (pas de N+1 sur les lignes).
|
|
||||||
$unavailable = array_fill_keys($this->products->autoUnavailableIds(), true);
|
|
||||||
|
|
||||||
// Resolution + calcul (lecture seule) AVANT la transaction d'ecriture.
|
// Resolution + calcul (lecture seule) AVANT la transaction d'ecriture.
|
||||||
$lines = array_map(fn (array $item): array => $this->resolveLine($item, $unavailable), $items);
|
$lines = array_map(fn (array $item): array => $this->resolveLine($item), $items);
|
||||||
|
|
||||||
$totalTtc = 0;
|
$totalTtc = 0;
|
||||||
$totalHt = 0;
|
$totalHt = 0;
|
||||||
|
|
@ -603,10 +597,9 @@ class OrderRepository
|
||||||
* Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix.
|
* Resout une ligne (produit ou menu) : lit le catalogue, valide, calcule le prix.
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $item
|
* @param array<string, mixed> $item
|
||||||
* @param array<int, bool> $unavailable set des product_id en rupture calculee (RG-T21)
|
|
||||||
* @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list<array{menu_slot_id:int,product_id:int,label:string}>, modifiers:list<array{ingredient_id:int,action:string,extra_price_cents:int}>}
|
* @return array{item_type:string, product_id:?int, menu_id:?int, format:string, label:string, unit_ttc:int, unit_ht:int, vat_rate:int, quantity:int, selections:list<array{menu_slot_id:int,product_id:int,label:string}>, modifiers:list<array{ingredient_id:int,action:string,extra_price_cents:int}>}
|
||||||
*/
|
*/
|
||||||
private function resolveLine(array $item, array $unavailable = []): array
|
private function resolveLine(array $item): array
|
||||||
{
|
{
|
||||||
$type = (string) ($item['type'] ?? '');
|
$type = (string) ($item['type'] ?? '');
|
||||||
$quantity = max(1, (int) ($item['quantity'] ?? 1));
|
$quantity = max(1, (int) ($item['quantity'] ?? 1));
|
||||||
|
|
@ -617,10 +610,6 @@ class OrderRepository
|
||||||
if ($product === null || (int) ($product['is_available'] ?? 0) !== 1) {
|
if ($product === null || (int) ($product['is_available'] ?? 0) !== 1) {
|
||||||
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
|
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
|
||||||
}
|
}
|
||||||
// RG-T21 : rupture calculee (ingredient requis sous la bande critique).
|
|
||||||
if (isset($unavailable[(int) $product['id']])) {
|
|
||||||
throw new OrderValidationException('PRODUCT_UNAVAILABLE');
|
|
||||||
}
|
|
||||||
$unitBase = (int) $product['price_cents'];
|
$unitBase = (int) $product['price_cents'];
|
||||||
$vat = (int) $product['vat_rate'];
|
$vat = (int) $product['vat_rate'];
|
||||||
$modifiers = $this->resolveModifiers($item, (int) $product['id']);
|
$modifiers = $this->resolveModifiers($item, (int) $product['id']);
|
||||||
|
|
@ -634,14 +623,6 @@ class OrderRepository
|
||||||
if ($menu === null || (int) ($menu['is_available'] ?? 0) !== 1) {
|
if ($menu === null || (int) ($menu['is_available'] ?? 0) !== 1) {
|
||||||
throw new OrderValidationException('MENU_UNAVAILABLE');
|
throw new OrderValidationException('MENU_UNAVAILABLE');
|
||||||
}
|
}
|
||||||
// RG-T21, granularite "burger impose seul" (coherente avec l'affichage borne
|
|
||||||
// F2) : si le burger principal est en rupture calculee, le menu n'est pas
|
|
||||||
// commandable. La rupture d'un accompagnement/boisson requis n'est PAS
|
|
||||||
// verifiee ici (decision produit : granularite burger seul, a elargir au
|
|
||||||
// besoin via les produits des slots requis).
|
|
||||||
if (isset($unavailable[(int) ($menu['burger_product_id'] ?? 0)])) {
|
|
||||||
throw new OrderValidationException('MENU_UNAVAILABLE');
|
|
||||||
}
|
|
||||||
$burger = $this->products->find((int) $menu['burger_product_id']);
|
$burger = $this->products->find((int) $menu['burger_product_id']);
|
||||||
$vat = $burger !== null ? (int) $burger['vat_rate'] : 100;
|
$vat = $burger !== null ? (int) $burger['vat_rate'] : 100;
|
||||||
$unitBase = $format === 'maxi' ? (int) $menu['price_maxi_cents'] : (int) $menu['price_normal_cents'];
|
$unitBase = $format === 'maxi' ? (int) $menu['price_maxi_cents'] : (int) $menu['price_normal_cents'];
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,11 @@ declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste des commandes du canal (comptoir ou drive), injectee dans admin/layout.php.
|
* Liste des commandes du canal (comptoir ou drive), injectee dans admin/layout.php.
|
||||||
* Deux sections : "En cours" (commandes payees non livrees du canal, la plus ancienne
|
* Lecture seule : numero, mode, statut, total, date + bouton "Nouvelle commande".
|
||||||
* d'abord, RG-T12) EN HAUT pour le service, puis l'historique recent (tous statuts)
|
* Partagee par les deux canaux ; le titre, le lien de creation et la source viennent
|
||||||
* en dessous. Lecture seule : numero, mode, statut, total, date + bouton "Nouvelle
|
* du controleur (CounterOrderController::channelView). Toute valeur est echappee (RG-T15).
|
||||||
* commande". Partagee par les deux canaux ; le titre, le lien de creation et la source
|
|
||||||
* viennent du controleur (CounterOrderController::channelView). Echappement RG-T15.
|
|
||||||
* Aucun rafraichissement auto (polling hors scope) : la page se relit a la navigation.
|
|
||||||
*
|
*
|
||||||
* @var list<array<string, mixed>> $orders historique recent (tous statuts)
|
* @var list<array<string, mixed>> $orders
|
||||||
* @var list<array<string, mixed>> $inProgress file "En cours" (paid non livre, canal)
|
|
||||||
* @var string $channelTitle
|
* @var string $channelTitle
|
||||||
* @var string $newPath
|
* @var string $newPath
|
||||||
*/
|
*/
|
||||||
|
|
@ -43,8 +39,6 @@ $statusPill = static fn (string $s): string => match ($s) {
|
||||||
|
|
||||||
/** @var list<array<string, mixed>> $rows */
|
/** @var list<array<string, mixed>> $rows */
|
||||||
$rows = isset($orders) && is_array($orders) ? $orders : [];
|
$rows = isset($orders) && is_array($orders) ? $orders : [];
|
||||||
/** @var list<array<string, mixed>> $queue */
|
|
||||||
$queue = isset($inProgress) && is_array($inProgress) ? $inProgress : [];
|
|
||||||
$heading = isset($channelTitle) && is_string($channelTitle) ? $channelTitle : 'Commandes';
|
$heading = isset($channelTitle) && is_string($channelTitle) ? $channelTitle : 'Commandes';
|
||||||
$createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orders/new';
|
$createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orders/new';
|
||||||
?>
|
?>
|
||||||
|
|
@ -54,45 +48,8 @@ $createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orde
|
||||||
<h1 id="counter-heading" class="admin-section__title"><?= $esc($heading) ?></h1>
|
<h1 id="counter-heading" class="admin-section__title"><?= $esc($heading) ?></h1>
|
||||||
<a class="btn btn-primary" href="<?= $esc($createPath) ?>">Nouvelle commande</a>
|
<a class="btn btn-primary" href="<?= $esc($createPath) ?>">Nouvelle commande</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="admin-section__subtitle">En cours</h2>
|
|
||||||
<p class="admin-section__sub"><?= count($queue) ?> commande(s) a servir</p>
|
|
||||||
<?php if ($queue === []): ?>
|
|
||||||
<p class="admin-empty">Aucune commande en cours.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<table class="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Numero</th>
|
|
||||||
<th>Mode</th>
|
|
||||||
<th>Table</th>
|
|
||||||
<th>Total</th>
|
|
||||||
<th>Payee a</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($queue as $o): ?>
|
|
||||||
<?php
|
|
||||||
// Numero de table : pertinent seulement en sur place (service_tag est
|
|
||||||
// NULL hors dine_in cote serveur). On affiche un tiret sinon, pour que
|
|
||||||
// l'equipier distingue "pas de table" d'une donnee manquante.
|
|
||||||
$queueMode = (string) ($o['service_mode'] ?? '');
|
|
||||||
$queueTag = $queueMode === 'dine_in' ? (string) ($o['service_tag'] ?? '') : '';
|
|
||||||
?>
|
|
||||||
<tr>
|
|
||||||
<td><strong><?= $esc($o['order_number'] ?? '') ?></strong></td>
|
|
||||||
<td><?= $esc($modeLabel($queueMode)) ?></td>
|
|
||||||
<td><?= $queueTag !== '' ? $esc($queueTag) : '-' ?></td>
|
|
||||||
<td><?= $esc($euros($o['total_ttc_cents'] ?? 0)) ?></td>
|
|
||||||
<td><?= $esc($o['paid_at'] ?? '') ?></td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<h2 class="admin-section__subtitle">Historique recent</h2>
|
|
||||||
<p class="admin-section__sub"><?= count($rows) ?> commande(s) recente(s)</p>
|
<p class="admin-section__sub"><?= count($rows) ?> commande(s) recente(s)</p>
|
||||||
|
|
||||||
<?php if ($rows === []): ?>
|
<?php if ($rows === []): ?>
|
||||||
<p class="admin-empty">Aucune commande pour ce canal.</p>
|
<p class="admin-empty">Aucune commande pour ce canal.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,25 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POS tactile a tuiles (comptoir / drive), injecte dans admin/layout.php. Refonte de
|
* Composeur de commande comptoir/drive COMPLET (sous-lot 3c), injecte dans
|
||||||
* la saisie : a la place du formulaire-liste, un ecran de caisse facon borne client
|
* admin/layout.php. Produits commandables ET menus composes (slots
|
||||||
* (onglets categories en haut, grille de tuiles produits/menus a gauche, panneau
|
* accompagnement/boisson/sauce + format Normal/Maxi + modificateurs d'ingredients).
|
||||||
* commande persistant a droite). Pensee pour la tablette : grandes cibles tactiles,
|
|
||||||
* un tap sur une tuile ajoute le produit a la commande (qty 1), un produit a
|
|
||||||
* modificateurs ou un menu ouvre la modale de composition.
|
|
||||||
*
|
*
|
||||||
* Le panier est construit cote client par counter-order.js (CSP 'self', vanilla JS,
|
* Le panier est construit cote client par counter-order.js (CSP 'self', vanilla JS,
|
||||||
* zero handler inline) : il lit produits et menus depuis un script JSON inerte
|
* zero handler inline) : il lit produits et menus depuis les data-* de
|
||||||
* (type="application/json"), construit les onglets, rend la grille, gere le panneau
|
* #counter-order-form (dont la composition PROPOSABLE de chaque produit et du burger
|
||||||
* commande, et serialise les items en JSON dans le champ cache #items_json a la
|
* de chaque menu : ingredients retirables / ajoutables + surcout), et serialise les
|
||||||
* soumission. Le serveur revalide tout (RG-T18, resolveModifiers) et recalcule les
|
* items en JSON dans le champ cache #items_json a la soumission. Le serveur revalide
|
||||||
* prix (RG-T16) : les prix affiches cote client (par ligne + total + libelle du
|
* tout (RG-T18, resolveModifiers) et recalcule les prix (RG-T16). Le tableau de
|
||||||
* bouton) sont INDICATIFS, le serveur reste seul juge. Le contrat de soumission est
|
* quantites produit `qty_<id>` reste present comme repli sans JS (3a).
|
||||||
* inchange (items_json + service_mode + service_tag + _csrf). Sans JS, la grille ne
|
|
||||||
* s'affiche pas : un message invite a activer JS (le POS est interactif par nature).
|
|
||||||
*
|
*
|
||||||
* Partage par les deux canaux ; la source/landing viennent du controleur. Au canal
|
* Partage par les deux canaux ; la source/landing viennent du controleur. Au canal
|
||||||
* drive, service_mode est FIGE a 'drive' (affichage non editable + input cache,
|
* drive, service_mode est verrouille a 'drive' (RG-T09). Echappement RG-T15.
|
||||||
* RG-T09 : un select readonly reste editable, on ne s'y fie pas). Echappement RG-T15.
|
|
||||||
*
|
*
|
||||||
* @var list<array<string, mixed>> $products
|
* @var list<array<string, mixed>> $products
|
||||||
* @var list<array<string, mixed>> $menus menus + slots (option_product_ids)
|
* @var list<array<string, mixed>> $menus menus + slots (option_product_ids)
|
||||||
* @var string $source 'counter' | 'drive'
|
* @var string $source 'counter' | 'drive'
|
||||||
* @var string $serviceMode valeur preselectionnee / reaffichee
|
* @var string $serviceMode valeur preselectionnee / reaffichee
|
||||||
* @var string $serviceTag numero de table reaffiche (re-rendu d'erreur)
|
|
||||||
* @var string $landing retour a la liste du canal
|
* @var string $landing retour a la liste du canal
|
||||||
* @var string|null $error
|
* @var string|null $error
|
||||||
* @var string $csrfToken
|
* @var string $csrfToken
|
||||||
|
|
@ -37,12 +30,19 @@ declare(strict_types=1);
|
||||||
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
$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';
|
$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 ?? '');
|
$csrf = $esc($csrfToken ?? '');
|
||||||
$chan = isset($source) && $source === 'drive' ? 'drive' : 'counter';
|
$chan = isset($source) && $source === 'drive' ? 'drive' : 'counter';
|
||||||
$action = $chan === 'drive' ? '/drive/orders' : '/counter/orders';
|
$action = $chan === 'drive' ? '/drive/orders' : '/counter/orders';
|
||||||
$backTo = isset($landing) && is_string($landing) ? $landing : '/counter/orders';
|
$backTo = isset($landing) && is_string($landing) ? $landing : '/counter/orders';
|
||||||
$mode = isset($serviceMode) && is_string($serviceMode) ? $serviceMode : ($chan === 'drive' ? 'drive' : 'dine_in');
|
$mode = isset($serviceMode) && is_string($serviceMode) ? $serviceMode : ($chan === 'drive' ? 'drive' : 'dine_in');
|
||||||
$tag = isset($serviceTag) && is_string($serviceTag) ? $serviceTag : '';
|
|
||||||
$errorMessage = isset($error) && is_string($error) ? $error : null;
|
$errorMessage = isset($error) && is_string($error) ? $error : null;
|
||||||
|
|
||||||
/** @var list<array<string, mixed>> $productRows */
|
/** @var list<array<string, mixed>> $productRows */
|
||||||
|
|
@ -50,11 +50,10 @@ $productRows = isset($products) && is_array($products) ? $products : [];
|
||||||
/** @var list<array<string, mixed>> $menuRows */
|
/** @var list<array<string, mixed>> $menuRows */
|
||||||
$menuRows = isset($menus) && is_array($menus) ? $menus : [];
|
$menuRows = isset($menus) && is_array($menus) ? $menus : [];
|
||||||
|
|
||||||
// Projection compacte pour le JS : seules les cles utiles a la composition, l'affichage
|
// Projection compacte pour le JS : seules les cles utiles a la composition. Les
|
||||||
// (tuiles : nom, prix, image, categorie) et le calcul local. Les prix sont passes pour
|
// prix sont passes pour l'affichage local (le serveur reste seul juge, RG-T16).
|
||||||
// l'affichage local (le serveur reste seul juge, RG-T16). modifiers : ingredients
|
// modifiers : ingredients retirables / ajoutables proposables (le client les affiche
|
||||||
// retirables / ajoutables proposables (cases a cocher cote client ; resolveModifiers
|
// en cases a cocher ; resolveModifiers revalide chacun cote serveur).
|
||||||
// revalide chacun cote serveur).
|
|
||||||
$jsModifiers = static fn (mixed $rows): array => array_map(
|
$jsModifiers = static fn (mixed $rows): array => array_map(
|
||||||
static fn (array $r): array => [
|
static fn (array $r): array => [
|
||||||
'ingredient_id' => (int) ($r['ingredient_id'] ?? 0),
|
'ingredient_id' => (int) ($r['ingredient_id'] ?? 0),
|
||||||
|
|
@ -65,31 +64,17 @@ $jsModifiers = static fn (mixed $rows): array => array_map(
|
||||||
],
|
],
|
||||||
is_array($rows) ? $rows : [],
|
is_array($rows) ? $rows : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Nom de categorie d'une ligne : category_name si fourni, sinon repli "Autres" pour ne
|
|
||||||
// pas creer d'onglet a libelle vide.
|
|
||||||
$catNameOf = static fn (array $r): string => isset($r['category_name'])
|
|
||||||
&& is_string($r['category_name']) && $r['category_name'] !== ''
|
|
||||||
? $r['category_name']
|
|
||||||
: 'Autres';
|
|
||||||
|
|
||||||
$jsProducts = array_map(
|
$jsProducts = array_map(
|
||||||
static fn (array $p): array => [
|
static fn (array $p): array => [
|
||||||
'id' => (int) ($p['id'] ?? 0),
|
'id' => (int) ($p['id'] ?? 0),
|
||||||
'name' => (string) ($p['name'] ?? ''),
|
'name' => (string) ($p['name'] ?? ''),
|
||||||
'price' => (int) ($p['price_cents'] ?? 0),
|
'price' => (int) ($p['price_cents'] ?? 0),
|
||||||
'image' => (string) ($p['image_path'] ?? ''),
|
'modifiers' => $jsModifiers($p['modifiers'] ?? null),
|
||||||
'category_id' => (int) ($p['category_id'] ?? 0),
|
|
||||||
'category_name' => $catNameOf($p),
|
|
||||||
'modifiers' => $jsModifiers($p['modifiers'] ?? null),
|
|
||||||
// RG-T21 : false = rupture de stock calculee. La tuile reste visible (parite
|
|
||||||
// borne) mais grisee et non tappable cote JS. Absent => commandable par defaut.
|
|
||||||
'commandable' => ($p['is_orderable'] ?? true) !== false,
|
|
||||||
],
|
],
|
||||||
$productRows,
|
$productRows,
|
||||||
);
|
);
|
||||||
$jsMenus = array_map(
|
$jsMenus = array_map(
|
||||||
static function (array $m) use ($jsModifiers, $catNameOf): array {
|
static function (array $m) use ($jsModifiers): array {
|
||||||
/** @var list<array<string, mixed>> $slots */
|
/** @var list<array<string, mixed>> $slots */
|
||||||
$slots = isset($m['slots']) && is_array($m['slots']) ? $m['slots'] : [];
|
$slots = isset($m['slots']) && is_array($m['slots']) ? $m['slots'] : [];
|
||||||
|
|
||||||
|
|
@ -98,12 +83,6 @@ $jsMenus = array_map(
|
||||||
'name' => (string) ($m['name'] ?? ''),
|
'name' => (string) ($m['name'] ?? ''),
|
||||||
'price_normal' => (int) ($m['price_normal_cents'] ?? 0),
|
'price_normal' => (int) ($m['price_normal_cents'] ?? 0),
|
||||||
'price_maxi' => (int) ($m['price_maxi_cents'] ?? 0),
|
'price_maxi' => (int) ($m['price_maxi_cents'] ?? 0),
|
||||||
'image' => (string) ($m['image_path'] ?? ''),
|
|
||||||
'category_id' => (int) ($m['category_id'] ?? 0),
|
|
||||||
'category_name' => $catNameOf($m),
|
|
||||||
// RG-T21 (granularite burger impose seul) : false = burger en rupture
|
|
||||||
// calculee. La tuile menu reste visible mais grisee et non tappable.
|
|
||||||
'commandable' => ($m['is_orderable'] ?? true) !== false,
|
|
||||||
// Modificateurs du burger support : la selection d'un menu cible le burger
|
// Modificateurs du burger support : la selection d'un menu cible le burger
|
||||||
// (resolveModifiers cote serveur le resout sur burger_product_id).
|
// (resolveModifiers cote serveur le resout sur burger_product_id).
|
||||||
'burger_modifiers' => $jsModifiers($m['burger_modifiers'] ?? null),
|
'burger_modifiers' => $jsModifiers($m['burger_modifiers'] ?? null),
|
||||||
|
|
@ -123,95 +102,109 @@ $jsMenus = array_map(
|
||||||
$menuRows,
|
$menuRows,
|
||||||
);
|
);
|
||||||
|
|
||||||
// JSON inerte (type="application/json") plutot que data-* : la charge (compo de chaque
|
// RG-T09 : au drive, le seul mode possible est 'drive'. Le comptoir choisit librement.
|
||||||
// produit + slots de chaque menu) peut etre volumineuse ; un script JSON reste CSP-safe
|
$modeOptions = $chan === 'drive'
|
||||||
// (non execute) et plus lisible qu'un long attribut data-*. JSON_HEX_* echappe < > & '
|
? ['drive' => 'Drive']
|
||||||
// pour que la sortie soit sure a l'interieur d'un <script> (anti-XSS, RG-T15).
|
: ['dine_in' => 'Sur place', 'takeaway' => 'A emporter'];
|
||||||
$jsonFlags = JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
|
|
||||||
?>
|
?>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">Nouvelle commande <?= $chan === 'drive' ? 'drive' : 'comptoir' ?></h1>
|
<h1 class="page-title">Nouvelle commande <?= $chan === 'drive' ? 'drive' : 'comptoir' ?></h1>
|
||||||
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($errorMessage !== null): ?>
|
<?php if ($errorMessage !== null): ?>
|
||||||
<p class="form-error" role="alert"><?= $esc($errorMessage) ?></p>
|
<p class="form-error" role="alert"><?= $esc($errorMessage) ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post" action="<?= $esc($action) ?>" class="pos" id="counter-order-form">
|
<form method="post" action="<?= $esc($action) ?>" class="form-card" id="counter-order-form"
|
||||||
|
data-products="<?= $attr($jsProducts) ?>"
|
||||||
|
data-menus="<?= $attr($jsMenus) ?>">
|
||||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||||
<input type="hidden" name="items_json" id="items_json" value="">
|
<input type="hidden" name="items_json" id="items_json" value="">
|
||||||
|
|
||||||
<?php /* Donnees du catalogue pour counter-order.js : script JSON inerte (CSP-safe). */ ?>
|
<div class="form-group">
|
||||||
<script type="application/json" id="pos-products"><?= (string) json_encode($jsProducts, $jsonFlags) ?></script>
|
<label class="form-label" for="service_mode">Mode de service</label>
|
||||||
<script type="application/json" id="pos-menus"><?= (string) json_encode($jsMenus, $jsonFlags) ?></script>
|
<select class="form-input" id="service_mode" name="service_mode"<?= $chan === 'drive' ? ' readonly' : '' ?>>
|
||||||
|
<?php foreach ($modeOptions as $value => $label): ?>
|
||||||
|
<option value="<?= $esc($value) ?>"<?= $mode === $value ? ' selected' : '' ?>><?= $esc($label) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pos__main">
|
<fieldset class="form-group">
|
||||||
<div class="pos__catalogue">
|
<legend>Produits</legend>
|
||||||
<?php /* Barre d'onglets categories (construite par le JS depuis le catalogue). */ ?>
|
<?php if ($productRows === []): ?>
|
||||||
<div class="pos__tabs" id="pos-tabs" role="tablist" aria-label="Categories"></div>
|
<p class="admin-empty">Aucun produit commandable pour le moment.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Produit</th>
|
||||||
|
<th>Prix</th>
|
||||||
|
<th>Quantite</th>
|
||||||
|
<th>Personnaliser</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($productRows as $p): ?>
|
||||||
|
<?php
|
||||||
|
$pid = (int) ($p['id'] ?? 0);
|
||||||
|
// Un produit ne porte un bouton "Personnaliser" que si sa recette
|
||||||
|
// offre au moins un ingredient retirable/ajoutable (data-* modifiers).
|
||||||
|
$hasModifiers = isset($p['modifiers']) && is_array($p['modifiers']) && $p['modifiers'] !== [];
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><?= $esc($p['name'] ?? '') ?></td>
|
||||||
|
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td>
|
||||||
|
<td>
|
||||||
|
<input class="form-input order-qty" type="number" min="0" value="0"
|
||||||
|
id="qty_<?= $pid ?>" name="qty_<?= $pid ?>"
|
||||||
|
data-product-id="<?= $pid ?>"
|
||||||
|
aria-label="Quantite <?= $esc($p['name'] ?? '') ?>">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if ($hasModifiers): ?>
|
||||||
|
<button class="btn btn-secondary product-configure" type="button" data-product-id="<?= $pid ?>">
|
||||||
|
Personnaliser
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<?php if ($productRows === [] && $menuRows === []): ?>
|
<fieldset class="form-group">
|
||||||
<p class="admin-empty">Aucun produit ni menu commandable pour le moment.</p>
|
<legend>Menus</legend>
|
||||||
<?php else: ?>
|
<?php if ($menuRows === []): ?>
|
||||||
<?php /* Grille de tuiles (remplie par le JS) + repli sans JS. role=tabpanel
|
<p class="admin-empty">Aucun menu commandable pour le moment.</p>
|
||||||
relie au tablist (aria-labelledby pose par le JS vers l'onglet
|
<?php else: ?>
|
||||||
actif). Pas d'aria-live ici : la grille est rebatie a chaque
|
<ul class="menu-list" id="menu-list">
|
||||||
changement de categorie, une re-annonce complete serait verbeuse. */ ?>
|
<?php foreach ($menuRows as $m): ?>
|
||||||
<div class="pos__grid" id="pos-grid" role="tabpanel" tabindex="0">
|
<?php $mid = (int) ($m['id'] ?? 0); ?>
|
||||||
<p class="pos__nojs">Activez JavaScript pour saisir une commande sur cet ecran de caisse.</p>
|
<li class="menu-list__item">
|
||||||
</div>
|
<span class="menu-list__name"><?= $esc($m['name'] ?? '') ?></span>
|
||||||
<?php endif; ?>
|
<span class="menu-list__price"><?= $esc($euros($m['price_normal_cents'] ?? 0)) ?></span>
|
||||||
</div>
|
<button class="btn btn-secondary menu-configure" type="button" data-menu-id="<?= $mid ?>">
|
||||||
|
Configurer
|
||||||
<?php /* Panneau commande persistant (recap a droite, facon caisse). */ ?>
|
</button>
|
||||||
<aside class="pos__panel" aria-label="Commande en cours">
|
</li>
|
||||||
<div class="pos__panel-head">
|
<?php endforeach; ?>
|
||||||
<span class="pos__panel-title">Commande</span>
|
|
||||||
<div class="pos__service">
|
|
||||||
<?php if ($chan === 'drive'): ?>
|
|
||||||
<?php /* RG-T09 : au drive, le mode est impose. On AFFICHE 'Drive' fige et on
|
|
||||||
transmet la valeur par un champ cache (un select readonly resterait
|
|
||||||
editable, donc non fiable ; disabled ne serait pas soumis). */ ?>
|
|
||||||
<p class="form-static" id="service_mode_display">Drive</p>
|
|
||||||
<input type="hidden" name="service_mode" id="service_mode" value="drive">
|
|
||||||
<?php else: ?>
|
|
||||||
<label class="pos__service-label" for="service_mode">Mode</label>
|
|
||||||
<select class="form-input" id="service_mode" name="service_mode">
|
|
||||||
<option value="dine_in"<?= $mode === 'dine_in' ? ' selected' : '' ?>>Sur place</option>
|
|
||||||
<option value="takeaway"<?= $mode === 'takeaway' ? ' selected' : '' ?>>A emporter</option>
|
|
||||||
</select>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php if ($chan !== 'drive'): ?>
|
|
||||||
<?php /* 7a : numero de table, utile uniquement en sur place. Masque par defaut
|
|
||||||
hors dine_in (toggle JS sur le mode) ; le champ reste soumis tel quel,
|
|
||||||
persist() l'ignore hors dine_in. */ ?>
|
|
||||||
<div class="pos__service" id="service_tag_group"<?= $mode === 'dine_in' ? '' : ' hidden' ?>>
|
|
||||||
<label class="pos__service-label" for="service_tag">Table</label>
|
|
||||||
<input class="form-input" type="text" id="service_tag" name="service_tag"
|
|
||||||
maxlength="20" value="<?= $esc($tag) ?>" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php /* Pas d'aria-live sur la liste : elle est rebatie a chaque +/- (une
|
|
||||||
re-annonce de tout le panier serait verbeuse). Une region live dediee
|
|
||||||
(#pos-announce) annonce un message concis a chaque mutation. */ ?>
|
|
||||||
<ul class="order-cart" id="order-cart">
|
|
||||||
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="pos__panel-foot">
|
<fieldset class="form-group">
|
||||||
<?php /* Total indicatif du panier (recalcule cote serveur a l'encaissement). */ ?>
|
<legend>Panier</legend>
|
||||||
<p class="order-total" id="order-total">Total <span id="order-total-value"><?= $esc($euros(0)) ?></span></p>
|
<ul class="order-cart" id="order-cart" aria-live="polite">
|
||||||
<button class="btn btn-primary pos__pay" type="submit" id="order-submit">Encaisser <?= $esc($euros(0)) ?></button>
|
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
|
||||||
</div>
|
</ul>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<?php /* Region live concise (C) : recoit "Total X EUR, N articles" a chaque
|
<div class="form-actions">
|
||||||
mutation du panier. Visuellement discrete (classe sr-only). */ ?>
|
<button class="btn btn-primary" type="submit">Encaisser la commande</button>
|
||||||
<span class="sr-only" id="pos-announce" role="status" aria-live="polite"></span>
|
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,263 +3,107 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tableau de bord stock (READ_STOCK 9.3), injecte dans admin/layout.php. Oriente
|
* Liste du stock (READ_STOCK 9.3), injectee dans admin/layout.php. Affiche le
|
||||||
* usage quotidien : on met en avant ce qui est bas a reapprovisionner, le CRUD de
|
* pourcentage et la bande calcules (RG-2) ; les liens d'action sont conditionnes
|
||||||
* definition (config rare) est relegue. Le lien metier explique a quoi sert le stock :
|
* aux permissions (la garde reelle reste par-route). Texte echappe.
|
||||||
* un ingredient requis sous le seuil critique rend les produits qui l'utilisent
|
|
||||||
* indisponibles sur la borne (RG-T21). Pourcentage/bande resolus cote depot ; les
|
|
||||||
* liens d'action restent conditionnes aux permissions (garde reelle par-route). Texte echappe.
|
|
||||||
*
|
*
|
||||||
* @var array<int, array<string, mixed>> $ingredients
|
* @var array<int, array<string, mixed>> $ingredients
|
||||||
* @var array<string, int> $bandCounts
|
* @var bool $canManage
|
||||||
* @var bool $canManage
|
* @var bool $canRestock
|
||||||
* @var bool $canRestock
|
* @var bool $canCount
|
||||||
* @var bool $canCount
|
* @var string $csrfToken
|
||||||
* @var string|null $thresholdError
|
|
||||||
* @var string $csrfToken
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @var array<int, array<string, mixed>> $rows */
|
/** @var array<int, array<string, mixed>> $rows */
|
||||||
$rows = isset($ingredients) && is_array($ingredients) ? $ingredients : [];
|
$rows = isset($ingredients) && is_array($ingredients) ? $ingredients : [];
|
||||||
/** @var array<string, int> $counts */
|
|
||||||
$counts = isset($bandCounts) && is_array($bandCounts) ? $bandCounts : [];
|
|
||||||
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||||
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
$manage = (bool) ($canManage ?? false);
|
$manage = (bool) ($canManage ?? false);
|
||||||
$restock = (bool) ($canRestock ?? false);
|
$restock = (bool) ($canRestock ?? false);
|
||||||
$count = (bool) ($canCount ?? false);
|
$count = (bool) ($canCount ?? false);
|
||||||
|
|
||||||
$nCritical = (int) ($counts['critical'] ?? 0);
|
$bandLabel = static fn (string $band): string => match ($band) {
|
||||||
$nLow = (int) ($counts['low'] ?? 0);
|
'critical' => 'pill pill-danger',
|
||||||
$nNormal = (int) ($counts['normal'] ?? 0);
|
'low' => 'pill pill-warning',
|
||||||
$thresholdErr = isset($thresholdError) && is_string($thresholdError) ? $thresholdError : null;
|
default => 'pill pill-success',
|
||||||
|
|
||||||
/**
|
|
||||||
* Bouton "Regler les seuils" (F13). Ouvre la modale pre-remplie via des data-attributes
|
|
||||||
* (l'id pour l'action POST, le nom pour le titre, les trois valeurs courantes) ; le JS
|
|
||||||
* stock-thresholds.js intercepte le clic. Affiche seulement si le role peut calibrer le
|
|
||||||
* stock ($restock = stock.manage). Valeurs echappees (attributs HTML).
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $row
|
|
||||||
*/
|
|
||||||
$renderThresholdButton = static function (array $row) use ($esc, $restock): string {
|
|
||||||
if (!$restock) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
$id = (int) ($row['id'] ?? 0);
|
|
||||||
|
|
||||||
return '<button type="button" class="btn btn-ghost btn-sm" data-threshold-open'
|
|
||||||
. ' data-id="' . $id . '"'
|
|
||||||
. ' data-name="' . $esc($row['name'] ?? '') . '"'
|
|
||||||
. ' data-capacity="' . (int) ($row['stock_capacity'] ?? 0) . '"'
|
|
||||||
. ' data-low="' . (int) ($row['low_stock_pct'] ?? 0) . '"'
|
|
||||||
. ' data-critical="' . (int) ($row['critical_stock_pct'] ?? 0) . '">Regler les seuils</button>';
|
|
||||||
};
|
};
|
||||||
|
$bandText = static fn (string $band): string => match ($band) {
|
||||||
// Les ingredients a reapprovisionner : critiques d'abord, puis en alerte. Le reste
|
'critical' => 'Critique',
|
||||||
// (au-dessus des seuils) va dans la liste calme "Tous les ingredients" plus bas.
|
'low' => 'Alerte',
|
||||||
$critical = [];
|
default => 'Normal',
|
||||||
$low = [];
|
|
||||||
foreach ($rows as $row) {
|
|
||||||
$band = (string) ($row['stock_band'] ?? 'normal');
|
|
||||||
if ($band === 'critical') {
|
|
||||||
$critical[] = $row;
|
|
||||||
} elseif ($band === 'low') {
|
|
||||||
$low[] = $row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$toRestock = array_merge($critical, $low);
|
|
||||||
|
|
||||||
$barClass = static fn (string $band): string => match ($band) {
|
|
||||||
'critical' => 'stock-bar__fill stock-bar--critical',
|
|
||||||
'low' => 'stock-bar__fill stock-bar--low',
|
|
||||||
default => 'stock-bar__fill stock-bar--normal',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Barre de niveau : conteneur + portion remplie (largeur = pct%, couleur = bande).
|
|
||||||
* La largeur est bornee a 100 pour rester dans le conteneur meme si le depot renvoie
|
|
||||||
* un pourcentage superieur. Style inline pour la largeur (deja la convention admin).
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $row
|
|
||||||
*/
|
|
||||||
$renderBar = static function (array $row) use ($esc, $barClass): string {
|
|
||||||
$pct = (int) ($row['stock_pct'] ?? 0);
|
|
||||||
$width = max(0, min(100, $pct));
|
|
||||||
$band = (string) ($row['stock_band'] ?? 'normal');
|
|
||||||
$qty = (int) ($row['stock_quantity'] ?? 0);
|
|
||||||
$cap = (int) ($row['stock_capacity'] ?? 0);
|
|
||||||
|
|
||||||
$state = match ($band) {
|
|
||||||
'critical' => 'critique',
|
|
||||||
'low' => 'en alerte',
|
|
||||||
default => 'au-dessus du seuil',
|
|
||||||
};
|
|
||||||
$html = '<div class="stock-bar" role="img" aria-label="Niveau de stock ' . $pct . ' pourcent, etat ' . $state . '">';
|
|
||||||
$html .= '<span class="' . $esc($barClass($band)) . '" style="width:' . $width . '%"></span>';
|
|
||||||
$html .= '</div>';
|
|
||||||
$html .= '<div class="stock-bar__meta"><span class="stock-bar__pct">' . $pct . '%</span>';
|
|
||||||
$html .= '<span class="stock-bar__qty">' . $esc((string) $qty) . ' / ' . $esc((string) $cap) . '</span></div>';
|
|
||||||
|
|
||||||
return $html;
|
|
||||||
};
|
};
|
||||||
?>
|
?>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">Stock des ingredients</h1>
|
<h1 class="page-title">Stock</h1>
|
||||||
<p class="page-subtitle">Ce qui est bas a reapprovisionner, en un coup d oeil</p>
|
<p class="page-subtitle">Ingredients, niveaux de stock et mouvements</p>
|
||||||
</div>
|
</div>
|
||||||
<?php if ($manage): ?>
|
<?php if ($manage): ?>
|
||||||
<div class="page-actions">
|
<div class="page-actions">
|
||||||
<a class="btn btn-secondary" href="/admin/ingredients/new">Nouvel ingredient</a>
|
<a class="btn btn-primary" href="/admin/ingredients/new">Nouvel ingredient</a>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($thresholdErr !== null && $thresholdErr !== ''): ?>
|
<div class="table-container">
|
||||||
<div class="flash flash-error" role="alert"><?= $esc($thresholdErr) ?></div>
|
<div class="table-wrapper">
|
||||||
<?php endif; ?>
|
<table>
|
||||||
|
<thead>
|
||||||
<p class="stock-explainer">
|
<tr>
|
||||||
Le stock pilote ce qui est commandable sur la borne. Un ingredient requis par une
|
<th>Ingredient</th>
|
||||||
recette qui passe sous son seuil critique rend les produits qui l utilisent
|
<th>Unite</th>
|
||||||
indisponibles a la commande. Tenez les niveaux a jour pour garder le menu ouvert.
|
<th>Stock</th>
|
||||||
</p>
|
<th>Niveau</th>
|
||||||
|
<th>Statut</th>
|
||||||
<div class="stock-summary">
|
<th style="width:280px;"></th>
|
||||||
<div class="stock-summary__item stock-summary__item--danger">
|
</tr>
|
||||||
<span class="stock-summary__count"><?= $nCritical ?></span>
|
</thead>
|
||||||
<span class="stock-summary__label">critiques</span>
|
<tbody>
|
||||||
</div>
|
<?php if ($rows === []): ?>
|
||||||
<div class="stock-summary__item stock-summary__item--warning">
|
<tr><td colspan="6" class="muted">Aucun ingredient.</td></tr>
|
||||||
<span class="stock-summary__count"><?= $nLow ?></span>
|
<?php endif; ?>
|
||||||
<span class="stock-summary__label">en alerte</span>
|
<?php foreach ($rows as $row): ?>
|
||||||
</div>
|
<?php
|
||||||
<div class="stock-summary__item stock-summary__item--success">
|
$id = (int) ($row['id'] ?? 0);
|
||||||
<span class="stock-summary__count"><?= $nNormal ?></span>
|
$active = (int) ($row['is_active'] ?? 0) === 1;
|
||||||
<span class="stock-summary__label">au-dessus du seuil</span>
|
$band = (string) ($row['stock_band'] ?? 'normal');
|
||||||
</div>
|
$pct = (int) ($row['stock_pct'] ?? 0);
|
||||||
</div>
|
?>
|
||||||
|
<tr>
|
||||||
<section class="stock-section stock-section--restock">
|
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
|
||||||
<h2 class="stock-section__title">A reapprovisionner</h2>
|
<td class="muted"><?= $esc($row['unit'] ?? '') ?></td>
|
||||||
<?php if ($toRestock === []): ?>
|
<td>
|
||||||
<div class="stock-empty stock-empty--ok">
|
<?= $esc((string) ((int) ($row['stock_quantity'] ?? 0))) ?>
|
||||||
Tous les ingredients sont au-dessus de leurs seuils.
|
<span class="muted">/ <?= $esc((string) ((int) ($row['stock_capacity'] ?? 0))) ?> (<?= $pct ?>%)</span>
|
||||||
</div>
|
</td>
|
||||||
<?php else: ?>
|
<td><span class="<?= $bandLabel($band) ?>"><?= $bandText($band) ?></span></td>
|
||||||
<div class="stock-cards">
|
<td>
|
||||||
<?php foreach ($toRestock as $row): ?>
|
<?php if ($active): ?>
|
||||||
<?php
|
<span class="pill pill-success">Actif</span>
|
||||||
$id = (int) ($row['id'] ?? 0);
|
<?php else: ?>
|
||||||
$band = (string) ($row['stock_band'] ?? 'normal');
|
<span class="pill pill-neutral">Inactif</span>
|
||||||
$bandPill = $band === 'critical' ? 'pill pill-danger' : 'pill pill-warning';
|
<?php endif; ?>
|
||||||
$bandText = $band === 'critical' ? 'Critique' : 'Alerte';
|
</td>
|
||||||
?>
|
<td>
|
||||||
<div class="stock-card stock-card--<?= $esc($band) ?>">
|
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
|
||||||
<div class="stock-card__head">
|
<?php if ($restock): ?>
|
||||||
<div>
|
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/restock">Reappro</a>
|
||||||
<span class="stock-card__name"><?= $esc($row['name'] ?? '') ?></span>
|
<?php endif; ?>
|
||||||
<span class="stock-card__unit"><?= $esc($row['unit'] ?? '') ?></span>
|
<?php if ($count): ?>
|
||||||
</div>
|
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
|
||||||
<span class="<?= $bandPill ?>"><?= $bandText ?></span>
|
<?php endif; ?>
|
||||||
</div>
|
<?php if ($manage): ?>
|
||||||
<?= $renderBar($row) ?>
|
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/edit">Modifier</a>
|
||||||
<div class="stock-card__actions">
|
<form method="post" action="/admin/ingredients/<?= $id ?>/toggle" style="display:inline;">
|
||||||
<?php if ($restock): ?>
|
|
||||||
<a class="btn btn-primary stock-card__action" href="/admin/ingredients/<?= $id ?>/restock">Reapprovisionner</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?= $renderThresholdButton($row) ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="stock-section">
|
|
||||||
<h2 class="stock-section__title">Tous les ingredients</h2>
|
|
||||||
<?php if ($rows === []): ?>
|
|
||||||
<div class="stock-empty">Aucun ingredient.</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<ul class="stock-list">
|
|
||||||
<?php foreach ($rows as $row): ?>
|
|
||||||
<?php
|
|
||||||
$id = (int) ($row['id'] ?? 0);
|
|
||||||
$active = (int) ($row['is_active'] ?? 0) === 1;
|
|
||||||
?>
|
|
||||||
<li class="stock-list__row">
|
|
||||||
<div class="stock-list__main">
|
|
||||||
<span class="stock-list__name"><?= $esc($row['name'] ?? '') ?></span>
|
|
||||||
<span class="stock-list__unit"><?= $esc($row['unit'] ?? '') ?></span>
|
|
||||||
<?php if ($active): ?>
|
|
||||||
<span class="pill pill-success">Actif</span>
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="pill pill-neutral">Inactif</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<div class="stock-list__bar"><?= $renderBar($row) ?></div>
|
|
||||||
<div class="stock-list__actions">
|
|
||||||
<?php if ($count): ?>
|
|
||||||
<a class="btn btn-secondary btn-sm" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?= $renderThresholdButton($row) ?>
|
|
||||||
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
|
|
||||||
<?php if ($manage): ?>
|
|
||||||
<span class="stock-list__crud">
|
|
||||||
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/edit">Modifier</a>
|
|
||||||
<form method="post" action="/admin/ingredients/<?= $id ?>/toggle" class="stock-list__inline-form">
|
|
||||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||||
<button class="btn btn-ghost btn-sm" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
|
<button class="btn btn-secondary" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
|
||||||
</form>
|
</form>
|
||||||
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
|
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
|
||||||
</span>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
</li>
|
<?php endforeach; ?>
|
||||||
<?php endforeach; ?>
|
</tbody>
|
||||||
</ul>
|
</table>
|
||||||
<?php endif; ?>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<?php if ($restock): ?>
|
|
||||||
<?php /*
|
|
||||||
Modale de reglage rapide des seuils (F13), rendue serveur (VRAI form POST + CSRF,
|
|
||||||
comme restock/inventory ; pas de fetch). Une seule modale pour la page : le bouton
|
|
||||||
clique (data-threshold-open) y injecte l'action /admin/ingredients/{id}/thresholds
|
|
||||||
et pre-remplit les trois champs depuis ses data-attributes (stock-thresholds.js).
|
|
||||||
Reutilise les classes .pin-modal-* (overlay generique). Cachee par defaut (pas de
|
|
||||||
classe .open) : sans JS, elle reste invisible et les actions classiques fonctionnent.
|
|
||||||
*/ ?>
|
|
||||||
<div class="pin-modal-overlay" data-threshold-modal role="dialog" aria-modal="true" aria-label="Reglage des seuils de stock">
|
|
||||||
<div class="pin-modal">
|
|
||||||
<div class="pin-modal-head">
|
|
||||||
<div>
|
|
||||||
<h2 class="pin-modal-title">Regler les seuils</h2>
|
|
||||||
<p class="pin-modal-sub" data-threshold-name>Capacite de reference et seuils d alerte de l ingredient.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="" data-threshold-form>
|
|
||||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="th-capacity">Capacite (quantite consideree comme 100%)</label>
|
|
||||||
<input class="form-input" type="number" id="th-capacity" name="stock_capacity" min="1" step="1" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="th-low">Seuil d alerte (% du plein)</label>
|
|
||||||
<input class="form-input" type="number" id="th-low" name="low_stock_pct" min="0" max="100" step="1" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="th-critical">Seuil critique (% du plein)</label>
|
|
||||||
<input class="form-input" type="number" id="th-critical" name="critical_stock_pct" min="0" max="100" step="1" required>
|
|
||||||
<p class="form-hint">Le seuil critique doit etre inferieur au seuil d alerte. Sous le critique, les produits qui utilisent cet ingredient passent indisponibles sur la borne.</p>
|
|
||||||
</div>
|
|
||||||
<p class="form-error" data-threshold-error hidden></p>
|
|
||||||
<div class="pin-modal-actions">
|
|
||||||
<button class="btn btn-secondary" type="button" data-threshold-cancel>Annuler</button>
|
|
||||||
<button class="btn btn-primary" type="submit">Enregistrer les seuils</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,6 @@ declare(strict_types=1);
|
||||||
* de remise n'apparait que pour les roles dotes de order.deliver (kitchen ne l'a pas :
|
* de remise n'apparait que pour les roles dotes de order.deliver (kitchen ne l'a pas :
|
||||||
* il voit la file en lecture seule ; counter/drive/admin remettent).
|
* il voit la file en lecture seule ; counter/drive/admin remettent).
|
||||||
*
|
*
|
||||||
* Chaque commande porte son detail (items -> selections + modifiers) et une bande SLA
|
|
||||||
* (sla_band : fresh / warn / late) calculee cote serveur depuis (now - paid_at),
|
|
||||||
* mappee vers une classe CSS sur la carte (kds-order--fresh / --warn / --late). Le KDS
|
|
||||||
* est rendu exploitable pour PREPARER : la liste lisible des articles est affichee.
|
|
||||||
*
|
|
||||||
* @var list<array<string, mixed>> $orders
|
* @var list<array<string, mixed>> $orders
|
||||||
* @var bool $canDeliver
|
* @var bool $canDeliver
|
||||||
* @var string $csrfToken
|
* @var string $csrfToken
|
||||||
|
|
@ -25,65 +20,12 @@ $can = !empty($canDeliver);
|
||||||
|
|
||||||
$sourceLabel = static fn (string $s): string => ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive'][$s] ?? $s;
|
$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');
|
$modeLabel = static fn (string $m): string => $m === 'dine_in' ? 'Sur place' : ($m === 'drive' ? 'Drive' : 'A emporter');
|
||||||
|
|
||||||
// Bande SLA (serveur) -> classe CSS de la carte. Defaut prudent sur valeur inconnue.
|
|
||||||
$slaClass = static fn (string $band): string => [
|
|
||||||
'fresh' => 'kds-order--fresh',
|
|
||||||
'warn' => 'kds-order--warn',
|
|
||||||
'late' => 'kds-order--late',
|
|
||||||
][$band] ?? 'kds-order--fresh';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Libelle lisible d'un article : "<qty>x <label> (Maxi) - <selections> - <modifs>".
|
|
||||||
* S'appuie sur les snapshots (label_snapshot, format) ; les modificateurs sont rendus
|
|
||||||
* "sans <ingredient>" (remove) / "+<ingredient>" (add). Tout est echappe a la sortie.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $item
|
|
||||||
*/
|
|
||||||
$itemLabel = static function (array $item) use ($esc): string {
|
|
||||||
$qty = max(1, (int) ($item['quantity'] ?? 1));
|
|
||||||
$name = (string) ($item['label_snapshot'] ?? '');
|
|
||||||
$main = $esc($qty) . 'x ' . $esc($name);
|
|
||||||
if ((string) ($item['format'] ?? 'normal') === 'maxi') {
|
|
||||||
$main .= ' (Maxi)';
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = [];
|
|
||||||
|
|
||||||
$selections = isset($item['selections']) && is_array($item['selections']) ? $item['selections'] : [];
|
|
||||||
$selLabels = [];
|
|
||||||
foreach ($selections as $sel) {
|
|
||||||
$label = trim((string) ($sel['label_snapshot'] ?? ''));
|
|
||||||
if ($label !== '') {
|
|
||||||
$selLabels[] = $esc($label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ($selLabels !== []) {
|
|
||||||
$parts[] = implode(', ', $selLabels);
|
|
||||||
}
|
|
||||||
|
|
||||||
$modifiers = isset($item['modifiers']) && is_array($item['modifiers']) ? $item['modifiers'] : [];
|
|
||||||
$modLabels = [];
|
|
||||||
foreach ($modifiers as $mod) {
|
|
||||||
$ing = trim((string) ($mod['ingredient_name'] ?? ''));
|
|
||||||
if ($ing === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$modLabels[] = ((string) ($mod['action'] ?? '') === 'add' ? '+' : 'sans ') . $esc($ing);
|
|
||||||
}
|
|
||||||
if ($modLabels !== []) {
|
|
||||||
$parts[] = implode(', ', $modLabels);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $parts === [] ? $main : $main . ' - ' . implode(' - ', $parts);
|
|
||||||
};
|
|
||||||
?>
|
?>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">Cuisine</h1>
|
<h1 class="page-title">Cuisine</h1>
|
||||||
<p class="page-subtitle">File des commandes payees, de la plus ancienne a la plus recente.</p>
|
<p class="page-subtitle">File des commandes payees, de la plus ancienne a la plus recente.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="kitchen-clock" id="kitchenTime" aria-hidden="true"></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($rows === []): ?>
|
<?php if ($rows === []): ?>
|
||||||
|
|
@ -91,13 +33,9 @@ $itemLabel = static function (array $item) use ($esc): string {
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<section class="kitchen-grid" aria-label="File des commandes payees">
|
<section class="kitchen-grid" aria-label="File des commandes payees">
|
||||||
<?php foreach ($rows as $o): ?>
|
<?php foreach ($rows as $o): ?>
|
||||||
<?php
|
<article class="kitchen-card">
|
||||||
$items = isset($o['items']) && is_array($o['items']) ? $o['items'] : [];
|
|
||||||
$band = (string) ($o['sla_band'] ?? 'fresh');
|
|
||||||
?>
|
|
||||||
<article class="kitchen-card <?= $esc($slaClass($band)) ?>">
|
|
||||||
<div class="kitchen-card-header">
|
<div class="kitchen-card-header">
|
||||||
<span class="kitchen-order-num"><?= $esc($o['order_number'] ?? '') ?></span>
|
<span class="kitchen-card-number"><?= $esc($o['order_number'] ?? '') ?></span>
|
||||||
<span class="kitchen-card-source"><?= $esc($sourceLabel((string) ($o['source'] ?? ''))) ?></span>
|
<span class="kitchen-card-source"><?= $esc($sourceLabel((string) ($o['source'] ?? ''))) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="kitchen-card-body">
|
<div class="kitchen-card-body">
|
||||||
|
|
@ -106,15 +44,6 @@ $itemLabel = static function (array $item) use ($esc): string {
|
||||||
<p class="kitchen-line">Table : <?= $esc($o['service_tag']) ?></p>
|
<p class="kitchen-line">Table : <?= $esc($o['service_tag']) ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<p class="kitchen-line">Payee a : <?= $esc($o['paid_at'] ?? '') ?></p>
|
<p class="kitchen-line">Payee a : <?= $esc($o['paid_at'] ?? '') ?></p>
|
||||||
<?php if ($items === []): ?>
|
|
||||||
<p class="kitchen-line">Aucun article.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<ul class="kds-items">
|
|
||||||
<?php foreach ($items as $item): ?>
|
|
||||||
<li class="kds-item"><?= $itemLabel($item) ?></li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
<?php if ($can): ?>
|
<?php if ($can): ?>
|
||||||
<div class="kitchen-card-footer">
|
<div class="kitchen-card-footer">
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ declare(strict_types=1);
|
||||||
* @var list<string> $permissions
|
* @var list<string> $permissions
|
||||||
* @var string $csrfToken
|
* @var string $csrfToken
|
||||||
* @var string $activeNav
|
* @var string $activeNav
|
||||||
* @var string $orderChannel 'counter' | 'drive' : canal de saisie du role courant
|
|
||||||
* @var string|null $flash
|
* @var string|null $flash
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -28,13 +27,6 @@ $userRole = htmlspecialchars($currentUserRole ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
$active = is_string($activeNav ?? null) ? $activeNav : '';
|
$active = is_string($activeNav ?? null) ? $activeNav : '';
|
||||||
|
|
||||||
// Canal de saisie du role courant : un equipier drive est route vers /drive/orders,
|
|
||||||
// les autres roles vers /counter/orders. CounterOrderController pose activeNav a
|
|
||||||
// 'counter' ou 'drive' selon le chemin ; on marque le lien actif sur l'un OU l'autre.
|
|
||||||
$channel = ($orderChannel ?? '') === 'drive' ? 'drive' : 'counter';
|
|
||||||
$orderHref = $channel === 'drive' ? '/drive/orders' : '/counter/orders';
|
|
||||||
$orderActive = ($active === 'counter' || $active === 'drive') ? 'sidebar-item active' : 'sidebar-item';
|
|
||||||
|
|
||||||
/** @var list<string> $perms */
|
/** @var list<string> $perms */
|
||||||
$perms = isset($permissions) && is_array($permissions) ? $permissions : [];
|
$perms = isset($permissions) && is_array($permissions) ? $permissions : [];
|
||||||
$can = static fn (string $code): bool => in_array($code, $perms, true);
|
$can = static fn (string $code): bool => in_array($code, $perms, true);
|
||||||
|
|
@ -127,9 +119,9 @@ $navClass = static function (string $code, string $current): string {
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<div class="sidebar-section-label">Pilotage</div>
|
<div class="sidebar-section-label">Pilotage</div>
|
||||||
<?php if ($can('order.create')): ?>
|
<?php if ($can('order.create')): ?>
|
||||||
<?php /* Le canal (counter/drive) vient de role.order_source : un equipier
|
<?php /* Lien generique vers le comptoir ; le canal effectif (counter/drive)
|
||||||
drive est route vers /drive/orders, les autres vers /counter/orders. */ ?>
|
est derive du chemin par CounterOrderController (mlt 4.1). */ ?>
|
||||||
<a href="<?= htmlspecialchars($orderHref, ENT_QUOTES, 'UTF-8') ?>" class="<?= $orderActive ?>">Saisie commande</a>
|
<a href="/counter/orders" class="<?= $navClass('counter', $active) ?>">Saisie commande</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($can('stats.read')): ?>
|
<?php if ($can('stats.read')): ?>
|
||||||
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>
|
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>
|
||||||
|
|
@ -169,6 +161,5 @@ $navClass = static function (string $code, string $current): string {
|
||||||
</div>
|
</div>
|
||||||
<script src="/assets/js/admin.js"></script>
|
<script src="/assets/js/admin.js"></script>
|
||||||
<script src="/assets/js/pin-modal.js"></script>
|
<script src="/assets/js/pin-modal.js"></script>
|
||||||
<script src="/assets/js/stock-thresholds.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,8 @@ declare(strict_types=1);
|
||||||
*
|
*
|
||||||
* @var int $menuId
|
* @var int $menuId
|
||||||
* @var array<int, array<string, mixed>> $categories
|
* @var array<int, array<string, mixed>> $categories
|
||||||
* @var array<int, array<string, mixed>> $products burgers de base (select ancre)
|
* @var array<int, array<string, mixed>> $products
|
||||||
* @var array<int, array<string, mixed>> $slotProducts options de slot {id, name, category_slug} (F12)
|
|
||||||
* @var list<string> $slotTypes
|
* @var list<string> $slotTypes
|
||||||
* @var array<string, list<string>> $slotCategories slot_type -> categories autorisees (F12)
|
|
||||||
* @var array<string, mixed> $values
|
* @var array<string, mixed> $values
|
||||||
* @var string $slotsJson
|
* @var string $slotsJson
|
||||||
* @var array<string, string> $errors
|
* @var array<string, string> $errors
|
||||||
|
|
@ -32,12 +30,8 @@ $errs = isset($errors) && is_array($errors) ? $errors : [];
|
||||||
$cats = isset($categories) && is_array($categories) ? $categories : [];
|
$cats = isset($categories) && is_array($categories) ? $categories : [];
|
||||||
/** @var array<int, array<string, mixed>> $prods */
|
/** @var array<int, array<string, mixed>> $prods */
|
||||||
$prods = isset($products) && is_array($products) ? $products : [];
|
$prods = isset($products) && is_array($products) ? $products : [];
|
||||||
/** @var array<int, array<string, mixed>> $slotProds */
|
|
||||||
$slotProds = isset($slotProducts) && is_array($slotProducts) ? $slotProducts : [];
|
|
||||||
/** @var list<string> $types */
|
/** @var list<string> $types */
|
||||||
$types = isset($slotTypes) && is_array($slotTypes) ? $slotTypes : [];
|
$types = isset($slotTypes) && is_array($slotTypes) ? $slotTypes : [];
|
||||||
/** @var array<string, list<string>> $slotCats */
|
|
||||||
$slotCats = isset($slotCategories) && is_array($slotCategories) ? $slotCategories : [];
|
|
||||||
|
|
||||||
$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), 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] : '';
|
$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : '';
|
||||||
|
|
@ -47,16 +41,9 @@ $available = (bool) ($vals['is_available'] ?? true);
|
||||||
|
|
||||||
// Donnees pour le builder JS, passees en attributs data-* (CSP 'self' : pas de
|
// 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.
|
// script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut.
|
||||||
// F12 : chaque option de slot porte sa categorie (category) pour que le builder
|
|
||||||
// filtre les choix proposes selon le type de slot ; le mapping slot_type ->
|
|
||||||
// categories (slotCategories) provient de la meme source que la garde serveur.
|
|
||||||
$slimProducts = array_map(
|
$slimProducts = array_map(
|
||||||
static fn (array $p): array => [
|
static fn (array $p): array => ['id' => (int) ($p['id'] ?? 0), 'name' => (string) ($p['name'] ?? '')],
|
||||||
'id' => (int) ($p['id'] ?? 0),
|
$prods,
|
||||||
'name' => (string) ($p['name'] ?? ''),
|
|
||||||
'category' => (string) ($p['category_slug'] ?? ''),
|
|
||||||
],
|
|
||||||
$slotProds,
|
|
||||||
);
|
);
|
||||||
$attr = static fn (mixed $data): string => htmlspecialchars(
|
$attr = static fn (mixed $data): string => htmlspecialchars(
|
||||||
(string) json_encode($data, JSON_UNESCAPED_UNICODE),
|
(string) json_encode($data, JSON_UNESCAPED_UNICODE),
|
||||||
|
|
@ -137,7 +124,6 @@ $slotsData = isset($slotsJson) && is_string($slotsJson) && $slotsJson !== '' ? $
|
||||||
<div id="slot-builder"
|
<div id="slot-builder"
|
||||||
data-products="<?= $attr($slimProducts) ?>"
|
data-products="<?= $attr($slimProducts) ?>"
|
||||||
data-slot-types="<?= $attr($types) ?>"
|
data-slot-types="<?= $attr($types) ?>"
|
||||||
data-slot-categories="<?= $attr($slotCats) ?>"
|
|
||||||
data-slots="<?= htmlspecialchars($slotsData, ENT_QUOTES, 'UTF-8') ?>"></div>
|
data-slots="<?= htmlspecialchars($slotsData, ENT_QUOTES, 'UTF-8') ?>"></div>
|
||||||
<button class="btn btn-secondary" type="button" id="add-slot">Ajouter un slot</button>
|
<button class="btn btn-secondary" type="button" id="add-slot">Ajouter un slot</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ declare(strict_types=1);
|
||||||
*
|
*
|
||||||
* @var int $productId
|
* @var int $productId
|
||||||
* @var array<int, array<string, mixed>> $categories
|
* @var array<int, array<string, mixed>> $categories
|
||||||
* @var array<int, array<string, mixed>> $baseCandidates produits de base eligibles (R4)
|
|
||||||
* @var array<string, mixed> $values
|
* @var array<string, mixed> $values
|
||||||
* @var array<string, string> $errors
|
* @var array<string, string> $errors
|
||||||
* @var string $csrfToken
|
* @var string $csrfToken
|
||||||
|
|
@ -25,16 +24,12 @@ $vals = isset($values) && is_array($values) ? $values : [];
|
||||||
$errs = isset($errors) && is_array($errors) ? $errors : [];
|
$errs = isset($errors) && is_array($errors) ? $errors : [];
|
||||||
/** @var array<int, array<string, mixed>> $cats */
|
/** @var array<int, array<string, mixed>> $cats */
|
||||||
$cats = isset($categories) && is_array($categories) ? $categories : [];
|
$cats = isset($categories) && is_array($categories) ? $categories : [];
|
||||||
/** @var array<int, array<string, mixed>> $bases */
|
|
||||||
$bases = isset($baseCandidates) && is_array($baseCandidates) ? $baseCandidates : [];
|
|
||||||
|
|
||||||
$val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ?? ''), ENT_QUOTES, 'UTF-8');
|
$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] : '';
|
$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? $errs[$k] : '';
|
||||||
$selectedCat = (string) ($vals['category_id'] ?? '');
|
$selectedCat = (string) ($vals['category_id'] ?? '');
|
||||||
$selectedVat = (string) ($vals['vat_rate'] ?? '100');
|
$selectedVat = (string) ($vals['vat_rate'] ?? '100');
|
||||||
$available = (bool) ($vals['is_available'] ?? true);
|
$available = (bool) ($vals['is_available'] ?? true);
|
||||||
$selectedBase = (string) ($vals['base_product_id'] ?? '');
|
|
||||||
$selectedMaxi = (string) ($vals['maxi_variant_product_id'] ?? '');
|
|
||||||
?>
|
?>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -101,48 +96,6 @@ $selectedMaxi = (string) ($vals['maxi_variant_product_id'] ?? '');
|
||||||
<label class="form-label"><input type="checkbox" name="is_available" value="1"<?= $available ? ' checked' : '' ?>> Disponible</label>
|
<label class="form-label"><input type="checkbox" name="is_available" value="1"<?= $available ? ' checked' : '' ?>> Disponible</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="form-group">
|
|
||||||
<legend>Variantes (optionnel)</legend>
|
|
||||||
<p><small>A remplir seulement pour une boisson en plusieurs tailles ou un accompagnement servi en plus grand au format Maxi. Laissez vide pour un produit ordinaire.</small></p>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="size_cl">Taille en centilitres (boissons)</label>
|
|
||||||
<input class="form-input" type="number" id="size_cl" name="size_cl" min="0" max="65535" value="<?= $val('size_cl') ?>">
|
|
||||||
<small>Exemple : 30 ou 50 pour un soda. Laissez vide si le produit n'a pas de taille.</small>
|
|
||||||
<?php if ($err('size_cl') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('size_cl'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="base_product_id">Variante de taille de</label>
|
|
||||||
<select class="form-input" id="base_product_id" name="base_product_id">
|
|
||||||
<option value="">-- ce produit est un produit a part entiere --</option>
|
|
||||||
<?php foreach ($bases as $b): ?>
|
|
||||||
<?php $bid = (string) ($b['id'] ?? ''); ?>
|
|
||||||
<option value="<?= htmlspecialchars($bid, ENT_QUOTES, 'UTF-8') ?>"<?= $bid === $selectedBase ? ' selected' : '' ?>>
|
|
||||||
<?= htmlspecialchars((string) ($b['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<small>Rattache ce produit a un produit principal comme une autre taille (exemple : "Coca 50cl" rattache a "Coca"). Une variante n'apparait pas seule sur la borne.</small>
|
|
||||||
<?php if ($err('base_product_id') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('base_product_id'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="maxi_variant_product_id">Version servie en format Maxi</label>
|
|
||||||
<select class="form-input" id="maxi_variant_product_id" name="maxi_variant_product_id">
|
|
||||||
<option value="">-- aucune (pas de version Maxi) --</option>
|
|
||||||
<?php foreach ($bases as $b): ?>
|
|
||||||
<?php $bid = (string) ($b['id'] ?? ''); ?>
|
|
||||||
<option value="<?= htmlspecialchars($bid, ENT_QUOTES, 'UTF-8') ?>"<?= $bid === $selectedMaxi ? ' selected' : '' ?>>
|
|
||||||
<?= htmlspecialchars((string) ($b['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<small>Le produit servi a la place de celui-ci quand le menu est commande en Maxi (exemple : "Moyenne Frite" servie en "Grande Frite").</small>
|
|
||||||
<?php if ($err('maxi_variant_product_id') !== ''): ?><p class="form-error"><?= htmlspecialchars($err('maxi_variant_product_id'), ENT_QUOTES, 'UTF-8') ?></p><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<?php if ($id !== 0): ?>
|
<?php if ($id !== 0): ?>
|
||||||
<fieldset class="form-group">
|
<fieldset class="form-group">
|
||||||
<legend>Changement de prix ou de TVA : confirmation par PIN</legend>
|
<legend>Changement de prix ou de TVA : confirmation par PIN</legend>
|
||||||
|
|
|
||||||
|
|
@ -49,22 +49,9 @@ $euros = static fn (int $cents): string => number_format($cents / 100, 2, ',', '
|
||||||
$available = (int) ($row['is_available'] ?? 0) === 1;
|
$available = (int) ($row['is_available'] ?? 0) === 1;
|
||||||
$autoRupture = in_array($id, $autoIds, true); // RG-T21 : stock-driven
|
$autoRupture = in_array($id, $autoIds, true); // RG-T21 : stock-driven
|
||||||
$vat = (int) ($row['vat_rate'] ?? 100);
|
$vat = (int) ($row['vat_rate'] ?? 100);
|
||||||
// R4/F9-4 : une ligne dont base_product_id est non nul est une
|
|
||||||
// VARIANTE de taille, pas un produit autonome. On la garde dans la
|
|
||||||
// liste (l'admin la voit et la gere) mais on la marque "Variante de
|
|
||||||
// X" pour qu'aucune confusion ne subsiste.
|
|
||||||
$baseProductId = isset($row['base_product_id']) && $row['base_product_id'] !== null
|
|
||||||
? (int) $row['base_product_id'] : 0;
|
|
||||||
$isVariant = $baseProductId > 0;
|
|
||||||
$baseName = (string) ($row['base_name'] ?? '');
|
|
||||||
?>
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fw-600">
|
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
|
||||||
<?= $esc($row['name'] ?? '') ?>
|
|
||||||
<?php if ($isVariant): ?>
|
|
||||||
<span class="pill pill-neutral" title="Cette ligne est une variante de taille, pas un produit affiche seul sur la borne">Variante de <?= $esc($baseName !== '' ? $baseName : '?') ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td class="muted"><?= $esc($row['category_name'] ?? '') ?></td>
|
<td class="muted"><?= $esc($row['category_name'] ?? '') ?></td>
|
||||||
<td><?= $esc($euros((int) ($row['price_cents'] ?? 0))) ?></td>
|
<td><?= $esc($euros((int) ($row['price_cents'] ?? 0))) ?></td>
|
||||||
<td class="muted"><?= $vat === 55 ? '5,5%' : '10%' ?></td>
|
<td class="muted"><?= $vat === 55 ? '5,5%' : '10%' ?></td>
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,6 @@ $errorMessage = isset($error) && is_string($error) ? $error : null;
|
||||||
<form method="post" action="/admin/profile/pin" class="form-card">
|
<form method="post" action="/admin/profile/pin" class="form-card">
|
||||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
|
||||||
<input class="form-input" type="password" id="current_password" name="current_password" autocomplete="current-password" required>
|
|
||||||
<small>Confirme votre identite avant de definir un PIN d action sensible.</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="pin">Nouveau PIN</label>
|
<label class="form-label" for="pin">Nouveau PIN</label>
|
||||||
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off" required>
|
<input class="form-input" type="password" id="pin" name="pin" inputmode="numeric" autocomplete="off" required>
|
||||||
|
|
|
||||||
|
|
@ -85,36 +85,6 @@ button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Utilitaire lecteur d'ecran : retire du flux visuel, lu par les technologies
|
|
||||||
d'assistance (regions live discretes type #pos-announce). */
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Message d'erreur de formulaire (global admin : erreurs de validation / serveur). */
|
|
||||||
.form-error {
|
|
||||||
color: var(--color-danger-text);
|
|
||||||
background: var(--color-danger-bg);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin: 6px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Etat vide d'une liste / d'un catalogue (global admin). */
|
|
||||||
.admin-empty {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Layout Shell --- */
|
/* --- Layout Shell --- */
|
||||||
.admin-layout {
|
.admin-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -1095,48 +1065,6 @@ tbody td.mono {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- KDS : detail des articles --- */
|
|
||||||
.kds-items {
|
|
||||||
list-style: none;
|
|
||||||
margin: 8px 0 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kds-item {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.35;
|
|
||||||
padding: 4px 0;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kds-item:first-child {
|
|
||||||
border-top: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- KDS : bande SLA (now - paid_at), calculee cote serveur --- */
|
|
||||||
/* Bande couleur a gauche de la carte : vert < 5 min, ambre 5-10 min, rouge > 10 min. */
|
|
||||||
.kds-order--fresh {
|
|
||||||
border-left: 4px solid var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kds-order--warn {
|
|
||||||
border-left: 4px solid var(--color-warning);
|
|
||||||
background: var(--color-warning-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kds-order--late {
|
|
||||||
border-left: 4px solid var(--color-danger);
|
|
||||||
background: var(--color-danger-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kitchen-clock {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Login page --- */
|
/* --- Login page --- */
|
||||||
.login-page {
|
.login-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|
@ -1503,636 +1431,3 @@ tbody td.mono {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =============================================================================
|
|
||||||
POS tactile a tuiles comptoir/drive (counter-order.js + admin/counter/new.php)
|
|
||||||
Ecran de caisse facon borne : onglets categories, grille de tuiles, panneau
|
|
||||||
commande persistant (lignes + stepper + total), modale de composition.
|
|
||||||
Cibles tactiles superieures ou egales a 44px (usage tablette).
|
|
||||||
============================================================================= */
|
|
||||||
|
|
||||||
/* Mode de service fige (drive) : affichage non editable a la place du select. */
|
|
||||||
.form-static {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
padding: 8px 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disposition POS : catalogue a gauche (flex 1), panneau commande a droite (fixe). */
|
|
||||||
.pos { margin: 0; }
|
|
||||||
.pos__main {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.pos__catalogue {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Onglets categories : bandeau scrollable horizontal (un onglet par categorie). */
|
|
||||||
.pos__tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
.pos__tab {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 10px 18px;
|
|
||||||
border: 2px solid var(--color-border-dark);
|
|
||||||
border-radius: var(--radius-pill, 9999px);
|
|
||||||
background: var(--color-white);
|
|
||||||
color: var(--color-text-sec);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease;
|
|
||||||
}
|
|
||||||
.pos__tab:hover,
|
|
||||||
.pos__tab:focus-visible {
|
|
||||||
border-color: var(--color-yellow);
|
|
||||||
color: var(--color-text);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.pos__tab.is-active {
|
|
||||||
border-color: var(--color-yellow);
|
|
||||||
background: var(--color-yellow);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Grille de tuiles produits/menus : auto-fit, grandes cibles tactiles. */
|
|
||||||
.pos__grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.pos__nojs {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tuile : image/pastille + nom + prix. Bouton plein (tap = ajout ou modale). */
|
|
||||||
.pos-tile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--color-white);
|
|
||||||
border: 2px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
position: relative;
|
|
||||||
transition: border-color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease;
|
|
||||||
}
|
|
||||||
.pos-tile:hover,
|
|
||||||
.pos-tile:focus-visible {
|
|
||||||
border-color: var(--color-yellow);
|
|
||||||
box-shadow: var(--shadow-card-hover);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.pos-tile:active { transform: translateY(0); box-shadow: var(--shadow-card); }
|
|
||||||
|
|
||||||
.pos-tile__media {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
background: var(--color-surface);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.pos-tile__image {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
padding: 10px;
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
/* Pastille de repli (initiale) quand aucune image n'est disponible cote back-office. */
|
|
||||||
.pos-tile__pastille {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-yellow-soft, #FFF3D1);
|
|
||||||
color: var(--color-yellow-ink, #C8920A);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
.pos-tile__body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 10px 12px 12px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.pos-tile__name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
.pos-tile__price {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text-sec);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
/* Badge "Menu" / "A composer" sur une tuile qui ouvre la modale au tap. */
|
|
||||||
.pos-tile__badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
z-index: 2;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-text);
|
|
||||||
color: var(--color-white);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rupture de stock (RG-T21, parite borne product-card--unavailable) : tuile visible
|
|
||||||
mais non commandable. Grisee, curseur interdit, sans effet de survol (l'etat ne
|
|
||||||
ment pas) ; le tap est neutralise cote JS (return precoce). */
|
|
||||||
.pos-tile--unavailable {
|
|
||||||
opacity: 0.55;
|
|
||||||
filter: grayscale(0.6);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.pos-tile--unavailable:hover,
|
|
||||||
.pos-tile--unavailable:focus-visible {
|
|
||||||
border-color: var(--color-border);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
/* Badge "Indisponible" : pose en haut a gauche pour ne pas chevaucher le badge
|
|
||||||
"Menu"/"A composer" (top-right) sur une tuile menu en rupture. */
|
|
||||||
.pos-tile__badge--unavailable {
|
|
||||||
right: auto;
|
|
||||||
left: 8px;
|
|
||||||
background: var(--color-brand-dark, var(--color-text));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Panneau commande persistant a droite (facon caisse). */
|
|
||||||
.pos__panel {
|
|
||||||
flex: 0 0 340px;
|
|
||||||
position: sticky;
|
|
||||||
top: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: calc(100vh - 32px);
|
|
||||||
background: var(--color-white);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.pos__panel-head {
|
|
||||||
padding: 16px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.pos__panel-title {
|
|
||||||
font-size: 17px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.pos__service {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.pos__service-label {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text-sec);
|
|
||||||
min-width: 48px;
|
|
||||||
}
|
|
||||||
.pos__service .form-input { flex: 1; min-height: 44px; }
|
|
||||||
|
|
||||||
/* Lignes du panier (corps scrollable du panneau). */
|
|
||||||
.order-cart {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0 16px;
|
|
||||||
margin: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
.order-cart__empty { color: var(--color-text-muted); padding: 16px 0; }
|
|
||||||
.order-cart__line {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
.order-cart__main {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.order-cart__label { font-weight: 600; flex: 1; }
|
|
||||||
.order-cart__price { font-weight: 800; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
||||||
.order-cart__controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.order-cart__qty {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.order-cart__qty-btn {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 2px solid var(--color-border-dark);
|
|
||||||
background: var(--color-white);
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: border-color 0.12s ease, background 0.12s ease;
|
|
||||||
}
|
|
||||||
.order-cart__qty-btn:hover,
|
|
||||||
.order-cart__qty-btn:focus-visible {
|
|
||||||
border-color: var(--color-yellow);
|
|
||||||
background: var(--color-yellow-bg);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.order-cart__qty-value {
|
|
||||||
min-width: 32px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 800;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pied du panneau : total indicatif + bouton d'encaissement (pleine largeur). */
|
|
||||||
.pos__panel-foot {
|
|
||||||
padding: 16px;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
background: var(--color-surface);
|
|
||||||
}
|
|
||||||
.order-total {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 800;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
.order-total span { font-variant-numeric: tabular-nums; }
|
|
||||||
.pos__pay {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 52px;
|
|
||||||
font-size: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tablette etroite / portrait : le panneau passe sous le catalogue. */
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.pos__main { flex-direction: column; }
|
|
||||||
.pos__panel {
|
|
||||||
flex-basis: auto;
|
|
||||||
width: 100%;
|
|
||||||
position: static;
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
.order-cart { max-height: 320px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modale de composition : overlay + panneau centre. */
|
|
||||||
.menu-composer__overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(26, 26, 26, 0.45);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
.menu-composer {
|
|
||||||
background: var(--color-white);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
border-top: 3px solid var(--color-yellow);
|
|
||||||
box-shadow: var(--shadow-card-hover);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 460px;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
.menu-composer__title { font-size: 18px; font-weight: 800; margin-bottom: 14px; }
|
|
||||||
.menu-composer__legend { font-weight: 600; margin: 12px 0 4px; }
|
|
||||||
.menu-composer__slot, .menu-composer__format, .menu-composer__modifiers { margin-bottom: 10px; }
|
|
||||||
.menu-composer__radio, .menu-composer__modifier {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin: 3px 14px 3px 0;
|
|
||||||
}
|
|
||||||
.menu-composer__error {
|
|
||||||
color: var(--color-danger-text);
|
|
||||||
background: var(--color-danger-bg);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 8px 10px;
|
|
||||||
margin: 10px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
/* Le <p role=alert> reste dans le DOM en permanence (annonce lecteur d'ecran) ;
|
|
||||||
tant qu'il est vide, on l'efface visuellement pour ne pas afficher un bloc vide. */
|
|
||||||
.menu-composer__error:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.menu-composer__actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Stock dashboard (page d'accueil ingredients) --- */
|
|
||||||
.stock-explainer {
|
|
||||||
background: var(--color-yellow-bg);
|
|
||||||
border: 1px solid var(--color-yellow-soft);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--color-text-sec);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-summary__item {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 10px;
|
|
||||||
background: var(--color-white);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-left-width: 4px;
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
padding: 18px 22px;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-summary__item--danger { border-left-color: var(--color-danger); }
|
|
||||||
.stock-summary__item--warning { border-left-color: var(--color-warning); }
|
|
||||||
.stock-summary__item--success { border-left-color: var(--color-success); }
|
|
||||||
|
|
||||||
.stock-summary__count {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-summary__item--danger .stock-summary__count { color: var(--color-danger-text); }
|
|
||||||
.stock-summary__item--warning .stock-summary__count { color: var(--color-warning-text); }
|
|
||||||
.stock-summary__item--success .stock-summary__count { color: var(--color-success-text); }
|
|
||||||
|
|
||||||
.stock-summary__label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-section {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-section__title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-empty {
|
|
||||||
background: var(--color-white);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-empty--ok {
|
|
||||||
background: var(--color-success-bg);
|
|
||||||
border-color: var(--color-success-bg);
|
|
||||||
color: var(--color-success-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Barre de niveau : conteneur gris + portion remplie coloree selon la bande. */
|
|
||||||
.stock-bar {
|
|
||||||
height: 8px;
|
|
||||||
background: var(--color-neutral-bg);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-bar__fill {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-bar--critical { background: var(--color-danger); }
|
|
||||||
.stock-bar--low { background: var(--color-warning); }
|
|
||||||
.stock-bar--normal { background: var(--color-success); }
|
|
||||||
|
|
||||||
.stock-bar__meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-bar__pct { font-weight: 600; color: var(--color-text); }
|
|
||||||
.stock-bar__qty { color: var(--color-text-muted); }
|
|
||||||
|
|
||||||
/* Section "A reapprovisionner" : cartes mises en avant. */
|
|
||||||
.stock-cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-card {
|
|
||||||
background: var(--color-white);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-top-width: 3px;
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
padding: 16px 18px;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-card--critical { border-top-color: var(--color-danger); }
|
|
||||||
.stock-card--low { border-top-color: var(--color-warning); }
|
|
||||||
|
|
||||||
.stock-card__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-card__name {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-card__unit {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-card__action {
|
|
||||||
height: 44px;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section "Tous les ingredients" : liste calme, actions secondaires discretes. */
|
|
||||||
.stock-list {
|
|
||||||
list-style: none;
|
|
||||||
background: var(--color-white);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-list__row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 200px auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-list__row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-list__main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-list__name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-list__unit {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-list__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-list__crud {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding-left: 6px;
|
|
||||||
margin-left: 2px;
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stock-list__inline-form {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.stock-summary {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.stock-list__row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.stock-list__actions {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bandeau de confirmation (flash succes, pose par setFlash apres redirection) et sa
|
|
||||||
variante erreur (re-rendu 422 du reglage rapide de seuils, F13). */
|
|
||||||
.flash {
|
|
||||||
color: var(--color-success-text);
|
|
||||||
background: var(--color-success-bg);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 10px 14px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.flash-error {
|
|
||||||
color: var(--color-danger-text);
|
|
||||||
background: var(--color-danger-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Aide sous un champ de formulaire (texte explicatif, pas une erreur). */
|
|
||||||
.form-hint {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Zone d'actions d'une carte a reapprovisionner : reappro + reglage des seuils empiles. */
|
|
||||||
.stock-card__actions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.stock-card__actions .btn-sm {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -2,34 +2,23 @@
|
||||||
* menu-form.js — Builder de slots du formulaire menu (back-office).
|
* menu-form.js — Builder de slots du formulaire menu (back-office).
|
||||||
*
|
*
|
||||||
* CSP 'self' : script externe (pas d'inline). Les donnees (produits, types,
|
* CSP 'self' : script externe (pas d'inline). Les donnees (produits, types,
|
||||||
* mapping slot_type -> categories, slots initiaux) sont lues depuis les attributs
|
* slots initiaux) sont lues depuis les attributs data-* de #slot-builder.
|
||||||
* data-* de #slot-builder. A la soumission, l'etat des slots est serialise en JSON
|
* A la soumission, l'etat des slots est serialise en JSON dans le champ cache
|
||||||
* dans le champ cache #slots_json (Request::formBody cote serveur ne garde que les
|
* #slots_json (Request::formBody cote serveur ne garde que les scalaires, d'ou
|
||||||
* scalaires, d'ou le passage par une chaine JSON). Le serveur revalide tout (RG-T18).
|
* le passage par une chaine JSON). Le serveur revalide tout (RG-T18).
|
||||||
*
|
|
||||||
* F12 : les options proposees dans un slot sont FILTREES par le type de slot. Chaque
|
|
||||||
* produit porte sa categorie (data-products[].category) ; le mapping slot_type ->
|
|
||||||
* categories (data-slot-categories) decide quelles categories sont eligibles. Le type
|
|
||||||
* etant un <select> modifiable, la liste d'options est RE-RENDUE a chaque changement
|
|
||||||
* de type. Le mapping vient de MenuController::SLOT_CATEGORIES (source unique, aussi
|
|
||||||
* appliquee par la garde serveur parseSlots).
|
|
||||||
*
|
|
||||||
* Module CommonJS (admin = racine CommonJS, comme pin-modal.js / counter-order.js) :
|
|
||||||
* init(doc) est exporte pour les tests et auto-appele au DOMContentLoaded en prod.
|
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
function el(doc, tag, className) {
|
var builder = document.getElementById('slot-builder');
|
||||||
var e = doc.createElement(tag);
|
var form = document.getElementById('menu-form');
|
||||||
if (className) {
|
var hidden = document.getElementById('slots_json');
|
||||||
e.className = className;
|
var addBtn = document.getElementById('add-slot');
|
||||||
}
|
if (!builder || !form || !hidden || !addBtn) {
|
||||||
return e;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lit un attribut data-* JSON et retourne un Array, sinon le fallback (forme tableau).
|
function parseData(key, fallback) {
|
||||||
function parseArray(builder, key, fallback) {
|
|
||||||
try {
|
try {
|
||||||
var v = JSON.parse(builder.dataset[key] || fallback);
|
var v = JSON.parse(builder.dataset[key] || fallback);
|
||||||
return Array.isArray(v) ? v : JSON.parse(fallback);
|
return Array.isArray(v) ? v : JSON.parse(fallback);
|
||||||
|
|
@ -38,213 +27,134 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lit un attribut data-* JSON et retourne un objet simple (le mapping slot_type ->
|
var products = parseData('products', '[]'); // [{id, name}]
|
||||||
// categories), sinon {} ; tolerant aux entrees non-objet / mal formees.
|
var slotTypes = parseData('slotTypes', '[]'); // ['drink', 'side', ...]
|
||||||
function parseObject(builder, key) {
|
var initialSlots = parseData('slots', '[]'); // [{name, slot_type, is_required, options:[id]}]
|
||||||
try {
|
|
||||||
var v = JSON.parse(builder.dataset[key] || '{}');
|
function el(tag, className) {
|
||||||
return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {};
|
var e = document.createElement(tag);
|
||||||
} catch (e) {
|
if (className) {
|
||||||
return {};
|
e.className = className;
|
||||||
}
|
}
|
||||||
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Categories eligibles pour un slot_type donne (liste, vide si type inconnu).
|
// Construit le bloc DOM d'un slot. `slot` peut etre vide (creation).
|
||||||
function allowedCategories(slotCategories, slotType) {
|
function renderSlot(slot) {
|
||||||
var list = slotCategories[slotType];
|
slot = slot || {};
|
||||||
return Array.isArray(list) ? list : [];
|
var selectedOptions = Array.isArray(slot.options) ? slot.options.map(Number) : [];
|
||||||
}
|
|
||||||
|
|
||||||
// Un produit est-il proposable dans un slot de ce type ? (sa categorie figure dans
|
var block = el('fieldset', 'slot-block form-group');
|
||||||
// la liste autorisee). Source unique de la decision UI, miroir de la garde serveur.
|
block.style.border = '1px solid #ddd';
|
||||||
function productAllowed(product, slotCategories, slotType) {
|
block.style.padding = '0.75rem';
|
||||||
return allowedCategories(slotCategories, slotType).indexOf(String(product.category)) !== -1;
|
block.style.marginBottom = '0.75rem';
|
||||||
}
|
|
||||||
|
|
||||||
function init(doc) {
|
var head = el('div');
|
||||||
var builder = doc.getElementById('slot-builder');
|
|
||||||
var form = doc.getElementById('menu-form');
|
|
||||||
var hidden = doc.getElementById('slots_json');
|
|
||||||
var addBtn = doc.getElementById('add-slot');
|
|
||||||
if (!builder || !form || !hidden || !addBtn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var products = parseArray(builder, 'products', '[]'); // [{id, name, category}]
|
// Nom du slot
|
||||||
var slotTypes = parseArray(builder, 'slotTypes', '[]'); // ['drink', 'side', ...]
|
var nameLabel = el('label');
|
||||||
var slotCategories = parseObject(builder, 'slotCategories'); // {drink:['boissons'], ...}
|
nameLabel.appendChild(document.createTextNode('Nom du slot '));
|
||||||
var initialSlots = parseArray(builder, 'slots', '[]'); // [{name, slot_type, is_required, options:[id]}]
|
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);
|
||||||
|
|
||||||
// (Re)construit la liste des cases a cocher d'options pour un type de slot donne.
|
// Type
|
||||||
// N'affiche QUE les produits dont la categorie est eligible (F12). Les ids deja
|
var typeLabel = el('label');
|
||||||
// coches qui restent eligibles sont conserves coches ; un id devenu non eligible
|
typeLabel.appendChild(document.createTextNode(' Type '));
|
||||||
// (changement de type) DISPARAIT simplement de la liste : c'est le comportement
|
var typeSelect = el('select', 'form-input slot-type');
|
||||||
// le plus previsible pour un equipier non technicien (une option absente ne peut
|
slotTypes.forEach(function (t) {
|
||||||
// pas etre soumise par megarde), et la garde serveur rejetterait de toute facon
|
var opt = el('option');
|
||||||
// une option hors categorie. selectedSet : set d'ids coches a preserver.
|
opt.value = String(t);
|
||||||
function renderOptions(optWrap, slotType, selectedSet) {
|
opt.textContent = String(t);
|
||||||
optWrap.textContent = '';
|
if (String(slot.slot_type) === String(t)) {
|
||||||
var shown = 0;
|
opt.selected = true;
|
||||||
products.forEach(function (p) {
|
|
||||||
if (!productAllowed(p, slotCategories, slotType)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
shown += 1;
|
|
||||||
var lab = el(doc, 'label');
|
|
||||||
lab.style.display = 'block';
|
|
||||||
var cb = el(doc, 'input', 'slot-option');
|
|
||||||
cb.type = 'checkbox';
|
|
||||||
cb.value = String(p.id);
|
|
||||||
if (selectedSet[String(p.id)]) {
|
|
||||||
cb.checked = true;
|
|
||||||
}
|
|
||||||
lab.appendChild(cb);
|
|
||||||
lab.appendChild(doc.createTextNode(' ' + String(p.name)));
|
|
||||||
optWrap.appendChild(lab);
|
|
||||||
});
|
|
||||||
// Repere visible quand aucun produit n'est eligible (ex. type sans catalogue) :
|
|
||||||
// evite une zone vide muette pour l'equipier.
|
|
||||||
if (shown === 0) {
|
|
||||||
var empty = el(doc, 'p');
|
|
||||||
empty.className = 'slot-options-empty';
|
|
||||||
empty.textContent = 'Aucun produit disponible pour ce type de slot.';
|
|
||||||
optWrap.appendChild(empty);
|
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
// Construit le bloc DOM d'un slot. `slot` peut etre vide (creation).
|
// Retirer
|
||||||
function renderSlot(slot) {
|
var removeBtn = el('button', 'btn btn-secondary slot-remove');
|
||||||
slot = slot || {};
|
removeBtn.type = 'button';
|
||||||
var selectedSet = {};
|
removeBtn.textContent = 'Retirer';
|
||||||
(Array.isArray(slot.options) ? slot.options : []).forEach(function (id) {
|
removeBtn.addEventListener('click', function () {
|
||||||
selectedSet[String(Number(id))] = true;
|
block.parentNode.removeChild(block);
|
||||||
});
|
});
|
||||||
|
head.appendChild(removeBtn);
|
||||||
|
|
||||||
var block = el(doc, 'fieldset', 'slot-block form-group');
|
block.appendChild(head);
|
||||||
block.style.border = '1px solid #ddd';
|
|
||||||
block.style.padding = '0.75rem';
|
|
||||||
block.style.marginBottom = '0.75rem';
|
|
||||||
|
|
||||||
var head = el(doc, 'div');
|
// Options : cases a cocher des produits eligibles
|
||||||
|
var optWrap = el('div', 'slot-options');
|
||||||
// Nom du slot
|
optWrap.style.maxHeight = '160px';
|
||||||
var nameLabel = el(doc, 'label');
|
optWrap.style.overflowY = 'auto';
|
||||||
nameLabel.appendChild(doc.createTextNode('Nom du slot '));
|
optWrap.style.marginTop = '0.5rem';
|
||||||
var nameInput = el(doc, 'input', 'form-input slot-name');
|
products.forEach(function (p) {
|
||||||
nameInput.type = 'text';
|
var lab = el('label');
|
||||||
nameInput.maxLength = 80;
|
lab.style.display = 'block';
|
||||||
nameInput.value = slot.name ? String(slot.name) : '';
|
var cb = el('input', 'slot-option');
|
||||||
nameLabel.appendChild(nameInput);
|
cb.type = 'checkbox';
|
||||||
head.appendChild(nameLabel);
|
cb.value = String(p.id);
|
||||||
|
if (selectedOptions.indexOf(Number(p.id)) !== -1) {
|
||||||
// Type
|
cb.checked = true;
|
||||||
var typeLabel = el(doc, 'label');
|
|
||||||
typeLabel.appendChild(doc.createTextNode(' Type '));
|
|
||||||
var typeSelect = el(doc, 'select', 'form-input slot-type');
|
|
||||||
slotTypes.forEach(function (t) {
|
|
||||||
var opt = el(doc, '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(doc, 'label');
|
|
||||||
var reqInput = el(doc, 'input', 'slot-required');
|
|
||||||
reqInput.type = 'checkbox';
|
|
||||||
if (Number(slot.is_required) === 1) {
|
|
||||||
reqInput.checked = true;
|
|
||||||
}
|
}
|
||||||
reqLabel.appendChild(reqInput);
|
lab.appendChild(cb);
|
||||||
reqLabel.appendChild(doc.createTextNode(' Requis'));
|
lab.appendChild(document.createTextNode(' ' + String(p.name)));
|
||||||
head.appendChild(reqLabel);
|
optWrap.appendChild(lab);
|
||||||
|
|
||||||
// Retirer
|
|
||||||
var removeBtn = el(doc, '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 AU TYPE COURANT (F12).
|
|
||||||
var optWrap = el(doc, 'div', 'slot-options');
|
|
||||||
optWrap.style.maxHeight = '160px';
|
|
||||||
optWrap.style.overflowY = 'auto';
|
|
||||||
optWrap.style.marginTop = '0.5rem';
|
|
||||||
// Type initial : la valeur du slot (edition) ou le 1er type (creation), pour
|
|
||||||
// matcher l'option selectionnee par defaut dans le <select> ci-dessus.
|
|
||||||
var currentType = String(typeSelect.value || (slotTypes.length ? slotTypes[0] : ''));
|
|
||||||
renderOptions(optWrap, currentType, selectedSet);
|
|
||||||
block.appendChild(optWrap);
|
|
||||||
|
|
||||||
// Re-filtrage dynamique : changer le type re-rend les options eligibles. On
|
|
||||||
// repart des cases actuellement cochees (preservees si encore eligibles), pas
|
|
||||||
// de la selection initiale : l'equipier ne reperd pas un choix encore valide.
|
|
||||||
typeSelect.addEventListener('change', function () {
|
|
||||||
var keep = {};
|
|
||||||
Array.prototype.forEach.call(optWrap.querySelectorAll('.slot-option'), function (cb) {
|
|
||||||
if (cb.checked) {
|
|
||||||
keep[String(cb.value)] = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
renderOptions(optWrap, String(typeSelect.value), keep);
|
|
||||||
});
|
|
||||||
|
|
||||||
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));
|
|
||||||
});
|
});
|
||||||
|
block.appendChild(optWrap);
|
||||||
|
|
||||||
form.addEventListener('submit', function () {
|
return block;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
// Lit l'etat des blocs et le serialise dans #slots_json.
|
||||||
module.exports = { init: init, productAllowed: productAllowed, allowedCategories: allowedCategories };
|
function serialize() {
|
||||||
}
|
var slots = [];
|
||||||
if (typeof document !== 'undefined' && document.addEventListener) {
|
var blocks = builder.querySelectorAll('.slot-block');
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
Array.prototype.forEach.call(blocks, function (block) {
|
||||||
init(document);
|
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));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
/**
|
|
||||||
* stock-thresholds.js — Reglage rapide des seuils de stock depuis le tableau de bord (F13).
|
|
||||||
*
|
|
||||||
* Chaque carte/ligne ingredient porte un bouton "Regler les seuils" (data-threshold-open)
|
|
||||||
* decore de ses valeurs courantes (data-id, data-name, data-capacity, data-low,
|
|
||||||
* data-critical). Au clic, on pre-remplit l'unique modale rendue serveur (un VRAI form POST
|
|
||||||
* avec CSRF, pas de fetch), on pointe son action sur /admin/ingredients/{id}/thresholds, et
|
|
||||||
* on l'ouvre. La validation finale reste cote serveur (validateThresholds) ; on ajoute ici
|
|
||||||
* un garde-fou client leger (capacite >= 1, % 0-100, critique < alerte strict) pour eviter
|
|
||||||
* un aller-retour evident. Sans JS, la modale reste cachee et le reste de la page marche.
|
|
||||||
*
|
|
||||||
* CSP 'self' : script externe, aucun handler inline. Style CommonJS testable + browser-safe.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
function init(doc) {
|
|
||||||
var overlay = doc.querySelector('[data-threshold-modal]');
|
|
||||||
if (!overlay) {
|
|
||||||
return; // role sans stock.manage : la modale n'est pas rendue.
|
|
||||||
}
|
|
||||||
|
|
||||||
var form = overlay.querySelector('[data-threshold-form]');
|
|
||||||
var inCapacity = doc.getElementById('th-capacity');
|
|
||||||
var inLow = doc.getElementById('th-low');
|
|
||||||
var inCritical = doc.getElementById('th-critical');
|
|
||||||
var nameLabel = overlay.querySelector('[data-threshold-name]');
|
|
||||||
var errorBox = overlay.querySelector('[data-threshold-error]');
|
|
||||||
if (!form || !inCapacity || !inLow || !inCritical) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var openers = doc.querySelectorAll('[data-threshold-open]');
|
|
||||||
for (var i = 0; i < openers.length; i++) {
|
|
||||||
openers[i].addEventListener('click', function (e) {
|
|
||||||
openModal(e.currentTarget);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
overlay.querySelector('[data-threshold-cancel]').addEventListener('click', closeModal);
|
|
||||||
overlay.addEventListener('mousedown', function (e) {
|
|
||||||
if (e.target === overlay) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
doc.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Escape' && overlay.classList.contains('open')) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Garde-fou client : on ne bloque que les cas evidents (le serveur reste l'autorite).
|
|
||||||
form.addEventListener('submit', function (e) {
|
|
||||||
var error = validate(inCapacity.value, inLow.value, inCritical.value);
|
|
||||||
if (error !== null) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (errorBox) {
|
|
||||||
errorBox.textContent = error;
|
|
||||||
errorBox.hidden = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function openModal(button) {
|
|
||||||
var id = button.getAttribute('data-id') || '';
|
|
||||||
form.setAttribute('action', '/admin/ingredients/' + id + '/thresholds');
|
|
||||||
if (nameLabel) {
|
|
||||||
var name = button.getAttribute('data-name') || '';
|
|
||||||
nameLabel.textContent = name === '' ? '' : 'Ingredient : ' + name;
|
|
||||||
}
|
|
||||||
inCapacity.value = button.getAttribute('data-capacity') || '';
|
|
||||||
inLow.value = button.getAttribute('data-low') || '';
|
|
||||||
inCritical.value = button.getAttribute('data-critical') || '';
|
|
||||||
if (errorBox) {
|
|
||||||
errorBox.hidden = true;
|
|
||||||
}
|
|
||||||
overlay.classList.add('open');
|
|
||||||
inCapacity.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
overlay.classList.remove('open');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validation cliente legere, miroir de validateThresholds() cote serveur :
|
|
||||||
* capacite entiere >= 1 ; seuils entiers 0-100 ; critique STRICTEMENT < alerte.
|
|
||||||
* Renvoie un message d'erreur (string) ou null si tout est coherent.
|
|
||||||
*/
|
|
||||||
function validate(capacityRaw, lowRaw, criticalRaw) {
|
|
||||||
var capacity = toInt(capacityRaw);
|
|
||||||
var low = toInt(lowRaw);
|
|
||||||
var critical = toInt(criticalRaw);
|
|
||||||
|
|
||||||
if (capacity === null || capacity < 1) {
|
|
||||||
return 'La capacite (reference 100%) doit etre un entier superieur ou egal a 1.';
|
|
||||||
}
|
|
||||||
if (low === null || low < 0 || low > 100) {
|
|
||||||
return 'Le seuil d alerte doit etre un entier entre 0 et 100.';
|
|
||||||
}
|
|
||||||
if (critical === null || critical < 0 || critical > 100) {
|
|
||||||
return 'Le seuil critique doit etre un entier entre 0 et 100.';
|
|
||||||
}
|
|
||||||
if (critical >= low) {
|
|
||||||
return 'Le seuil critique doit etre strictement inferieur au seuil d alerte.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Entier strict (suite de chiffres) ou null : refuse "", " 5", "5.0", "abc". */
|
|
||||||
function toInt(raw) {
|
|
||||||
var value = String(raw === undefined || raw === null ? '' : raw).trim();
|
|
||||||
if (!/^[0-9]+$/.test(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseInt(value, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
|
||||||
module.exports = { init: init, validate: validate };
|
|
||||||
}
|
|
||||||
if (typeof document !== 'undefined' && document.addEventListener) {
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
init(document);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -221,11 +221,6 @@ try {
|
||||||
$router->add('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']);
|
$router->add('POST', '/admin/ingredients/{id}/delete', [IngredientController::class, 'destroy']);
|
||||||
$router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']);
|
$router->add('GET', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restockForm']);
|
||||||
$router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']);
|
$router->add('POST', '/admin/ingredients/{id}/restock', [IngredientController::class, 'restock']);
|
||||||
// Reglage rapide des seuils (F13) : capacite/alerte/critique edites depuis la page
|
|
||||||
// Stock via une modale, sans passer par le formulaire complet. stock.manage (calibrage
|
|
||||||
// du stock), CSRF, SANS PIN (config, pas un comptage d'inventaire). {id} = un seul
|
|
||||||
// segment ; /thresholds ne chevauche ni /restock ni /inventory.
|
|
||||||
$router->add('POST', '/admin/ingredients/{id}/thresholds', [IngredientController::class, 'updateThresholds']);
|
|
||||||
$router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']);
|
$router->add('GET', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventoryForm']);
|
||||||
$router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']);
|
$router->add('POST', '/admin/ingredients/{id}/inventory', [IngredientController::class, 'inventory']);
|
||||||
$router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']);
|
$router->add('GET', '/admin/ingredients/{id}/movements', [IngredientController::class, 'movements']);
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,7 @@ button {
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-header__cart,
|
||||||
.site-header__mode {
|
.site-header__mode {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
@ -453,6 +454,49 @@ button {
|
||||||
7. SHARED COMPONENTS — header extensions + badges + buttons
|
7. SHARED COMPONENTS — header extensions + badges + buttons
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
|
/* Cart link in header (products / product / cart pages) */
|
||||||
|
.site-header__cart {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header__cart:hover,
|
||||||
|
.site-header__cart:focus-visible {
|
||||||
|
background: rgba(255, 199, 44, 0.18);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-icon {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-brand-red);
|
||||||
|
color: #fff;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mode badge — shown in header for context */
|
/* Mode badge — shown in header for context */
|
||||||
.mode-badge {
|
.mode-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
@ -632,35 +676,6 @@ button {
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rupture de stock (RG-T21) : tuile visible mais non commandable. Grisee, non
|
|
||||||
cliquable, sans effet de survol (l'image est attenuee, pas l'etat ne ment pas). */
|
|
||||||
.product-card--unavailable {
|
|
||||||
opacity: 0.55;
|
|
||||||
filter: grayscale(0.6);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-card--unavailable:hover,
|
|
||||||
.product-card--unavailable:focus-visible {
|
|
||||||
border-color: var(--color-border-default);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Badge "Indisponible" superpose a l'image d'une tuile en rupture. */
|
|
||||||
.product-card__badge {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--space-2);
|
|
||||||
left: var(--space-2);
|
|
||||||
z-index: 2;
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-brand-dark);
|
|
||||||
color: #fff;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-card__image-wrap {
|
.product-card__image-wrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
|
|
@ -700,9 +715,215 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
10. COMPONENT — QUANTITY CONTROLS (modale options produit)
|
9. COMPONENT — PRODUCT DETAIL (product.html)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
|
.product-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-6);
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-error {
|
||||||
|
color: var(--color-brand-red);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__skeleton {
|
||||||
|
/* Placeholder shown while JS loads data */
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
animation: skeleton-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__image-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__name {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__price {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-brand-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__composition {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-left: 4px solid var(--color-brand-yellow);
|
||||||
|
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__composition-title {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__composition-text {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__add {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
10. COMPONENT — CART (cart.html)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.cart-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-main__heading {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-10) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-empty__message {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One cart line */
|
||||||
|
.cart-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-line__image {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-line__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-line__name {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-line__unit-price {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quantity controls */
|
||||||
|
.cart-line__qty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.qty-btn {
|
.qty-btn {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|
@ -734,6 +955,73 @@ button {
|
||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-line__total {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-line__remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-line__remove:hover,
|
||||||
|
.cart-line__remove:focus-visible {
|
||||||
|
background: rgba(218, 2, 14, 0.10);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Order summary block */
|
||||||
|
.cart-summary {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: var(--space-5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-summary__line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-summary__line--total {
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 2px solid var(--color-border-default);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cart action row */
|
||||||
|
.cart-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-bottom: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-actions .btn {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
11. COMPONENT — PAYMENT (payment.html)
|
11. COMPONENT — PAYMENT (payment.html)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
@ -951,6 +1239,15 @@ button {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-line {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-line__info {
|
||||||
|
flex: 1 1 calc(100% - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
.payment-methods {
|
.payment-methods {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|
@ -966,10 +1263,14 @@ button {
|
||||||
.products-main {
|
.products-main {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-main {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
14. COMPONENT — MENU COMPOSER MODAL (modale ouverte depuis la grille produits)
|
14. COMPONENT — MENU COMPOSER MODAL (product.html, type=menu)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -989,42 +1290,6 @@ button {
|
||||||
animation: composer-fade-in var(--transition-base) both;
|
animation: composer-fade-in var(--transition-base) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modale de confirmation d'un geste destructeur (Abandon). */
|
|
||||||
.confirm-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.55);
|
|
||||||
z-index: 210;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: var(--space-4);
|
|
||||||
animation: composer-fade-in var(--transition-base) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-modal {
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-overlay);
|
|
||||||
padding: var(--space-6);
|
|
||||||
max-width: 28rem;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-modal__message {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
margin: 0 0 var(--space-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-modal__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-3);
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes composer-fade-in {
|
@keyframes composer-fade-in {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
|
|
@ -1221,6 +1486,10 @@ button {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail__info .allergen-info-btn {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.allergen-modal-overlay {
|
.allergen-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -1490,6 +1759,31 @@ button {
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Cart line composition display -------------------- */
|
||||||
|
|
||||||
|
.cart-line__composition {
|
||||||
|
list-style: none;
|
||||||
|
padding: var(--space-1) 0 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-line__comp-item {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-line__comp-suppl {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-brand-yellow-dk);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: 1.4;
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Responsive — narrow screens --------------------- */
|
/* ---------- Responsive — narrow screens --------------------- */
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
|
@ -1597,7 +1891,8 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-panel__line {
|
.order-panel__line {
|
||||||
padding: var(--space-3) 0;
|
position: relative;
|
||||||
|
padding: var(--space-3) var(--space-6) var(--space-3) 0;
|
||||||
border-bottom: 1px solid var(--color-border-default);
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1628,49 +1923,14 @@ button {
|
||||||
content: "+ ";
|
content: "+ ";
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ligne de controles : stepper de quantite a gauche, retrait a droite. */
|
|
||||||
.order-panel__line-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-panel__qty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Boutons +/- : cible tactile confortable (borne). */
|
|
||||||
.order-panel__qty-btn {
|
|
||||||
min-width: 44px;
|
|
||||||
min-height: 44px;
|
|
||||||
border: 2px solid var(--color-border-default);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-panel__qty-value {
|
|
||||||
min-width: 2ch;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.order-panel__remove {
|
.order-panel__remove {
|
||||||
display: flex;
|
position: absolute;
|
||||||
align-items: center;
|
top: var(--space-3);
|
||||||
justify-content: center;
|
right: 0;
|
||||||
min-width: 44px;
|
|
||||||
min-height: 44px;
|
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@
|
||||||
*
|
*
|
||||||
* CSP 'self' : aucun script inline, aucun handler inline. Le DOM est construit par
|
* CSP 'self' : aucun script inline, aucun handler inline. Le DOM est construit par
|
||||||
* l'API (createElement/textContent) ; textContent neutralise toute injection.
|
* l'API (createElement/textContent) ; textContent neutralise toute injection.
|
||||||
* Les donnees viennent de data.js (loadAllergens), qui lit /api/allergens.
|
* Les donnees viennent de data.js (loadAllergens) : liste fixe en P5, /api/allergens
|
||||||
* openAllergenModal prend la liste en parametre pour rester independant de la
|
* au swap P4. openAllergenModal prend la liste en parametre pour rester independant
|
||||||
* couche de chargement (et testable sans fetch).
|
* de la couche de chargement (et testable sans fetch).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const OVERLAY_CLASS = 'allergen-modal-overlay';
|
const OVERLAY_CLASS = 'allergen-modal-overlay';
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@
|
||||||
* Traduction panier borne -> contrat API :
|
* Traduction panier borne -> contrat API :
|
||||||
* - produit simple -> { type:'product', product_id, quantity }
|
* - produit simple -> { type:'product', product_id, quantity }
|
||||||
* - menu -> { type:'menu', menu_id, quantity, format, selections }
|
* - menu -> { type:'menu', menu_id, quantity, format, selections }
|
||||||
* format = cartItem.format (choix Normal/Maxi porte par l'item panier) ; repli
|
* format = 'maxi' si supplement_cents>0, sinon 'normal'.
|
||||||
* historique sur supplement_cents>0 pour un panier serialise avant cette version.
|
|
||||||
* selections = [{menu_slot_id, product_id}] reconstruites depuis la composition
|
* selections = [{menu_slot_id, product_id}] reconstruites depuis la composition
|
||||||
* (accompagnement/boisson/sauce) mappee aux slots reels du menu (re-fetch).
|
* (accompagnement/boisson/sauce) mappee aux slots reels du menu (re-fetch).
|
||||||
* - service_mode : 'sur-place' -> 'dine_in', 'a-emporter' -> 'takeaway'.
|
* - service_mode : 'sur-place' -> 'dine_in', 'a-emporter' -> 'takeaway'.
|
||||||
|
|
@ -65,10 +64,7 @@ export function buildOrderItem(cartItem, menuSlotsById) {
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
menu_id: cartItem.id,
|
menu_id: cartItem.id,
|
||||||
quantity: cartItem.quantite,
|
quantity: cartItem.quantite,
|
||||||
// Format choisi par l'utilisateur, transporte explicitement. Repli sur
|
format: (cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal',
|
||||||
// l'ancienne inference (supplement_cents>0) pour un panier serialise en
|
|
||||||
// sessionStorage avant l'ajout du champ format.
|
|
||||||
format: cartItem.format ?? ((cartItem.supplement_cents ?? 0) > 0 ? 'maxi' : 'normal'),
|
|
||||||
selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []),
|
selections: buildSelections(cartItem.composition, menuSlotsById[cartItem.id] || []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
/*
|
|
||||||
* confirm-modal.js — Modale de confirmation reutilisable pour un geste destructeur
|
|
||||||
* (ex. Abandon de toute la commande). CSP-safe (createElement + addEventListener,
|
|
||||||
* aucun handler inline). Accessible : role="dialog" aria-modal, fond mis aria-hidden,
|
|
||||||
* Echap et clic sur le fond = annuler, focus piege dans la modale et rendu au
|
|
||||||
* declencheur a la fermeture. Defaut sur Annuler : un appui accidentel sur Entree
|
|
||||||
* n'execute pas l'action destructrice. Public non-technique : message + 2 boutons clairs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { escHtml } from './state.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Affiche une demande de confirmation. onConfirm n'est appele que si l'utilisateur
|
|
||||||
* confirme explicitement ; Annuler / Echap / clic-fond ferment sans rien faire.
|
|
||||||
* @param {{message:string, confirmLabel?:string, cancelLabel?:string, onConfirm:Function}} opts
|
|
||||||
* @returns {{close:Function}}
|
|
||||||
*/
|
|
||||||
export function confirmAction({ message, confirmLabel = 'Confirmer', cancelLabel = 'Annuler', onConfirm }) {
|
|
||||||
const previouslyFocused = document.activeElement;
|
|
||||||
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.className = 'confirm-overlay';
|
|
||||||
overlay.innerHTML = `
|
|
||||||
<div class="confirm-modal" role="dialog" aria-modal="true" aria-labelledby="confirm-modal-msg">
|
|
||||||
<p class="confirm-modal__message" id="confirm-modal-msg">${escHtml(message)}</p>
|
|
||||||
<div class="confirm-modal__actions">
|
|
||||||
<button type="button" class="confirm-modal__cancel btn btn--secondary">${escHtml(cancelLabel)}</button>
|
|
||||||
<button type="button" class="confirm-modal__confirm btn btn--primary">${escHtml(confirmLabel)}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const prevOverflow = document.body.style.overflow;
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
const bgSiblings = Array.from(document.body.children).filter(el => el !== overlay);
|
|
||||||
bgSiblings.forEach(el => el.setAttribute('aria-hidden', 'true'));
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
document.removeEventListener('keydown', onKey);
|
|
||||||
bgSiblings.forEach(el => el.removeAttribute('aria-hidden'));
|
|
||||||
overlay.remove();
|
|
||||||
document.body.style.overflow = prevOverflow;
|
|
||||||
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
|
||||||
previouslyFocused.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Echap = annuler ; Tab/Shift+Tab pieges sur les boutons de la modale.
|
|
||||||
const onKey = (e) => {
|
|
||||||
if (e.key === 'Escape') { close(); return; }
|
|
||||||
if (e.key !== 'Tab') return;
|
|
||||||
const focusable = Array.from(overlay.querySelectorAll('button:not([disabled])'));
|
|
||||||
if (!focusable.length) return;
|
|
||||||
const first = focusable[0];
|
|
||||||
const last = focusable[focusable.length - 1];
|
|
||||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
||||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
|
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
|
||||||
overlay.querySelector('.confirm-modal__cancel').addEventListener('click', close);
|
|
||||||
overlay.querySelector('.confirm-modal__confirm').addEventListener('click', () => {
|
|
||||||
close();
|
|
||||||
onConfirm();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Focus initial sur Annuler (defaut sur), pour ne pas confirmer par inadvertance.
|
|
||||||
requestAnimationFrame(() => overlay.querySelector('.confirm-modal__cancel').focus());
|
|
||||||
|
|
||||||
return { close };
|
|
||||||
}
|
|
||||||
|
|
@ -10,17 +10,18 @@
|
||||||
* indexe par slug de categorie ; menus glisses sous la cle 'menus'). Les signatures
|
* 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.
|
* publiques et les formes de retour sont inchangees -> les pages n'ont pas bouge.
|
||||||
*
|
*
|
||||||
* Les allergenes sont desormais lus depuis /api/allergens (id/code/name/description),
|
* Les allergenes restent un repli statique (data/allergens.json) : leur bascule
|
||||||
* comme les autres collections catalogue : le repli statique a ete retire.
|
* sur /api/allergens est un chunk ulterieur.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CATEGORIES_URL = '/api/categories';
|
const CATEGORIES_URL = '/api/categories';
|
||||||
const PRODUCTS_URL = '/api/products';
|
const PRODUCTS_URL = '/api/products';
|
||||||
const MENUS_URL = '/api/menus';
|
const MENUS_URL = '/api/menus';
|
||||||
/* Les 14 allergenes INCO (info generale, modale borne). L'endpoint /api/allergens
|
/* Liste fixe des 14 allergenes INCO (info generale, modale borne). L'endpoint
|
||||||
* porte id/code/name/description (la description INCO est seede en base) -> la borne
|
* /api/allergens existe desormais (id/code/name), mais la borne garde ce JSON
|
||||||
* la consomme via l'API, comme les autres collections catalogue. */
|
* statique : il porte les DESCRIPTIONS riches, absentes du schema allergen. Bascule
|
||||||
const ALLERGENS_URL = '/api/allergens';
|
* possible si les descriptions sont ajoutees cote API. */
|
||||||
|
const ALLERGENS_URL = 'data/allergens.json';
|
||||||
|
|
||||||
/* Memoisation par PROMESSE (pas par resultat) : N appelants concurrents au meme
|
/* Memoisation par PROMESSE (pas par resultat) : N appelants concurrents au meme
|
||||||
* chargement partagent UNE seule requete reseau (evite les fetch /api/* redondants
|
* chargement partagent UNE seule requete reseau (evite les fetch /api/* redondants
|
||||||
|
|
@ -88,16 +89,12 @@ export function loadProducts() {
|
||||||
// en a une, sinon null. Le composeur de menu l'affiche en format Maxi.
|
// en a une, sinon null. Le composeur de menu l'affiche en format Maxi.
|
||||||
maxiNom: p.maxi_variant_name ?? null,
|
maxiNom: p.maxi_variant_name ?? null,
|
||||||
sizes: Array.isArray(p.sizes) ? p.sizes : [],
|
sizes: Array.isArray(p.sizes) ? p.sizes : [],
|
||||||
// commandable : false si rupture de stock calculee (RG-T21, is_orderable
|
|
||||||
// serveur) -> la borne grise la tuile et bloque le clic. Defaut true si
|
|
||||||
// l'API ne porte pas le flag (compat).
|
|
||||||
commandable: p.is_orderable !== false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const m of menus) {
|
for (const m of menus) {
|
||||||
const slug = slugByCategoryId[m.category_id];
|
const slug = slugByCategoryId[m.category_id];
|
||||||
if (slug === undefined) continue;
|
if (slug === undefined) continue;
|
||||||
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu', commandable: m.is_orderable !== false });
|
bySlug[slug].push({ id: m.id, nom: m.name, prix: m.price_normal_cents, image: m.image_path, type: 'menu' });
|
||||||
}
|
}
|
||||||
return bySlug;
|
return bySlug;
|
||||||
}).catch(e => { _productsPromise = null; throw e; });
|
}).catch(e => { _productsPromise = null; throw e; });
|
||||||
|
|
@ -147,16 +144,17 @@ export async function loadMenu(id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and caches the 14 INCO allergens (general info modal). Consomme
|
* Fetches and caches the 14 INCO allergens (general info modal). Repli statique :
|
||||||
* /api/allergens (enveloppe { data }, forme canonique id/code/name/description) et
|
* la reponse est un tableau nu (pas d'enveloppe), conserve tel quel.
|
||||||
* ramene chaque entree a la forme borne { id, name, description } attendue par la
|
* @returns {Promise<Array>}
|
||||||
* modale (allergens.js) ; le champ `code` n'est pas utilise cote borne.
|
|
||||||
* @returns {Promise<Array<{id:number, name:string, description:?string}>>}
|
|
||||||
*/
|
*/
|
||||||
export function loadAllergens() {
|
export function loadAllergens() {
|
||||||
if (!_allergensPromise) {
|
if (!_allergensPromise) {
|
||||||
_allergensPromise = fetchCollection(ALLERGENS_URL)
|
_allergensPromise = fetch(ALLERGENS_URL)
|
||||||
.then(rows => rows.map(a => ({ id: a.id, name: a.name, description: a.description ?? null })))
|
.then(res => {
|
||||||
|
if (!res.ok) throw new Error(`Failed to load allergens: HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
.catch(e => { _allergensPromise = null; throw e; });
|
.catch(e => { _allergensPromise = null; throw e; });
|
||||||
}
|
}
|
||||||
return _allergensPromise;
|
return _allergensPromise;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
/*
|
/*
|
||||||
* order-panel.js — Panneau de commande persistant (maquette : recap a droite de
|
* order-panel.js — Panneau de commande persistant (maquette : recap a droite de
|
||||||
* l'ecran de commande). Rendu sur l'ecran de commande (products) pour que le panier
|
* l'ecran de commande). Rendu sur les ecrans de commande (products, product) pour
|
||||||
* reste visible en permanence, comme sur la maquette borne.
|
* que le panier reste visible en permanence, comme sur la maquette borne.
|
||||||
*
|
*
|
||||||
* C'est l'UNIQUE vue panier : il montre lignes + total + Abandon/Payer, permet
|
* C'est un miroir COMPACT de page-cart.js : meme modele d'item, meme rendu de la
|
||||||
* d'ajuster la quantite de chaque ligne (+/-) et de la retirer. La logique de mise
|
* composition de menu. La page panier (cart.html) reste la vue detaillee (TVA, +/-) ;
|
||||||
* en forme est extraite en fonctions PURES (buildPanelModel, compositionLabels)
|
* le panneau, lui, montre lignes + total + Abandon/Payer et permet de retirer une
|
||||||
* pour etre testable sans DOM.
|
* ligne. La logique de mise en forme est extraite en fonctions PURES (buildPanelModel,
|
||||||
|
* compositionLabels) pour etre testable sans DOM.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getCart,
|
getCart,
|
||||||
removeFromCart,
|
removeFromCart,
|
||||||
updateQuantity,
|
|
||||||
computeMenuLineCents,
|
computeMenuLineCents,
|
||||||
clearCart,
|
clearCart,
|
||||||
formatPrice,
|
formatPrice,
|
||||||
|
|
@ -20,7 +20,6 @@ import {
|
||||||
getMode,
|
getMode,
|
||||||
} from './state.js';
|
} from './state.js';
|
||||||
import { refreshCartBadge } from './nav.js';
|
import { refreshCartBadge } from './nav.js';
|
||||||
import { confirmAction } from './confirm-modal.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcule le total d'une ligne en centimes (menu : avec supplement de taille ;
|
* Calcule le total d'une ligne en centimes (menu : avec supplement de taille ;
|
||||||
|
|
@ -36,8 +35,8 @@ export function lineCents(item) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit les libelles des options d'un menu (puces sous le nom de ligne).
|
* Construit les libelles des options d'un menu (puces sous le nom de ligne).
|
||||||
* Sans le supplement (le panneau affiche le total de ligne, pas le detail TVA).
|
* Miroir de renderCompositionBlock() de page-cart.js, sans le supplement (le panneau
|
||||||
* Tolerant aux composants absents.
|
* affiche le total de ligne, pas le detail TVA). Tolerant aux composants absents.
|
||||||
* @param {Object|undefined} c — objet composition de l'item menu
|
* @param {Object|undefined} c — objet composition de l'item menu
|
||||||
* @returns {string[]}
|
* @returns {string[]}
|
||||||
*/
|
*/
|
||||||
|
|
@ -94,7 +93,7 @@ function modeLabel() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit le HTML d'une ligne du panneau. Toute valeur derivee du catalogue est
|
* Construit le HTML d'une ligne du panneau. Toute valeur derivee du catalogue est
|
||||||
* echappee (RG-T15 anti-XSS).
|
* echappee (RG-T15 anti-XSS), comme dans page-cart.js.
|
||||||
* @param {Object} line — element de buildPanelModel().lines
|
* @param {Object} line — element de buildPanelModel().lines
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
|
|
@ -107,37 +106,18 @@ function lineHtml(line) {
|
||||||
return `
|
return `
|
||||||
<li class="order-panel__line">
|
<li class="order-panel__line">
|
||||||
<div class="order-panel__line-main">
|
<div class="order-panel__line-main">
|
||||||
<span class="order-panel__line-name">${escHtml(line.libelle)}</span>
|
<span class="order-panel__line-name">${line.quantite}× ${escHtml(line.libelle)}</span>
|
||||||
<span class="order-panel__line-price">${formatPrice(line.lineCents)}</span>
|
<span class="order-panel__line-price">${formatPrice(line.lineCents)}</span>
|
||||||
</div>
|
</div>
|
||||||
${options}
|
${options}
|
||||||
<div class="order-panel__line-controls">
|
<button
|
||||||
<div class="order-panel__qty" role="group" aria-label="Quantite de ${escHtml(line.libelle)}">
|
class="order-panel__remove"
|
||||||
<button
|
data-index="${line.index}"
|
||||||
class="order-panel__qty-btn"
|
type="button"
|
||||||
data-action="dec"
|
aria-label="Retirer ${escHtml(line.libelle)} de la commande"
|
||||||
data-index="${line.index}"
|
>
|
||||||
type="button"
|
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="20" height="20">
|
||||||
aria-label="Diminuer la quantite de ${escHtml(line.libelle)}"
|
</button>
|
||||||
>−</button>
|
|
||||||
<span class="order-panel__qty-value">${line.quantite}</span>
|
|
||||||
<button
|
|
||||||
class="order-panel__qty-btn"
|
|
||||||
data-action="inc"
|
|
||||||
data-index="${line.index}"
|
|
||||||
type="button"
|
|
||||||
aria-label="Augmenter la quantite de ${escHtml(line.libelle)}"
|
|
||||||
>+</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="order-panel__remove"
|
|
||||||
data-index="${line.index}"
|
|
||||||
type="button"
|
|
||||||
aria-label="Retirer ${escHtml(line.libelle)} de la commande"
|
|
||||||
>
|
|
||||||
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="20" height="20">
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -185,19 +165,6 @@ export function renderOrderPanel(container) {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Stepper +/- : ajuste la quantite de la ligne. Decrementer a 0 retire la ligne
|
|
||||||
// (updateQuantity supprime quand qty <= 0). Couvre produits ET menus (un menu a
|
|
||||||
// quantite > 1 = N menus identiques, facture par quantite cote serveur).
|
|
||||||
container.querySelectorAll('.order-panel__qty-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const index = parseInt(btn.dataset.index, 10);
|
|
||||||
const cart = getCart();
|
|
||||||
const current = cart[index] ? cart[index].quantite : 0;
|
|
||||||
updateQuantity(index, btn.dataset.action === 'inc' ? current + 1 : current - 1);
|
|
||||||
renderOrderPanel(container);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
container.querySelectorAll('.order-panel__remove').forEach(btn => {
|
container.querySelectorAll('.order-panel__remove').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
removeFromCart(parseInt(btn.dataset.index, 10));
|
removeFromCart(parseInt(btn.dataset.index, 10));
|
||||||
|
|
@ -207,23 +174,14 @@ export function renderOrderPanel(container) {
|
||||||
|
|
||||||
const abandon = container.querySelector('.order-panel__abandon');
|
const abandon = container.querySelector('.order-panel__abandon');
|
||||||
if (abandon) {
|
if (abandon) {
|
||||||
// Geste destructeur (efface toute la commande) -> confirmation explicite
|
|
||||||
// avant d'agir, plutot qu'un effacement immediat au moindre tap.
|
|
||||||
abandon.addEventListener('click', () => {
|
abandon.addEventListener('click', () => {
|
||||||
confirmAction({
|
clearCart();
|
||||||
message: 'Abandonner toute la commande ? Votre selection sera perdue.',
|
window.location.href = 'index.html';
|
||||||
confirmLabel: 'Oui, abandonner',
|
|
||||||
cancelLabel: 'Continuer ma commande',
|
|
||||||
onConfirm: () => {
|
|
||||||
clearCart();
|
|
||||||
window.location.href = 'index.html';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payer desactive sur panier vide : un <a> ignore `disabled`, on bloque le clic
|
// Payer desactive sur panier vide : un <a> ignore `disabled`, on bloque le clic
|
||||||
// via aria-disabled (parade a11y, cf. fix E2E #45).
|
// via aria-disabled (meme parade que page-cart.js / le fix a11y E2E #45).
|
||||||
const pay = container.querySelector('.order-panel__pay');
|
const pay = container.querySelector('.order-panel__pay');
|
||||||
if (pay) {
|
if (pay) {
|
||||||
pay.addEventListener('click', e => {
|
pay.addEventListener('click', e => {
|
||||||
|
|
|
||||||
194
src/public/borne/assets/js/page-cart.js
Normal file
194
src/public/borne/assets/js/page-cart.js
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
/*
|
||||||
|
* page-cart.js — Shopping cart screen.
|
||||||
|
*
|
||||||
|
* Displays all cart lines with quantity controls and totals.
|
||||||
|
* Handles two item shapes:
|
||||||
|
* - Simple product: { id, type, libelle, prix_cents, quantite, image }
|
||||||
|
* - Composed menu: { ...above, composition: {...}, supplement_cents: number }
|
||||||
|
*
|
||||||
|
* Menu lines render a composition breakdown beneath the product name.
|
||||||
|
* Simple product lines render as before (no composition block).
|
||||||
|
*
|
||||||
|
* TVA: 10% (taux normal restauration, France 2024 — simplification MVP).
|
||||||
|
* TODO: verify exact applicable TVA rate with an accountant in P3.
|
||||||
|
* The real rate depends on sur-place vs a-emporter, alcohol content, etc.
|
||||||
|
*
|
||||||
|
* The total displayed is TTC (tax inclusive) because French consumer law
|
||||||
|
* requires prices shown to end-consumers to include all taxes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCart, removeFromCart, updateQuantity, getTotalCents, computeMenuLineCents, clearCart, formatPrice, escHtml } from './state.js';
|
||||||
|
import { refreshCartBadge } from './nav.js';
|
||||||
|
|
||||||
|
/* TVA rate used for display breakdown only — stored prices are already TTC */
|
||||||
|
const TVA_RATE = 0.10;
|
||||||
|
|
||||||
|
const cartList = document.getElementById('cart-list');
|
||||||
|
const emptyBlock = document.getElementById('cart-empty');
|
||||||
|
const summaryBlock= document.getElementById('cart-summary');
|
||||||
|
const totalTTC = document.getElementById('total-ttc');
|
||||||
|
const totalHT = document.getElementById('total-ht');
|
||||||
|
const totalTVA = document.getElementById('total-tva');
|
||||||
|
const payBtn = document.getElementById('pay-btn');
|
||||||
|
const abandonBtn = document.getElementById('abandon-btn');
|
||||||
|
|
||||||
|
function renderCart() {
|
||||||
|
const items = getCart();
|
||||||
|
refreshCartBadge();
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
cartList.innerHTML = '';
|
||||||
|
emptyBlock.hidden = false;
|
||||||
|
summaryBlock.hidden = true;
|
||||||
|
// pay-btn est un <a> : `.disabled` n'existe pas dessus, il faut piloter
|
||||||
|
// aria-disabled (sinon le bouton reste annonce desactive panier rempli).
|
||||||
|
if (payBtn) payBtn.setAttribute('aria-disabled', 'true');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyBlock.hidden = true;
|
||||||
|
summaryBlock.hidden = false;
|
||||||
|
if (payBtn) payBtn.setAttribute('aria-disabled', 'false');
|
||||||
|
|
||||||
|
cartList.innerHTML = '';
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const isMenu = item.type === 'menu';
|
||||||
|
const lineTotalCents = isMenu
|
||||||
|
? computeMenuLineCents(item)
|
||||||
|
: item.prix_cents * item.quantite;
|
||||||
|
|
||||||
|
const row = document.createElement('li');
|
||||||
|
row.className = 'cart-line';
|
||||||
|
row.setAttribute('aria-label', `${item.libelle}, quantite ${item.quantite}`);
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<img
|
||||||
|
class="cart-line__image"
|
||||||
|
src="${escHtml(item.image)}"
|
||||||
|
alt="${escHtml(item.libelle)}"
|
||||||
|
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
|
||||||
|
>
|
||||||
|
<div class="cart-line__info">
|
||||||
|
<span class="cart-line__name">${escHtml(item.libelle)}</span>
|
||||||
|
<span class="cart-line__unit-price">${formatPrice(item.prix_cents)} / unite${isMenu && (item.supplement_cents ?? 0) > 0 ? ` + ${formatPrice(item.supplement_cents)} suppl.` : ''}</span>
|
||||||
|
${isMenu && item.composition ? renderCompositionBlock(item) : ''}
|
||||||
|
</div>
|
||||||
|
<div class="cart-line__qty" role="group" aria-label="Quantite de ${escHtml(item.libelle)}">
|
||||||
|
<button
|
||||||
|
class="qty-btn qty-btn--minus"
|
||||||
|
data-index="${index}"
|
||||||
|
aria-label="Diminuer la quantite de ${escHtml(item.libelle)}"
|
||||||
|
type="button"
|
||||||
|
>-</button>
|
||||||
|
<span class="qty-value" aria-live="polite">${item.quantite}</span>
|
||||||
|
<button
|
||||||
|
class="qty-btn qty-btn--plus"
|
||||||
|
data-index="${index}"
|
||||||
|
aria-label="Augmenter la quantite de ${escHtml(item.libelle)}"
|
||||||
|
type="button"
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
|
<span class="cart-line__total">${formatPrice(lineTotalCents)}</span>
|
||||||
|
<button
|
||||||
|
class="cart-line__remove"
|
||||||
|
data-index="${index}"
|
||||||
|
aria-label="Supprimer ${escHtml(item.libelle)} du panier"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<img src="assets/images/ui/trash.png" alt="" aria-hidden="true" width="24" height="24">
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
cartList.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Attach event listeners after render */
|
||||||
|
cartList.querySelectorAll('.qty-btn--minus').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(btn.dataset.index, 10);
|
||||||
|
const cart = getCart();
|
||||||
|
updateQuantity(idx, cart[idx].quantite - 1);
|
||||||
|
renderCart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cartList.querySelectorAll('.qty-btn--plus').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(btn.dataset.index, 10);
|
||||||
|
const cart = getCart();
|
||||||
|
updateQuantity(idx, cart[idx].quantite + 1);
|
||||||
|
renderCart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cartList.querySelectorAll('.cart-line__remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(btn.dataset.index, 10);
|
||||||
|
removeFromCart(idx);
|
||||||
|
renderCart();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Update totals */
|
||||||
|
const ttcCents = getTotalCents();
|
||||||
|
/* Back-calculate HT from TTC (prices assumed to be TTC already) */
|
||||||
|
const htCents = Math.round(ttcCents / (1 + TVA_RATE));
|
||||||
|
const tvaCents = ttcCents - htCents;
|
||||||
|
|
||||||
|
if (totalTTC) totalTTC.textContent = formatPrice(ttcCents);
|
||||||
|
if (totalHT) totalHT.textContent = formatPrice(htCents);
|
||||||
|
if (totalTVA) totalTVA.textContent = formatPrice(tvaCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the composition breakdown HTML for a menu cart line.
|
||||||
|
* Renders burger (with personalisation options), accompagnement, boisson, sauce,
|
||||||
|
* and the supplement summary if applicable. Le format Maxi se lit dans le libelle de
|
||||||
|
* l'accompagnement (variante "Grande ...") et la ligne de supplement, pas un suffixe.
|
||||||
|
*
|
||||||
|
* @param {Object} item — cart item with type === 'menu' and composition object
|
||||||
|
* @returns {string} HTML string
|
||||||
|
*/
|
||||||
|
function renderCompositionBlock(item) {
|
||||||
|
const c = item.composition;
|
||||||
|
if (!c) return '';
|
||||||
|
|
||||||
|
// Tolerant aux champs absents : depuis L2 (composeur slot-driven), un menu peut
|
||||||
|
// ne pas avoir tous les slots (ex. pas de sauce) -> ne pas supposer leur presence.
|
||||||
|
const parts = [];
|
||||||
|
if (c.burger) {
|
||||||
|
const burgerOpts = c.burger.options && c.burger.options.length
|
||||||
|
? ` (${c.burger.options.map(o => o === 'sans-oignon' ? 'sans oignon' : 'avec fromage').join(', ')})`
|
||||||
|
: '';
|
||||||
|
parts.push(`${escHtml(c.burger.libelle)}${burgerOpts}`);
|
||||||
|
}
|
||||||
|
// libelle fait foi : en Maxi l'accompagnement porte deja sa variante par nom
|
||||||
|
// ("Grande Frite"). Plus de suffixe taille -- il doublait le nom ("Grande Frite
|
||||||
|
// grande") et "normale"/"grande" mentait pour la boisson (le Maxi ne l'agrandit pas).
|
||||||
|
if (c.accompagnement) {
|
||||||
|
parts.push(escHtml(c.accompagnement.libelle));
|
||||||
|
}
|
||||||
|
if (c.boisson) {
|
||||||
|
parts.push(escHtml(c.boisson.libelle));
|
||||||
|
}
|
||||||
|
if (c.sauce) {
|
||||||
|
parts.push(escHtml(c.sauce.libelle));
|
||||||
|
}
|
||||||
|
|
||||||
|
const supplTotal = item.supplement_cents ?? 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<ul class="cart-line__composition" aria-label="Composition du menu">
|
||||||
|
${parts.map(t => `<li class="cart-line__comp-item">+ ${t}</li>`).join('')}
|
||||||
|
${supplTotal > 0 ? `<li class="cart-line__comp-suppl">Format Maxi : +${formatPrice(supplTotal)}</li>` : ''}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abandonBtn) {
|
||||||
|
abandonBtn.addEventListener('click', () => {
|
||||||
|
clearCart();
|
||||||
|
window.location.href = 'categories.html';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', renderCart);
|
||||||
|
|
@ -181,7 +181,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
const items = getCart();
|
const items = getCart();
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
window.location.href = 'categories.html';
|
window.location.href = 'cart.html';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (recap) {
|
if (recap) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/*
|
/*
|
||||||
* page-product-menu.js — Composeur de menu PILOTE PAR LES SLOTS (P5 L2).
|
* page-product-menu.js — Composeur de menu PILOTE PAR LES SLOTS (P5 L2).
|
||||||
*
|
*
|
||||||
* Importe par page-products.js quand le produit clique est un menu (type === 'menu').
|
* Importe par page-product.js quand le produit charge est un menu (type === 'menu').
|
||||||
*
|
*
|
||||||
* Avant L2 : le composeur composait LIBREMENT a partir des categories (burgers,
|
* Avant L2 : le composeur composait LIBREMENT a partir des categories (burgers,
|
||||||
* frites, boissons, sauces) sans tenir compte du menu reel. Desormais il consomme
|
* frites, boissons, sauces) sans tenir compte du menu reel. Desormais il consomme
|
||||||
|
|
@ -12,9 +12,9 @@
|
||||||
* Etapes : Format (Normal/Maxi, burger impose affiche) -> 1 pas par slot (dans
|
* Etapes : Format (Normal/Maxi, burger impose affiche) -> 1 pas par slot (dans
|
||||||
* l'ordre display_order ; requis = choix obligatoire, optionnel = "sans") -> recap.
|
* l'ordre display_order ; requis = choix obligatoire, optionnel = "sans") -> recap.
|
||||||
*
|
*
|
||||||
* La forme de `composition` produite reste compatible avec order-panel.js (burger /
|
* La forme de `composition` produite reste compatible avec page-cart.js et
|
||||||
* accompagnement / boisson / sauce + taille), le slot_type mappant vers le bon champ ;
|
* order-panel.js (burger / accompagnement / boisson / sauce + taille), le slot_type
|
||||||
* Maxi pose taille 'G' + supplement = prix_maxi - prix_normal.
|
* mappant vers le bon champ ; Maxi pose taille 'G' + supplement = prix_maxi - prix_normal.
|
||||||
*
|
*
|
||||||
* A11y : role=dialog, aria-modal, focus-trap, ESC annule, focus au 1er interactif.
|
* A11y : role=dialog, aria-modal, focus-trap, ESC annule, focus au 1er interactif.
|
||||||
*/
|
*/
|
||||||
|
|
@ -121,11 +121,6 @@ export function buildMenuCartItem(menu, model, { size, selections }) {
|
||||||
quantite: 1,
|
quantite: 1,
|
||||||
image: menu.image,
|
image: menu.image,
|
||||||
supplement_cents: supplement,
|
supplement_cents: supplement,
|
||||||
// format PORTE le choix Normal/Maxi de l'utilisateur, transporte tel quel
|
|
||||||
// jusqu'au contrat API. Le serveur l'utilise pour le prix Maxi ET la
|
|
||||||
// substitution des variantes (accompagnement Grande, boisson 50 cl). A NE
|
|
||||||
// PAS re-deviner depuis supplement_cents (faux negatif si maxi == normal).
|
|
||||||
format: isMaxi ? 'maxi' : 'normal',
|
|
||||||
composition,
|
composition,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +150,7 @@ export function composerIsViable(model) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Entree publique — appelee par page-products.js */
|
/* Entree publique — appelee par page-product.js */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
142
src/public/borne/assets/js/page-product.js
Normal file
142
src/public/borne/assets/js/page-product.js
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
/*
|
||||||
|
* page-product.js — Product detail screen.
|
||||||
|
*
|
||||||
|
* Reads ?id=<int>&category=<slug> from the query string.
|
||||||
|
*
|
||||||
|
* Branch on product type:
|
||||||
|
* - type === 'menu' → open the multi-step composer modal (page-product-menu.js).
|
||||||
|
* The standard detail layout is bypassed because a menu
|
||||||
|
* cannot be added to the cart without composition choices.
|
||||||
|
* - type === 'produit' → render the standard detail card with "Ajouter au panier".
|
||||||
|
*
|
||||||
|
* After "Ajouter au panier" (simple product):
|
||||||
|
* 1. Item added to cart via state.addToCart()
|
||||||
|
* 2. Button changes to "Ajoute !" for 1 second (visual feedback)
|
||||||
|
* 3. Redirect to products.html?category=<slug>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { findProduct, loadAllergens } from './data.js';
|
||||||
|
import { addToCart, formatPrice, escHtml } from './state.js';
|
||||||
|
import { refreshCartBadge } from './nav.js';
|
||||||
|
import { openMenuComposer } from './page-product-menu.js';
|
||||||
|
import { openProductOptions, productSizes } from './product-options.js';
|
||||||
|
import { buildAllergenInfoButton, openAllergenModal } from './allergens.js';
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const productId = parseInt(params.get('id'), 10);
|
||||||
|
const categorySlug = params.get('category') ?? 'menus';
|
||||||
|
|
||||||
|
const container = document.getElementById('product-detail');
|
||||||
|
const errorBlock = document.getElementById('product-error');
|
||||||
|
const backBtn = document.getElementById('back-to-products');
|
||||||
|
|
||||||
|
if (backBtn) {
|
||||||
|
backBtn.href = `products.html?category=${categorySlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderProduct() {
|
||||||
|
if (!productId) {
|
||||||
|
showError('Produit introuvable.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const product = await findProduct(productId, categorySlug);
|
||||||
|
if (!product) {
|
||||||
|
showError('Ce produit n\'existe pas.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.title = `Wakdo - ${product.nom}`;
|
||||||
|
|
||||||
|
if (product.type === 'menu') {
|
||||||
|
/* Hide the standard product detail area; the composer will overlay the page.
|
||||||
|
* The container stays in the DOM so the skeleton does not flash. */
|
||||||
|
container.hidden = true;
|
||||||
|
await openMenuComposer(product, categorySlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Produit a tailles multiples (R4, ex. boisson 30/50 cl) : on delegue a la
|
||||||
|
* modale d'options (meme picker que la grille) plutot que de dupliquer la
|
||||||
|
* selection de taille dans la fiche -> un seul chemin pour choisir la taille. */
|
||||||
|
if (productSizes(product).length) {
|
||||||
|
container.hidden = true;
|
||||||
|
openProductOptions(product, categorySlug);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="product-detail__image-wrap">
|
||||||
|
<img
|
||||||
|
class="product-detail__image"
|
||||||
|
src="${escHtml(product.image)}"
|
||||||
|
alt="${escHtml(product.nom)}"
|
||||||
|
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="product-detail__info">
|
||||||
|
<h1 class="product-detail__name">${escHtml(product.nom)}</h1>
|
||||||
|
<p class="product-detail__price">${formatPrice(product.prix)}</p>
|
||||||
|
<button
|
||||||
|
class="btn btn--primary btn--large product-detail__add"
|
||||||
|
id="add-to-cart-btn"
|
||||||
|
aria-label="Ajouter ${escHtml(product.nom)} au panier"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Ajouter au panier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Bouton "i" allergenes (modale generale) dans le bloc info de la fiche.
|
||||||
|
// Echec de chargement non bloquant : la fiche reste fonctionnelle.
|
||||||
|
try {
|
||||||
|
const allergens = await loadAllergens();
|
||||||
|
const info = container.querySelector('.product-detail__info');
|
||||||
|
if (info) {
|
||||||
|
info.appendChild(buildAllergenInfoButton(() => openAllergenModal(allergens)));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadAllergens error:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('add-to-cart-btn').addEventListener('click', () => {
|
||||||
|
addToCart({
|
||||||
|
id: product.id,
|
||||||
|
type: product.type,
|
||||||
|
categorie: product.categorie ?? categorySlug,
|
||||||
|
libelle: product.nom,
|
||||||
|
prix_cents: product.prix,
|
||||||
|
quantite: 1,
|
||||||
|
image: product.image
|
||||||
|
});
|
||||||
|
refreshCartBadge();
|
||||||
|
|
||||||
|
const btn = document.getElementById('add-to-cart-btn');
|
||||||
|
btn.textContent = 'Ajoute !';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
/* Redirect after brief confirmation pause */
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `products.html?category=${categorySlug}`;
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError('Erreur lors du chargement du produit.');
|
||||||
|
console.error('renderProduct error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
if (errorBlock) {
|
||||||
|
errorBlock.hidden = false;
|
||||||
|
errorBlock.textContent = msg;
|
||||||
|
}
|
||||||
|
if (container) {
|
||||||
|
container.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', renderProduct);
|
||||||
|
|
@ -3,8 +3,7 @@
|
||||||
*
|
*
|
||||||
* Reads ?category=<id> from the query string, maps to a slug via
|
* Reads ?category=<id> from the query string, maps to a slug via
|
||||||
* CATEGORY_ID_TO_SLUG, then fetches the matching product array.
|
* CATEGORY_ID_TO_SLUG, then fetches the matching product array.
|
||||||
* On product card click, opens an in-page modal (composer for a menu, options
|
* On product card click, navigates to product.html?id=<id>&category=<slug>.
|
||||||
* for a simple product) above the grid ; the order panel reflects the addition.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG, loadAllergens } from './data.js';
|
import { getProductsByCategory, getCategoryById, CATEGORY_ID_TO_SLUG, loadAllergens } from './data.js';
|
||||||
|
|
@ -64,16 +63,10 @@ async function renderProducts() {
|
||||||
|
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
products.forEach(product => {
|
products.forEach(product => {
|
||||||
// commandable : false = rupture de stock (RG-T21). La tuile reste visible
|
|
||||||
// (le client voit le produit de la carte) mais grisee et non cliquable.
|
|
||||||
const orderable = product.commandable !== false;
|
|
||||||
const card = document.createElement('a');
|
const card = document.createElement('a');
|
||||||
card.className = orderable ? 'product-card' : 'product-card product-card--unavailable';
|
card.className = 'product-card';
|
||||||
// Le <a> reste pour le focus/clavier (a11y) ; href='#' inerte, le handler
|
card.href = `product.html?id=${product.id}&category=${categorySlug}`;
|
||||||
// click ci-dessous fait foi (preventDefault + ouverture de la modale).
|
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}`);
|
||||||
card.href = '#';
|
|
||||||
card.setAttribute('aria-label', `${product.nom} - ${formatPrice(product.prix)}${orderable ? '' : ' - indisponible'}`);
|
|
||||||
if (!orderable) card.setAttribute('aria-disabled', 'true');
|
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="product-card__image-wrap">
|
<div class="product-card__image-wrap">
|
||||||
|
|
@ -84,7 +77,6 @@ async function renderProducts() {
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
|
onerror="this.src='assets/images/ui/logo.png'; this.alt='Image non disponible';"
|
||||||
>
|
>
|
||||||
${orderable ? '' : '<span class="product-card__badge">Indisponible</span>'}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="product-card__body">
|
<div class="product-card__body">
|
||||||
<span class="product-card__name">${escHtml(product.nom)}</span>
|
<span class="product-card__name">${escHtml(product.nom)}</span>
|
||||||
|
|
@ -97,13 +89,11 @@ async function renderProducts() {
|
||||||
const infoBtn = buildAllergenInfoButton(() => openAllergenModal(allergens));
|
const infoBtn = buildAllergenInfoButton(() => openAllergenModal(allergens));
|
||||||
card.querySelector('.product-card__image-wrap').appendChild(infoBtn);
|
card.querySelector('.product-card__image-wrap').appendChild(infoBtn);
|
||||||
|
|
||||||
// Clic produit -> modale au-dessus de la grille (paradigme maquette) :
|
// Clic produit -> modale au-dessus de la grille (paradigme maquette) au lieu
|
||||||
// menu -> composeur (L2), produit -> options (L3). Le panneau de droite est
|
// de naviguer vers product.html : menu -> composeur (L2), produit -> options
|
||||||
// l'unique vue panier ; pas de navigation au clic. Une tuile en rupture ne
|
// (L3). Le <a href> reste un repli (lien direct / sans JS).
|
||||||
// fait rien (ni navigation ni modale).
|
|
||||||
card.addEventListener('click', (e) => {
|
card.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!orderable) return;
|
|
||||||
if (product.type === 'menu') openMenuComposer(product, categorySlug);
|
if (product.type === 'menu') openMenuComposer(product, categorySlug);
|
||||||
else openProductOptions(product, categorySlug);
|
else openProductOptions(product, categorySlug);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
/*
|
/*
|
||||||
* product-options.js — Modale d'options produit (P5 L3, taille R4).
|
* product-options.js — Modale d'options produit (P5 L3, taille R4).
|
||||||
*
|
*
|
||||||
* Ouvre une modale d'options au clic produit, au lieu d'une navigation : cliquer un
|
* Remplace la navigation vers product.html : cliquer un produit simple ouvre une
|
||||||
* produit simple ouvre une modale (image, prix unitaire, stepper de quantite, total)
|
* modale (image, prix unitaire, stepper de quantite, total) au-dessus de la grille,
|
||||||
* au-dessus de la grille, facon maquette ("Une petite soif ?"). A l'ajout, le panneau
|
* facon maquette ("Une petite soif ?"). A l'ajout, le panneau de commande persistant
|
||||||
* de commande persistant (L1) est re-rendu pour refleter immediatement la commande.
|
* (L1) est re-rendu pour refleter immediatement la commande -> pas de navigation.
|
||||||
*
|
*
|
||||||
* Taille (R4) : la dimension 30/50 cl de la maquette existe desormais en base sous
|
* Taille (R4) : la dimension 30/50 cl de la maquette existe desormais en base sous
|
||||||
* forme de LIGNES produit distinctes (product.sizes : [{product_id, size_cl,
|
* forme de LIGNES produit distinctes (product.sizes : [{product_id, size_cl,
|
||||||
|
|
|
||||||
102
src/public/borne/cart.html
Normal file
102
src/public/borne/cart.html
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="description" content="Wakdo - Votre panier">
|
||||||
|
<title>Wakdo - Panier</title>
|
||||||
|
<link rel="canonical" href="https://corentin-wakdo.stark.a3n.fr/cart.html">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="assets/images/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="cart-page">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
cart.html — Shopping cart.
|
||||||
|
page-cart.js renders all cart lines, handles qty controls and removal.
|
||||||
|
|
||||||
|
TVA: 10% (restauration France 2024 — simplified rate).
|
||||||
|
TODO: verify exact rate with accountant in P3 — actual rate depends
|
||||||
|
on sur-place vs a-emporter and product type (alcohol, etc.).
|
||||||
|
|
||||||
|
The stored prices are TTC. HT is back-calculated at display time only.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<a
|
||||||
|
class="site-header__back"
|
||||||
|
href="categories.html"
|
||||||
|
aria-label="Continuer mes achats"
|
||||||
|
>
|
||||||
|
← Continuer
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
class="site-header__logo"
|
||||||
|
src="assets/images/ui/logo.png"
|
||||||
|
alt="Wakdo"
|
||||||
|
>
|
||||||
|
<span class="mode-badge site-header__mode" data-mode-badge aria-label="Mode de consommation">Sur place</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="cart-main" aria-label="Votre panier">
|
||||||
|
|
||||||
|
<h1 class="cart-main__heading">Votre panier</h1>
|
||||||
|
|
||||||
|
<!-- Empty cart state -->
|
||||||
|
<div id="cart-empty" class="cart-empty" hidden>
|
||||||
|
<p class="cart-empty__message">Votre panier est vide.</p>
|
||||||
|
<a class="btn btn--secondary" href="categories.html">Decouvrir nos produits</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cart lines -->
|
||||||
|
<ul id="cart-list" class="cart-list" aria-label="Lignes du panier">
|
||||||
|
<!-- Filled by page-cart.js -->
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Order summary -->
|
||||||
|
<aside id="cart-summary" class="cart-summary" hidden aria-label="Recapitulatif de commande">
|
||||||
|
<div class="cart-summary__line">
|
||||||
|
<span>Total HT</span>
|
||||||
|
<span id="total-ht">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="cart-summary__line">
|
||||||
|
<!-- TVA 10% — taux restauration FR 2024 (simplifie, voir commentaire ci-dessus) -->
|
||||||
|
<span>TVA (10%)</span>
|
||||||
|
<span id="total-tva">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="cart-summary__line cart-summary__line--total">
|
||||||
|
<span>Total TTC</span>
|
||||||
|
<strong id="total-ttc">—</strong>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="cart-actions">
|
||||||
|
<button
|
||||||
|
id="abandon-btn"
|
||||||
|
class="btn btn--secondary"
|
||||||
|
type="button"
|
||||||
|
aria-label="Abandonner la commande et retourner aux categories"
|
||||||
|
>
|
||||||
|
Abandonner
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
id="pay-btn"
|
||||||
|
class="btn btn--primary"
|
||||||
|
href="payment.html"
|
||||||
|
role="button"
|
||||||
|
aria-label="Passer au paiement"
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
Valider ma commande
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="assets/js/nav.js"></script>
|
||||||
|
<script type="module" src="assets/js/page-cart.js"></script>
|
||||||
|
<script type="module" src="assets/js/a11y.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -13,11 +13,11 @@
|
||||||
<body class="categories-page">
|
<body class="categories-page">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Categories screen — static scaffold (9 categories) listed in catalogue order.
|
Categories screen.
|
||||||
|
Data source: docs/merise/_sources/categories.json (9 categories).
|
||||||
Image paths: assets/images/categories/{title}.png — verified against filesystem.
|
Image paths: assets/images/categories/{title}.png — verified against filesystem.
|
||||||
The cards link to products.html?category=<id>; the product/menu/allergen data
|
In P4 this page will be generated dynamically from GET /api/categories.
|
||||||
is fetched from the REST API by data.js. Generating this list from
|
For now it is a static scaffold that matches the data contract exactly.
|
||||||
GET /api/categories is a later UI alignment step.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
|
|
@ -41,9 +41,10 @@
|
||||||
<p class="categories-main__sub">Choisissez une categorie pour decouvrir nos produits</p>
|
<p class="categories-main__sub">Choisissez une categorie pour decouvrir nos produits</p>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
9 categories in catalogue order. Each card links to a product page
|
9 categories from categories.json, in the same order as the source.
|
||||||
(products.html?category=<id>). The link is functional HTML; no JS needed.
|
Each card links to a product page (products.html?category=<id>) — stub URL
|
||||||
The category title is used as alt text and visible label.
|
for future P5 implementation. The link is functional HTML; no JS needed.
|
||||||
|
title field from JSON used as alt text and visible label.
|
||||||
-->
|
-->
|
||||||
<nav class="category-grid" aria-label="Navigation par categorie">
|
<nav class="category-grid" aria-label="Navigation par categorie">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
# Donnees de la borne
|
# Donnees statiques de la borne (repli P5)
|
||||||
|
|
||||||
La borne consomme l'API REST en lecture : `/api/categories`, `/api/products`,
|
`categories.json` et `produits.json` sont un **repli statique fige** consomme par
|
||||||
`/api/menus` et `/api/allergens` (cf. `docs/api/conventions.md` section 5.2). La
|
le front de la borne (Bloc 1 / P5) tant que l'API REST n'existe pas. Ils sont
|
||||||
couche `assets/js/data.js` deballe l'enveloppe `{ data }` et traduit la forme
|
copies du jeu de donnees source de l'ecole (`docs/merise/_sources/`), **pas**
|
||||||
canonique vers la forme attendue par les pages.
|
generes depuis la base.
|
||||||
|
|
||||||
Les anciens fichiers JSON statiques (`categories.json`, `produits.json`,
|
## Ces fichiers ne refletent pas la base
|
||||||
`allergens.json`) qui servaient de repli avant l'API ont ete retires : la borne
|
|
||||||
reflete la base via l'API.
|
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).
|
||||||
|
|
|
||||||
16
src/public/borne/data/allergens.json
Normal file
16
src/public/borne/data/allergens.json
Normal file
|
|
@ -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." }
|
||||||
|
]
|
||||||
11
src/public/borne/data/categories.json
Normal file
11
src/public/borne/data/categories.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[
|
||||||
|
{ "id": 1, "title": "menus", "slug": "menus", "image": "assets/images/categories/menus.png" },
|
||||||
|
{ "id": 2, "title": "boissons", "slug": "boissons", "image": "assets/images/categories/boissons.png" },
|
||||||
|
{ "id": 3, "title": "burgers", "slug": "burgers", "image": "assets/images/categories/burgers.png" },
|
||||||
|
{ "id": 4, "title": "frites", "slug": "frites", "image": "assets/images/categories/frites.png" },
|
||||||
|
{ "id": 5, "title": "encas", "slug": "encas", "image": "assets/images/categories/encas.png" },
|
||||||
|
{ "id": 6, "title": "wraps", "slug": "wraps", "image": "assets/images/categories/wraps.png" },
|
||||||
|
{ "id": 7, "title": "salades", "slug": "salades", "image": "assets/images/categories/salades.png" },
|
||||||
|
{ "id": 8, "title": "desserts", "slug": "desserts", "image": "assets/images/categories/desserts.png" },
|
||||||
|
{ "id": 9, "title": "sauces", "slug": "sauces", "image": "assets/images/categories/sauces.png" }
|
||||||
|
]
|
||||||
86
src/public/borne/data/produits.json
Normal file
86
src/public/borne/data/produits.json
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
{
|
||||||
|
"menus": [
|
||||||
|
{ "id": 1, "nom": "Menu Le 280", "prix": 880, "image": "assets/images/produits/burgers/280.png", "type": "menu" },
|
||||||
|
{ "id": 2, "nom": "Menu Big Tasty", "prix": 1060, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "menu" },
|
||||||
|
{ "id": 3, "nom": "Menu Big Tasty Bacon", "prix": 1090, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "menu" },
|
||||||
|
{ "id": 4, "nom": "Menu Big Mac", "prix": 800, "image": "assets/images/produits/burgers/bigmac.png", "type": "menu" },
|
||||||
|
{ "id": 5, "nom": "Menu CBO", "prix": 1090, "image": "assets/images/produits/burgers/cbo.png", "type": "menu" },
|
||||||
|
{ "id": 6, "nom": "Menu MC Chicken", "prix": 930, "image": "assets/images/produits/burgers/mcchicken.png", "type": "menu" },
|
||||||
|
{ "id": 7, "nom": "Menu MC Crispy", "prix": 720, "image": "assets/images/produits/burgers/mccrispy.png", "type": "menu" },
|
||||||
|
{ "id": 8, "nom": "Menu MC Fish", "prix": 720, "image": "assets/images/produits/burgers/mcfish.png", "type": "menu" },
|
||||||
|
{ "id": 9, "nom": "Menu Royal Bacon", "prix": 705, "image": "assets/images/produits/burgers/royalbacon.png", "type": "menu" },
|
||||||
|
{ "id": 10, "nom": "Menu Royal Cheese", "prix": 640, "image": "assets/images/produits/burgers/royalcheese.png", "type": "menu" },
|
||||||
|
{ "id": 11, "nom": "Menu Royal Deluxe", "prix": 740, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "menu" },
|
||||||
|
{ "id": 12, "nom": "Menu Signature BBQ Beef 2 viandes","prix": 1350,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png", "type": "menu" },
|
||||||
|
{ "id": 13, "nom": "Menu Signature Beef BBQ", "prix": 1190, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png", "type": "menu" }
|
||||||
|
],
|
||||||
|
"burgers": [
|
||||||
|
{ "id": 14, "nom": "Le 280", "prix": 680, "image": "assets/images/produits/burgers/280.png", "type": "produit" },
|
||||||
|
{ "id": 15, "nom": "Big Tasty", "prix": 860, "image": "assets/images/produits/burgers/big-tasty-1-viande.png", "type": "produit" },
|
||||||
|
{ "id": 16, "nom": "Big Tasty Bacon", "prix": 890, "image": "assets/images/produits/burgers/big-tasty-bacon-1-viande.png", "type": "produit" },
|
||||||
|
{ "id": 17, "nom": "Big Mac", "prix": 600, "image": "assets/images/produits/burgers/bigmac.png", "type": "produit" },
|
||||||
|
{ "id": 18, "nom": "CBO", "prix": 890, "image": "assets/images/produits/burgers/cbo.png", "type": "produit" },
|
||||||
|
{ "id": 19, "nom": "MC Chicken", "prix": 730, "image": "assets/images/produits/burgers/mcchicken.png", "type": "produit" },
|
||||||
|
{ "id": 20, "nom": "MC Crispy", "prix": 530, "image": "assets/images/produits/burgers/mccrispy.png", "type": "produit" },
|
||||||
|
{ "id": 21, "nom": "MC Fish", "prix": 485, "image": "assets/images/produits/burgers/mcfish.png", "type": "produit" },
|
||||||
|
{ "id": 22, "nom": "Royal Bacon", "prix": 510, "image": "assets/images/produits/burgers/royalbacon.png", "type": "produit" },
|
||||||
|
{ "id": 23, "nom": "Royal Cheese", "prix": 440, "image": "assets/images/produits/burgers/royalcheese.png", "type": "produit" },
|
||||||
|
{ "id": 24, "nom": "Royal Deluxe", "prix": 540, "image": "assets/images/produits/burgers/royaldeluxe.png", "type": "produit" },
|
||||||
|
{ "id": 25, "nom": "Signature BBQ Beef 2 viandes","prix": 1140,"image": "assets/images/produits/burgers/signature-bbq-beef-2-viandes.png","type": "produit" },
|
||||||
|
{ "id": 26, "nom": "Signature Beef BBQ", "prix": 1030, "image": "assets/images/produits/burgers/signature-beef-bbq-burger-1-viande.png","type": "produit" }
|
||||||
|
],
|
||||||
|
"boissons": [
|
||||||
|
{ "id": 27, "nom": "Coca Cola", "prix": 190, "image": "assets/images/produits/boissons/coca-cola.png", "type": "produit" },
|
||||||
|
{ "id": 28, "nom": "Coca Sans Sucres", "prix": 190, "image": "assets/images/produits/boissons/coca-sans-sucres.png", "type": "produit" },
|
||||||
|
{ "id": 29, "nom": "Eau", "prix": 100, "image": "assets/images/produits/boissons/eau.png", "type": "produit" },
|
||||||
|
{ "id": 30, "nom": "Fanta Orange", "prix": 190, "image": "assets/images/produits/boissons/fanta.png", "type": "produit" },
|
||||||
|
{ "id": 31, "nom": "Ice Tea Peche", "prix": 190, "image": "assets/images/produits/boissons/ice-tea-peche.png", "type": "produit" },
|
||||||
|
{ "id": 32, "nom": "Ice Tea Citron", "prix": 190, "image": "assets/images/produits/boissons/the-vert-citron-sans-sucres.png", "type": "produit" },
|
||||||
|
{ "id": 33, "nom": "Jus d'Orange", "prix": 210, "image": "assets/images/produits/boissons/jus-orange.png", "type": "produit" },
|
||||||
|
{ "id": 34, "nom": "Jus de Pommes Bio", "prix": 230, "image": "assets/images/produits/boissons/jus-pomme-bio.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"frites": [
|
||||||
|
{ "id": 35, "nom": "Petite Frite", "prix": 145, "image": "assets/images/produits/frites/petite-frite.png", "type": "produit" },
|
||||||
|
{ "id": 36, "nom": "Moyenne Frite", "prix": 275, "image": "assets/images/produits/frites/moyenne-frite.png", "type": "produit" },
|
||||||
|
{ "id": 37, "nom": "Grande Frite", "prix": 350, "image": "assets/images/produits/frites/grande-frite.png", "type": "produit" },
|
||||||
|
{ "id": 38, "nom": "Potatoes", "prix": 215, "image": "assets/images/produits/frites/potatoes.png", "type": "produit" },
|
||||||
|
{ "id": 39, "nom": "Grande Potatoes", "prix": 340, "image": "assets/images/produits/frites/grande-potatoes.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"encas": [
|
||||||
|
{ "id": 40, "nom": "Cheeseburger", "prix": 260, "image": "assets/images/produits/encas/cheeseburger.png", "type": "produit" },
|
||||||
|
{ "id": 41, "nom": "Croc MCdo", "prix": 320, "image": "assets/images/produits/encas/croc-mc-do.png", "type": "produit" },
|
||||||
|
{ "id": 42, "nom": "Nuggets x4", "prix": 420, "image": "assets/images/produits/encas/nuggets-4.png", "type": "produit" },
|
||||||
|
{ "id": 43, "nom": "Nuggets x20", "prix": 1300, "image": "assets/images/produits/encas/nuggets-20.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"desserts": [
|
||||||
|
{ "id": 44, "nom": "Brownie", "prix": 260, "image": "assets/images/produits/desserts/brownies.png", "type": "produit" },
|
||||||
|
{ "id": 45, "nom": "Cheesecake Chocolat M&M's","prix": 310, "image": "assets/images/produits/desserts/cheesecake-choconuts-m&m-s.png", "type": "produit" },
|
||||||
|
{ "id": 46, "nom": "Cheesecake Fraise", "prix": 310, "image": "assets/images/produits/desserts/cheesecake-fraise.png", "type": "produit" },
|
||||||
|
{ "id": 47, "nom": "Cookie", "prix": 320, "image": "assets/images/produits/desserts/cookie.png", "type": "produit" },
|
||||||
|
{ "id": 48, "nom": "Donut", "prix": 260, "image": "assets/images/produits/desserts/doghnut.png", "type": "produit" },
|
||||||
|
{ "id": 49, "nom": "Macarons", "prix": 270, "image": "assets/images/produits/desserts/macarons.png", "type": "produit" },
|
||||||
|
{ "id": 50, "nom": "MC Fleury", "prix": 440, "image": "assets/images/produits/desserts/mcfleury.png", "type": "produit" },
|
||||||
|
{ "id": 51, "nom": "Muffin", "prix": 360, "image": "assets/images/produits/desserts/muffin.png", "type": "produit" },
|
||||||
|
{ "id": 52, "nom": "Sunday", "prix": 100, "image": "assets/images/produits/desserts/sunday.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"sauces": [
|
||||||
|
{ "id": 53, "nom": "Classic Barbecue", "prix": 70, "image": "assets/images/produits/sauces/classic-barbecue.png", "type": "produit" },
|
||||||
|
{ "id": 54, "nom": "Classic Moutarde", "prix": 70, "image": "assets/images/produits/sauces/classic-moutarde.png", "type": "produit" },
|
||||||
|
{ "id": 55, "nom": "Creamy Deluxe", "prix": 70, "image": "assets/images/produits/sauces/cremy-deluxe.png", "type": "produit" },
|
||||||
|
{ "id": 56, "nom": "Ketchup", "prix": 70, "image": "assets/images/produits/sauces/ketchup.png", "type": "produit" },
|
||||||
|
{ "id": 57, "nom": "Chinoise", "prix": 70, "image": "assets/images/produits/sauces/sauce-chinoise.png", "type": "produit" },
|
||||||
|
{ "id": 58, "nom": "Curry", "prix": 70, "image": "assets/images/produits/sauces/sauce-curry.png", "type": "produit" },
|
||||||
|
{ "id": 59, "nom": "Pommes Frites", "prix": 70, "image": "assets/images/produits/sauces/sauce-pommes-frite.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"salades": [
|
||||||
|
{ "id": 60, "nom": "Petite Salade", "prix": 330, "image": "assets/images/produits/salades/petite-salade.png", "type": "produit" },
|
||||||
|
{ "id": 61, "nom": "Cesar Classic", "prix": 880, "image": "assets/images/produits/salades/salade-classic-caesar.png","type": "produit" },
|
||||||
|
{ "id": 62, "nom": "Italienne Mozza", "prix": 880, "image": "assets/images/produits/salades/salade-italian-mozza.png", "type": "produit" }
|
||||||
|
],
|
||||||
|
"wraps": [
|
||||||
|
{ "id": 63, "nom": "MC Wrap Chevre", "prix": 310, "image": "assets/images/produits/wraps/mcwrap-chevre.png", "type": "produit" },
|
||||||
|
{ "id": 64, "nom": "MC Wrap Poulet Bacon", "prix": 330, "image": "assets/images/produits/wraps/mcwrap-poulet-bacon.png","type": "produit" },
|
||||||
|
{ "id": 65, "nom": "Ptit Wrap Chevre", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-chevre.png", "type": "produit" },
|
||||||
|
{ "id": 66, "nom": "Ptit Wrap Ranch", "prix": 260, "image": "assets/images/produits/wraps/ptit-wrap-ranch.png", "type": "produit" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -21,10 +21,10 @@
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<a
|
<a
|
||||||
class="site-header__back"
|
class="site-header__back"
|
||||||
href="categories.html"
|
href="cart.html"
|
||||||
aria-label="Retour aux categories"
|
aria-label="Retour au panier"
|
||||||
>
|
>
|
||||||
← Retour
|
← Panier
|
||||||
</a>
|
</a>
|
||||||
<img
|
<img
|
||||||
class="site-header__logo"
|
class="site-header__logo"
|
||||||
|
|
|
||||||
73
src/public/borne/product.html
Normal file
73
src/public/borne/product.html
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<meta name="description" content="Wakdo - Detail du produit">
|
||||||
|
<title>Wakdo - Produit</title>
|
||||||
|
<link rel="canonical" href="https://corentin-wakdo.stark.a3n.fr/product.html">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="assets/images/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="product-page">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
product.html — Product detail screen.
|
||||||
|
Reads ?id=<int>&category=<slug>.
|
||||||
|
JS (page-product.js) fetches the product, renders detail and handles
|
||||||
|
the "Ajouter au panier" action.
|
||||||
|
Menu composition is shown as a fixed note (MVP: no composition selection).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<a
|
||||||
|
id="back-to-products"
|
||||||
|
class="site-header__back"
|
||||||
|
href="products.html"
|
||||||
|
aria-label="Retour a la liste des produits"
|
||||||
|
>
|
||||||
|
← Retour
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
class="site-header__logo"
|
||||||
|
src="assets/images/ui/logo.png"
|
||||||
|
alt="Wakdo"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="site-header__cart"
|
||||||
|
href="cart.html"
|
||||||
|
aria-label="Voir le panier"
|
||||||
|
>
|
||||||
|
<span class="cart-icon" aria-hidden="true">🛒</span>
|
||||||
|
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
|
||||||
|
<span class="sr-only">Panier</span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="order-layout">
|
||||||
|
<main class="product-main" aria-label="Detail du produit">
|
||||||
|
|
||||||
|
<!-- Error block: hidden unless fetch fails or id invalid -->
|
||||||
|
<p id="product-error" class="product-error" hidden role="alert"></p>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Container filled by page-product.js.
|
||||||
|
The JS replaces innerHTML once data is ready.
|
||||||
|
-->
|
||||||
|
<div id="product-detail" class="product-detail" aria-live="polite">
|
||||||
|
<!-- Skeleton placeholder visible during fetch -->
|
||||||
|
<div class="product-detail__skeleton" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="order-panel" data-order-panel aria-label="Ma commande" aria-live="polite"></aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="assets/js/nav.js"></script>
|
||||||
|
<script type="module" src="assets/js/page-product.js"></script>
|
||||||
|
<script type="module" src="assets/js/order-panel.js"></script>
|
||||||
|
<script type="module" src="assets/js/a11y.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
<!--
|
<!--
|
||||||
products.html — List of products in a category.
|
products.html — List of products in a category.
|
||||||
Category is determined at runtime from ?category=<id>.
|
Category is determined at runtime from ?category=<id>.
|
||||||
JS (page-products.js) reads the catalogue via data.js, which fetches
|
JS (page-products.js) fetches data/produits.json and renders cards.
|
||||||
GET /api/products (and /api/categories, /api/menus), then renders cards.
|
In P4: swap fetch URL in data.js to point to GET /api/products?category=<slug>.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
|
|
@ -33,6 +33,15 @@
|
||||||
src="assets/images/ui/logo.png"
|
src="assets/images/ui/logo.png"
|
||||||
alt="Wakdo"
|
alt="Wakdo"
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
class="site-header__cart"
|
||||||
|
href="cart.html"
|
||||||
|
aria-label="Voir le panier"
|
||||||
|
>
|
||||||
|
<span class="cart-icon" aria-hidden="true">🛒</span>
|
||||||
|
<span class="cart-badge" data-cart-count hidden aria-live="polite">0</span>
|
||||||
|
<span class="sr-only">Panier</span>
|
||||||
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="order-layout">
|
<div class="order-layout">
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,7 @@ use App\Core\Database;
|
||||||
/**
|
/**
|
||||||
* AllergenRepository contre une vraie MariaDB (schema migre + seed reference).
|
* AllergenRepository contre une vraie MariaDB (schema migre + seed reference).
|
||||||
* Auto-skip si WAKDO_DB_TESTS != 1. Lecture seule (donnees de reference) : aucun
|
* Auto-skip si WAKDO_DB_TESTS != 1. Lecture seule (donnees de reference) : aucun
|
||||||
* fixture/teardown. Verifie que les 14 allergenes INCO sont references avec
|
* fixture/teardown. Verifie que les 14 allergenes INCO sont references avec code+name.
|
||||||
* code + name + description.
|
|
||||||
*/
|
*/
|
||||||
final class AllergenReadDbTest extends TestCase
|
final class AllergenReadDbTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -35,7 +34,7 @@ final class AllergenReadDbTest extends TestCase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testListsIncoReferenceWithCodeNameAndDescription(): void
|
public function testListsIncoReferenceWithCodeAndName(): void
|
||||||
{
|
{
|
||||||
$rows = (new AllergenRepository($this->db))->all();
|
$rows = (new AllergenRepository($this->db))->all();
|
||||||
|
|
||||||
|
|
@ -43,11 +42,7 @@ final class AllergenReadDbTest extends TestCase
|
||||||
foreach ($rows as $a) {
|
foreach ($rows as $a) {
|
||||||
self::assertArrayHasKey('code', $a);
|
self::assertArrayHasKey('code', $a);
|
||||||
self::assertArrayHasKey('name', $a);
|
self::assertArrayHasKey('name', $a);
|
||||||
self::assertArrayHasKey('description', $a);
|
|
||||||
self::assertNotSame('', (string) ($a['name'] ?? ''));
|
self::assertNotSame('', (string) ($a['name'] ?? ''));
|
||||||
}
|
}
|
||||||
// La description INCO est seede (migration 0001 + seed 0001) : au moins une non vide.
|
|
||||||
$descriptions = array_filter($rows, static fn (array $a): bool => (string) ($a['description'] ?? '') !== '');
|
|
||||||
self::assertNotEmpty($descriptions, 'la description INCO doit etre exposee');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ final class CatalogueReadDbTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{category_id: int, name: string, description: ?string, price_cents: int, size_cl: ?int, base_product_id: ?int, maxi_variant_product_id: ?int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}
|
* @return array{category_id: int, name: string, description: ?string, price_cents: int, vat_rate: int, image_path: ?string, is_available: int, display_order: int}
|
||||||
*/
|
*/
|
||||||
private function productData(string $name, int $categoryId, int $available): array
|
private function productData(string $name, int $categoryId, int $available): array
|
||||||
{
|
{
|
||||||
|
|
@ -208,9 +208,6 @@ final class CatalogueReadDbTest extends TestCase
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'price_cents' => 500,
|
'price_cents' => 500,
|
||||||
'size_cl' => null,
|
|
||||||
'base_product_id' => null,
|
|
||||||
'maxi_variant_product_id' => null,
|
|
||||||
'vat_rate' => 100,
|
'vat_rate' => 100,
|
||||||
'image_path' => null,
|
'image_path' => null,
|
||||||
'is_available' => $available,
|
'is_available' => $available,
|
||||||
|
|
|
||||||
|
|
@ -100,66 +100,4 @@ final class OrderQueryRepositoryDbTest extends TestCase
|
||||||
$repo = new OrderQueryRepository($this->db);
|
$repo = new OrderQueryRepository($this->db);
|
||||||
self::assertLessThanOrEqual(3, count($repo->recent(3)));
|
self::assertLessThanOrEqual(3, count($repo->recent(3)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* paidQueueWithDetail (LIST_ORDERS_DISPLAY) contre le schema reel : insere une
|
|
||||||
* commande `paid` avec une ligne produit + un modificateur, et verifie que la file
|
|
||||||
* porte l'article (label_snapshot, format) et le modificateur (ingredient_name via
|
|
||||||
* la jointure ingredient + action). Les FK sont resolues par nom (convention des
|
|
||||||
* seeds : produit 'Le 280', ingredient 'Oignon'). Auto-skip si seeds absents.
|
|
||||||
*/
|
|
||||||
public function testPaidQueueWithDetailReturnsItemsAndModifiers(): void
|
|
||||||
{
|
|
||||||
$product = $this->db->fetch("SELECT id FROM product WHERE name = 'Le 280'");
|
|
||||||
$ingredient = $this->db->fetch("SELECT id FROM ingredient WHERE name = 'Oignon'");
|
|
||||||
if ($product === null || $ingredient === null) {
|
|
||||||
self::markTestSkipped('Seeds catalogue/ingredients absents (produit/ingredient introuvable).');
|
|
||||||
}
|
|
||||||
|
|
||||||
$num = 'IT-' . $this->suffix . '-KDS';
|
|
||||||
$this->insertOrder($num, 'paid', 1090);
|
|
||||||
// paid_at explicite : la file trie sur paid_at, et la bande SLA en derive.
|
|
||||||
$orderId = $this->orderIdByNumber($num);
|
|
||||||
$this->db->execute('UPDATE customer_order SET paid_at = NOW() WHERE id = :id', ['id' => $orderId]);
|
|
||||||
|
|
||||||
$this->db->execute(
|
|
||||||
'INSERT INTO order_item (order_id, item_type, product_id, format, label_snapshot, '
|
|
||||||
. 'unit_price_cents_snapshot, vat_rate_snapshot, quantity) '
|
|
||||||
. "VALUES (:oid, 'product', :pid, 'normal', 'Le 280', 1090, 100, 1)",
|
|
||||||
['oid' => $orderId, 'pid' => (int) $product['id']],
|
|
||||||
);
|
|
||||||
$itemId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
|
|
||||||
$this->db->execute(
|
|
||||||
'INSERT INTO order_item_modifier (order_item_id, ingredient_id, action, extra_price_cents) '
|
|
||||||
. "VALUES (:iid, :ing, 'remove', 0)",
|
|
||||||
['iid' => $itemId, 'ing' => (int) $ingredient['id']],
|
|
||||||
);
|
|
||||||
|
|
||||||
$queue = (new OrderQueryRepository($this->db))->paidQueueWithDetail(['kiosk', 'counter', 'drive']);
|
|
||||||
$mine = array_values(array_filter(
|
|
||||||
$queue,
|
|
||||||
static fn (array $o): bool => ($o['order_number'] ?? '') === $num,
|
|
||||||
));
|
|
||||||
self::assertCount(1, $mine, 'la commande inseree doit apparaitre dans la file KDS');
|
|
||||||
|
|
||||||
$order = $mine[0];
|
|
||||||
self::assertArrayNotHasKey('id', $order, 'l\'id technique ne doit pas etre expose');
|
|
||||||
self::assertContains($order['sla_band'], ['fresh', 'warn', 'late']);
|
|
||||||
|
|
||||||
self::assertCount(1, $order['items']);
|
|
||||||
$item = $order['items'][0];
|
|
||||||
self::assertSame('Le 280', (string) $item['label_snapshot']);
|
|
||||||
self::assertSame('normal', (string) $item['format']);
|
|
||||||
self::assertCount(1, $item['modifiers']);
|
|
||||||
self::assertSame('remove', (string) $item['modifiers'][0]['action']);
|
|
||||||
self::assertSame('Oignon', (string) $item['modifiers'][0]['ingredient_name']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function orderIdByNumber(string $number): int
|
|
||||||
{
|
|
||||||
return (int) ($this->db->fetch(
|
|
||||||
'SELECT id FROM customer_order WHERE order_number = :n',
|
|
||||||
['n' => $number],
|
|
||||||
)['id'] ?? 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,9 +169,6 @@ final class ProductIngredientDbTest extends TestCase
|
||||||
'name' => $this->product,
|
'name' => $this->product,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'price_cents' => 590,
|
'price_cents' => 590,
|
||||||
'size_cl' => null,
|
|
||||||
'base_product_id' => null,
|
|
||||||
'maxi_variant_product_id' => null,
|
|
||||||
'vat_rate' => 100,
|
'vat_rate' => 100,
|
||||||
'image_path' => null,
|
'image_path' => null,
|
||||||
'is_available' => 1,
|
'is_available' => 1,
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,6 @@ final class ProductRepositoryDbTest extends TestCase
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'price_cents' => 999,
|
'price_cents' => 999,
|
||||||
'size_cl' => null,
|
|
||||||
'base_product_id' => null,
|
|
||||||
'maxi_variant_product_id' => null,
|
|
||||||
'vat_rate' => 100,
|
'vat_rate' => 100,
|
||||||
'image_path' => null,
|
'image_path' => null,
|
||||||
'is_available' => 1,
|
'is_available' => 1,
|
||||||
|
|
@ -80,9 +77,6 @@ final class ProductRepositoryDbTest extends TestCase
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'description' => 'maj',
|
'description' => 'maj',
|
||||||
'price_cents' => 1099,
|
'price_cents' => 1099,
|
||||||
'size_cl' => null,
|
|
||||||
'base_product_id' => null,
|
|
||||||
'maxi_variant_product_id' => null,
|
|
||||||
'vat_rate' => 55,
|
'vat_rate' => 55,
|
||||||
'image_path' => null,
|
'image_path' => null,
|
||||||
'is_available' => 0,
|
'is_available' => 0,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue