From 545aa19cf11253a21cd79fde5a55b98b8df0ad36 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Mon, 22 Jun 2026 14:07:46 +0200 Subject: [PATCH] feat(borne): tailles 30/50cl boissons a la carte (R4) (#88) --- db/migrations/0007_product_size_variant.sql | 53 ++++++++++ db/seeds/0005_drink_sizes.sql | 86 ++++++++++++++++ src/app/Catalogue/ProductRepository.php | 75 +++++++++++++- src/app/Controllers/CatalogueController.php | 40 ++++++-- src/public/borne/assets/css/style.css | 24 +++++ src/public/borne/assets/js/data.js | 8 +- src/public/borne/assets/js/page-product.js | 10 ++ src/public/borne/assets/js/product-options.js | 81 ++++++++++++--- tests/Support/FakeCatalogueDatabase.php | 25 +++++ .../Catalogue/CatalogueControllerTest.php | 77 ++++++++++++++- .../Catalogue/ProductRepositorySizesTest.php | 99 +++++++++++++++++++ tests/Unit/Order/OrderRepositoryTest.php | 22 +++++ tests/js/data.test.js | 16 ++- tests/js/product-options.test.js | 73 +++++++++++++- 14 files changed, 662 insertions(+), 27 deletions(-) create mode 100644 db/migrations/0007_product_size_variant.sql create mode 100644 db/seeds/0005_drink_sizes.sql create mode 100644 tests/Unit/Catalogue/ProductRepositorySizesTest.php diff --git a/db/migrations/0007_product_size_variant.sql b/db/migrations/0007_product_size_variant.sql new file mode 100644 index 0000000..44235f7 --- /dev/null +++ b/db/migrations/0007_product_size_variant.sql @@ -0,0 +1,53 @@ +-- db/migrations/0007_product_size_variant.sql +-- ============================================================================= +-- Wakdo - Migration 0007 : variante de TAILLE d'un produit (boisson 30/50 cl) +-- ============================================================================= +-- Purpose : ajoute a `product` la dimension TAILLE des boissons fontaine (la +-- maquette borne propose 30 cl / 50 cl), modelisee comme des LIGNES +-- produit distinctes (meme approche que Moyenne/Grande Frite). Le +-- domaine commande facture deja par product_id : le flux de commande +-- reste inchange, la borne resout juste la taille choisie en product_id. +-- +-- Grouping DEDIE, distinct de maxi_variant_product_id (migration 0006) : +-- ce dernier pilote la substitution Maxi de l'accompagnement de menu +-- (resolveSelections) ; le reutiliser ferait basculer en 50 cl une +-- boisson 30 cl glissee dans un menu Maxi (effet de bord non voulu). +-- Target : MariaDB 11.4 LTS, InnoDB, utf8mb4 / utf8mb4_unicode_ci. +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Idempotence : meme garde information_schema que 0006 (re-jouable sans erreur). +-- On verifie l'absence de la colonne `size_cl` avant l'ALTER ; les deux colonnes +-- sont ajoutees ensemble, l'existence de l'une suffit donc a court-circuiter. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'product' AND column_name = 'size_cl' +); + +SET @ddl := IF( + @col_exists = 0, + 'ALTER TABLE product + ADD COLUMN size_cl SMALLINT UNSIGNED NULL AFTER price_cents, + ADD COLUMN base_product_id INT UNSIGNED NULL AFTER size_cl, + ADD CONSTRAINT fk_product_base_product_id FOREIGN KEY (base_product_id) + REFERENCES product (id) ON DELETE CASCADE', + 'DO 0' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- size_cl : volume en centilitres. NULL = le produit n'a pas de dimension taille +-- (bouteilles, produits non-boissons). La ligne de base (30) ET la variante (50) +-- portent toutes deux leur volume, pour que le picker affiche un libelle humain. +-- +-- base_product_id : auto-reference vers la ligne de base. NULL = produit de base +-- ou autonome (visible dans le catalogue) ; NON NULL = variante de taille du +-- produit reference (masquee de la grille catalogue, atteinte via le picker). +-- +-- ON DELETE CASCADE (et non SET NULL comme 0006) : une variante de taille n'a +-- AUCUN sens sans sa base (une "Coca Cola 50cl" orpheline n'est pas commercialisable), +-- alors que la substitution Maxi de 0006 est un confort optionnel survivant a la +-- perte de sa cible. Supprimer la base emporte donc ses variantes de taille. Les +-- commandes passees ne sont pas affectees (elles figent leurs snapshots, RG-T05). diff --git a/db/seeds/0005_drink_sizes.sql b/db/seeds/0005_drink_sizes.sql new file mode 100644 index 0000000..7c5111c --- /dev/null +++ b/db/seeds/0005_drink_sizes.sql @@ -0,0 +1,86 @@ +-- ============================================================================= +-- Wakdo — Seed 0005 : tailles a la carte des boissons fontaine (30 / 50 cl) +-- ============================================================================= +-- Purpose : cabler la dimension TAILLE (schema 0007) sur les boissons fontaine +-- seedees par 0002_catalogue.sql, sans toucher au code : +-- 1. la ligne existante de chaque soda devient la BASE 30 cl ; +-- 2. une ligne VARIANTE 50 cl est inseree par soda (base_product_id -> +-- la base, prix = base + 50c par defaut, +50 cl) ; +-- 3. la recette (product_ingredient) de la base est dupliquee sur la +-- variante, pour que le decrement de stock (consumption) frappe +-- aussi la 50 cl. +-- +-- Perimetre : seules les boissons fontaine ont deux tailles (Coca Cola, Coca Sans +-- Sucres, Fanta Orange, Ice Tea Peche, Ice Tea Citron). Les boissons en bouteille +-- (Eau, Jus d'Orange, Jus de Pommes Bio) restent mono-taille (size_cl laisse NULL, +-- aucune variante). +-- +-- Phase : R4 — depend du schema 0007 (product.size_cl + base_product_id) et du +-- catalogue 0002 (lignes boissons) ; la duplication de recette depend de +-- 0003 (product_ingredient des sodas). +-- +-- Conventions: +-- - Aucun id en dur : toutes les references sont resolues par sous-requete sur +-- le nom du produit (memes noms que 0002_catalogue.sql). +-- - IDEMPOTENT : UPDATE convergent (repositionne la meme valeur) ; INSERT gardes +-- par WHERE NOT EXISTS (re-jouer n'insere pas de doublon). La sous-requete qui +-- lit `product` dans un INSERT INTO product est enveloppee en table derivee +-- pour contourner l'erreur MariaDB 1093 (technique de 0004_menu_side_maxi.sql). +-- ============================================================================= + +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- ----------------------------------------------------------------------------- +-- 1. Marquer chaque soda fontaine comme BASE 30 cl. UPDATE convergent (rejouer +-- repose 30) -> idempotent. Le nom de base reste propre ("Coca Cola") : la +-- tuile catalogue garde le nom court, le picker affiche "30 cl" / "50 cl". +-- ----------------------------------------------------------------------------- +UPDATE product +SET size_cl = 30 +WHERE base_product_id IS NULL + AND name IN ('Coca Cola', 'Coca Sans Sucres', 'Fanta Orange', 'Ice Tea Peche', 'Ice Tea Citron'); + +-- ----------------------------------------------------------------------------- +-- 2. Inserer la VARIANTE 50 cl de chaque soda. category_id / vat_rate / image +-- copies de la base ; price = base + 50c (defaut sensible, a confirmer) ; +-- base_product_id -> id de la base ; size_cl = 50 ; is_available = 1. +-- L'INSERT lit ET ecrit `product` : la sous-requete est enveloppee en table +-- derivee (b) pour contourner l'erreur 1093. WHERE NOT EXISTS garde le doublon +-- a la re-execution (une variante 50 cl de cette base existe deja -> 0 ligne). +-- ----------------------------------------------------------------------------- +INSERT INTO product (category_id, name, price_cents, size_cl, base_product_id, vat_rate, image_path, is_available, display_order) +SELECT b.category_id, b.name_50, b.price_cents + 50, 50, b.id, b.vat_rate, b.image_path, 1, b.display_order +FROM ( + SELECT id, category_id, CONCAT(name, ' 50cl') AS name_50, price_cents, vat_rate, image_path, display_order + FROM product + WHERE base_product_id IS NULL + AND name IN ('Coca Cola', 'Coca Sans Sucres', 'Fanta Orange', 'Ice Tea Peche', 'Ice Tea Citron') +) b +WHERE NOT EXISTS ( + SELECT 1 FROM (SELECT base_product_id FROM product WHERE base_product_id IS NOT NULL) v + WHERE v.base_product_id = b.id +); + +-- ----------------------------------------------------------------------------- +-- 3. Dupliquer la recette de chaque base 30 cl sur sa variante 50 cl, pour que +-- le decrement de stock frappe aussi la 50 cl. Memes ingredients / quantites +-- que la base (simplification assumee : R4 vise le flux de commande, pas une +-- consommation volumetrique exacte). Une base sans recette (ex. theorique) ne +-- produit aucune ligne pour sa variante. +-- PK composite (product_id, ingredient_id) : WHERE NOT EXISTS garde la +-- re-execution (les lignes de la variante existent deja -> 0 ligne inseree). +-- ----------------------------------------------------------------------------- +INSERT INTO product_ingredient (product_id, ingredient_id, quantity_normal, quantity_maxi, is_removable, is_addable, extra_price_cents) +SELECT v.id, src.ingredient_id, src.quantity_normal, src.quantity_maxi, src.is_removable, src.is_addable, src.extra_price_cents +FROM product v +JOIN ( + SELECT pi.product_id AS base_id, pi.ingredient_id, pi.quantity_normal, pi.quantity_maxi, + pi.is_removable, pi.is_addable, pi.extra_price_cents + FROM product_ingredient pi +) src ON src.base_id = v.base_product_id +WHERE v.base_product_id IS NOT NULL + AND v.size_cl = 50 + AND NOT EXISTS ( + SELECT 1 FROM (SELECT product_id, ingredient_id FROM product_ingredient) e + WHERE e.product_id = v.id AND e.ingredient_id = src.ingredient_id + ); diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php index 2fafbaf..0fabfd0 100644 --- a/src/app/Catalogue/ProductRepository.php +++ b/src/app/Catalogue/ProductRepository.php @@ -66,25 +66,94 @@ final class ProductRepository * disponibilite = flag is_available ; la dispo CALCULEE RG-T21 (exclusion des * ruptures auto via autoUnavailableIds) se branchera au seed des recettes. * + * base_product_id IS NULL (R4) : les VARIANTES de taille (ex. "Coca Cola 50cl") + * ne sont jamais des tuiles catalogue autonomes ; elles sont atteintes via le + * picker de taille de la base, qui les expose par sizesForProduct(). size_cl est + * remonte pour que le controleur sache quelles bases portent une dimension taille. + * * @return array> */ public function availableForCatalogue(): array { return $this->db->fetchAll( - 'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, ' + 'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, p.size_cl, ' . 'p.image_path, p.display_order ' . 'FROM product p JOIN category c ON c.id = p.category_id ' - . 'WHERE p.is_available = 1 AND c.is_active = 1 ' + . 'WHERE p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL ' . 'ORDER BY p.display_order, p.name', ); } + /** + * Tailles commandables d'un produit de base (R4) : la base elle-meme + ses + * variantes de taille disponibles, triees par volume croissant (30 cl puis + * 50 cl). Chaque ligne porte son propre product_id et son price_cents : la + * borne resout la taille choisie en product_id, le domaine commande facture + * ce product_id sans logique de taille (flux inchange). Seules les variantes + * disponibles (is_available = 1) sont remontees ; la base est toujours incluse + * (l'appelant ne demande les tailles que pour une base deja affichable). + * NULLs de size_cl tries en premier (la base sans dimension n'a pas de variante, + * ce cas ne remonte qu'une ligne). + * + * @return array> + */ + public function sizesForProduct(int $baseId): array + { + return $this->db->fetchAll( + 'SELECT id, size_cl, price_cents FROM product ' + . 'WHERE (id = :base OR base_product_id = :base) AND is_available = 1 ' + . 'ORDER BY size_cl IS NULL DESC, size_cl, id', + ['base' => $baseId], + ); + } + + /** + * Toutes les tailles des produits AYANT au moins une variante de taille (R4), + * indexees par id de la base, en UNE requete (evite le N+1 sur la liste + * /api/products, cache-friendly cote borne). Ne remonte que les bases dont une + * variante existe : un produit mono-taille n'apparait pas (le controleur lui + * laisse alors un tableau sizes vide). La base est incluse parmi ses tailles. + * Lignes triees par base puis volume croissant (30 cl avant 50 cl). + * + * @return array>> base_id => [{id, size_cl, price_cents}, ...] + */ + public function sizesByBase(): array + { + $rows = $this->db->fetchAll( + 'SELECT COALESCE(p.base_product_id, p.id) AS base_id, p.id, p.size_cl, p.price_cents ' + . 'FROM product p ' + . 'WHERE p.is_available = 1 AND (' + . ' p.base_product_id IS NOT NULL ' + . ' OR EXISTS (SELECT 1 FROM product v WHERE v.base_product_id = p.id AND v.is_available = 1)' + . ') ' + . 'ORDER BY base_id, p.size_cl IS NULL DESC, p.size_cl, p.id', + ); + + /** @var array>> $byBase */ + $byBase = []; + foreach ($rows as $row) { + $baseId = (int) ($row['base_id'] ?? 0); + $byBase[$baseId][] = [ + 'id' => (int) ($row['id'] ?? 0), + 'size_cl' => (int) ($row['size_cl'] ?? 0), + 'price_cents' => (int) ($row['price_cents'] ?? 0), + ]; + } + + return $byBase; + } + /** * Detail produit pour la borne : la meme projection que la liste, et seulement * si le produit est commandable (is_available = 1) en categorie active ; sinon * null (le controleur rend 404). Un produit retire ou en categorie masquee est * donc invisible meme par lien direct. * + * base_product_id IS NULL (R4) : meme invariant que availableForCatalogue() -- + * une VARIANTE de taille n'est jamais une fiche detail autonome. Un acces direct + * a /api/products/{idVariante} rend donc null -> 404 ; la 50 cl ne s'atteint que + * via le picker de taille de sa base, jamais par lien direct. + * * @return array|null */ public function findForCatalogue(int $id): ?array @@ -93,7 +162,7 @@ final class ProductRepository 'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, ' . 'p.image_path, p.display_order ' . 'FROM product p JOIN category c ON c.id = p.category_id ' - . 'WHERE p.id = :id AND p.is_available = 1 AND c.is_active = 1', + . 'WHERE p.id = :id AND p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL', ['id' => $id], ); } diff --git a/src/app/Controllers/CatalogueController.php b/src/app/Controllers/CatalogueController.php index 5e8596a..14e9ac7 100644 --- a/src/app/Controllers/CatalogueController.php +++ b/src/app/Controllers/CatalogueController.php @@ -51,9 +51,14 @@ class CatalogueController extends Controller */ public function products(array $params = []): Response { + $repo = $this->productsRepo(); + // R4 : les tailles de TOUS les produits a variantes sont chargees en UNE + // requete (sizesByBase), pas une par produit -> /api/products reste un seul + // aller-retour cache-friendly cote borne (data.js memoise la liste). + $sizesByBase = $repo->sizesByBase(); $rows = array_map( - fn (array $row): array => $this->presentProduct($row), - $this->productsRepo()->availableForCatalogue(), + fn (array $row): array => $this->presentProduct($row, $sizesByBase[(int) ($row['id'] ?? 0)] ?? []), + $repo->availableForCatalogue(), ); return $this->json(['data' => $rows, 'total' => count($rows)]); @@ -65,7 +70,8 @@ class CatalogueController extends Controller public function product(array $params = []): Response { $id = (int) ($params['id'] ?? 0); - $row = $id > 0 ? $this->productsRepo()->findForCatalogue($id) : null; + $repo = $this->productsRepo(); + $row = $id > 0 ? $repo->findForCatalogue($id) : null; if ($row === null) { return $this->json( @@ -74,7 +80,12 @@ class CatalogueController extends Controller ); } - return $this->json(['data' => $this->presentProduct($row)]); + // R4 : sur le detail, les tailles ne sont presentees que si le produit en a + // au moins une VARIANTE (sinon sizesForProduct ne remonte que la base, et la + // base seule n'est pas une dimension de taille -> sizes vide cote presentation). + $sizes = $repo->sizesForProduct($id); + + return $this->json(['data' => $this->presentProduct($row, count($sizes) > 1 ? $sizes : [])]); } /** @@ -185,9 +196,13 @@ class CatalogueController extends Controller /** * @param array $row - * @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int} + * @param array> $sizes tailles de la base (R4) : base + + * variantes ; vide si le produit n'a pas de dimension taille. Chaque entree + * devient {product_id, size_cl, price_cents, label} ; le label humain est + * derive du volume ("30 cl") -- aucun slug/enum ne fuit a l'ecran. + * @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, sizes: list} */ - private function presentProduct(array $row): array + private function presentProduct(array $row, array $sizes = []): array { return [ 'id' => (int) ($row['id'] ?? 0), @@ -197,6 +212,19 @@ class CatalogueController extends Controller 'price_cents' => (int) ($row['price_cents'] ?? 0), 'image_path' => $this->nullableString($row['image_path'] ?? null), 'display_order' => (int) ($row['display_order'] ?? 0), + 'sizes' => array_map( + static function (array $size): array { + $cl = (int) ($size['size_cl'] ?? 0); + + return [ + 'product_id' => (int) ($size['id'] ?? 0), + 'size_cl' => $cl, + 'price_cents' => (int) ($size['price_cents'] ?? 0), + 'label' => $cl . ' cl', + ]; + }, + array_values($sizes), + ), ]; } diff --git a/src/public/borne/assets/css/style.css b/src/public/borne/assets/css/style.css index a8a94fe..f3c9a6e 100644 --- a/src/public/borne/assets/css/style.css +++ b/src/public/borne/assets/css/style.css @@ -2104,6 +2104,30 @@ button { color: var(--color-text-secondary); } +/* Picker de taille (R4 : 30/50 cl) : boutons-pills, l'actif borde par l'accent. */ +.product-options__sizes { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; + justify-content: center; +} + +.size-btn { + min-width: 72px; + padding: var(--space-2) var(--space-3); + border: 2px solid var(--color-border-default); + border-radius: var(--radius-pill); + background: var(--color-bg-card); + color: var(--color-text-primary); + font-size: var(--font-size-md); + cursor: pointer; +} + +.size-btn--selected { + border-color: var(--color-border-active); + font-weight: 700; +} + .product-options__total { font-size: var(--font-size-md); } diff --git a/src/public/borne/assets/js/data.js b/src/public/borne/assets/js/data.js index 7977842..3145883 100644 --- a/src/public/borne/assets/js/data.js +++ b/src/public/borne/assets/js/data.js @@ -80,7 +80,13 @@ export function loadProducts() { for (const p of products) { const slug = slugByCategoryId[p.category_id]; if (slug === undefined) continue; - bySlug[slug].push({ id: p.id, nom: p.name, prix: p.price_cents, image: p.image_path, type: 'produit' }); + // sizes (R4) : tailles a la carte d'une boisson (30/50 cl). Tableau vide + // si le produit n'a pas de dimension taille -> ajout direct inchange. La + // borne ne montre un picker que si sizes a plus d'une entree. + bySlug[slug].push({ + id: p.id, nom: p.name, prix: p.price_cents, image: p.image_path, type: 'produit', + sizes: Array.isArray(p.sizes) ? p.sizes : [], + }); } for (const m of menus) { const slug = slugByCategoryId[m.category_id]; diff --git a/src/public/borne/assets/js/page-product.js b/src/public/borne/assets/js/page-product.js index 1198892..3ffee6a 100644 --- a/src/public/borne/assets/js/page-product.js +++ b/src/public/borne/assets/js/page-product.js @@ -19,6 +19,7 @@ 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); @@ -56,6 +57,15 @@ async function renderProduct() { 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 = `
pas de navigation. * - * Note : la taille (30/50 Cl de la maquette) n'est PAS dans le modele produit actuel - * (un seul price_cents par produit) -> differee (necessite des variantes produit cote - * API). Ce lot couvre quantite + ajout. + * 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, + * price_cents, label}]). Quand un produit porte plus d'une taille, la modale affiche + * un selecteur ; la taille choisie resout le product_id ET le prix de l'item panier. + * Un produit sans taille (sizes vide ou unique) garde l'ajout direct. * * A11y : role=dialog, aria-modal, focus-trap, ESC, fond aria-hidden. */ @@ -22,24 +24,40 @@ const QTY_MAX = 99; /** * Construit l'item panier d'un produit simple pour une quantite donnee. Pur. * Quantite bornee a [1, QTY_MAX]. categorie = celle du produit, sinon le slug courant. - * @param {Object} product — forme borne {id, nom, prix, image, categorie?} + * + * Taille (R4) : si `size` est fournie (entree de product.sizes), c'est SON product_id, + * SON prix et SON libelle (nom + " -