feat(borne): tailles 30/50cl boissons a la carte (R4) (#88)
All checks were successful
CI / secret-scan (push) Successful in 18s
CI / php-lint (push) Successful in 33s
CI / static-tests (push) Successful in 1m9s
CI / js-tests (push) Successful in 41s

This commit is contained in:
Corentin JOGUET 2026-06-22 14:07:46 +02:00
parent 034038a31f
commit 545aa19cf1
14 changed files with 662 additions and 27 deletions

View file

@ -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).

View file

@ -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
);

View file

@ -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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, list<array<string, mixed>>> 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<int, list<array<string, mixed>>> $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<string, mixed>|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],
);
}

View file

@ -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<string, mixed> $row
* @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int}
* @param array<int, array<string, mixed>> $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<array{product_id: int, size_cl: int, price_cents: int, label: string}>}
*/
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),
),
];
}

View file

@ -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);
}

View file

@ -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];

View file

@ -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 = `
<div class="product-detail__image-wrap">
<img

View file

@ -1,14 +1,16 @@
/*
* product-options.js Modale d'options produit (P5 L3).
* product-options.js Modale d'options produit (P5 L3, taille R4).
*
* Remplace la navigation vers product.html : cliquer un produit simple ouvre une
* modale (image, prix unitaire, stepper de quantite, total) au-dessus de la grille,
* facon maquette ("Une petite soif ?"). A l'ajout, le panneau de commande persistant
* (L1) est re-rendu pour refleter immediatement la commande -> 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 + " - <label>") qui sont poses -> le domaine commande
* facture cette ligne produit, sans logique de taille. Sans size, comportement inchange.
*
* @param {Object} product forme borne {id, nom, prix, image, categorie?, sizes?}
* @param {string} categorySlug
* @param {number} qty
* @param {Object|null} [size] entree de sizes {product_id, size_cl, price_cents, label}
* @returns {Object} item panier
*/
export function productCartItem(product, categorySlug, qty) {
export function productCartItem(product, categorySlug, qty, size = null) {
const quantite = Math.min(QTY_MAX, Math.max(1, Math.floor(qty) || 1));
return {
id: product.id,
id: size ? size.product_id : product.id,
type: 'produit',
categorie: product.categorie ?? categorySlug,
libelle: product.nom,
prix_cents: product.prix,
libelle: size ? `${product.nom} - ${size.label}` : product.nom,
prix_cents: size ? size.price_cents : product.prix,
quantite,
image: product.image,
};
}
/**
* Tailles utilisables d'un produit : tableau de sizes seulement s'il en porte plus
* d'une (un picker n'a de sens qu'avec un choix). Sinon [] (ajout direct). Pur.
* @param {Object} product
* @returns {Array}
*/
export function productSizes(product) {
return Array.isArray(product.sizes) && product.sizes.length > 1 ? product.sizes : [];
}
/** Re-rend le panneau de commande persistant (s'il est present sur la page). */
function refreshOrderPanel() {
document.querySelectorAll('[data-order-panel]').forEach(renderOrderPanel);
@ -53,6 +71,12 @@ function refreshOrderPanel() {
export function openProductOptions(product, categorySlug) {
let qty = 1;
// Tailles (R4) : si le produit en porte plus d'une, le picker pilote prix + product_id.
// La plus petite (sizes deja trie par volume cote API) est le defaut.
const sizes = productSizes(product);
let selectedSize = sizes.length ? sizes[0] : null;
const unitPrice = () => (selectedSize ? selectedSize.price_cents : product.prix);
const overlay = document.createElement('div');
overlay.className = 'composer-overlay';
overlay.hidden = true;
@ -65,13 +89,14 @@ export function openProductOptions(product, categorySlug) {
<div class="product-options">
<img class="product-options__image" src="${escHtml(product.image)}"
alt="${escHtml(product.nom)}" onerror="this.src='assets/images/ui/logo.png';">
<p class="product-options__unit">${formatPrice(product.prix)} / unite</p>
<div class="product-options__sizes" role="group" aria-label="Taille"></div>
<p class="product-options__unit" id="po-unit">${formatPrice(unitPrice())} / unite</p>
<div class="qty-control" role="group" aria-label="Quantite">
<button class="qty-btn qty-btn--minus" type="button" aria-label="Diminuer la quantite">-</button>
<span class="qty-value" id="po-qty" aria-live="polite">1</span>
<button class="qty-btn qty-btn--plus" type="button" aria-label="Augmenter la quantite">+</button>
</div>
<p class="product-options__total" aria-live="polite" aria-atomic="true">Total : <strong id="po-total">${formatPrice(product.prix)}</strong></p>
<p class="product-options__total" aria-live="polite" aria-atomic="true">Total : <strong id="po-total">${formatPrice(unitPrice())}</strong></p>
</div>
</div>
<div class="composer-footer">
@ -92,10 +117,40 @@ export function openProductOptions(product, categorySlug) {
const qtyEl = overlay.querySelector('#po-qty');
const totalEl = overlay.querySelector('#po-total');
const unitEl = overlay.querySelector('#po-unit');
const sync = () => {
qtyEl.textContent = String(qty);
totalEl.textContent = formatPrice(product.prix * qty);
unitEl.textContent = `${formatPrice(unitPrice())} / unite`;
totalEl.textContent = formatPrice(unitPrice() * qty);
};
// Picker de taille : boutons radio-like construits par createElement (CSP-safe,
// pas de handler inline). Sans tailles multiples, le conteneur reste vide.
const sizesWrap = overlay.querySelector('.product-options__sizes');
if (sizes.length) {
sizes.forEach((size) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'size-btn';
btn.dataset.productId = String(size.product_id);
btn.setAttribute('role', 'radio');
const isDefault = size === selectedSize;
btn.setAttribute('aria-checked', isDefault ? 'true' : 'false');
if (isDefault) btn.classList.add('size-btn--selected');
btn.textContent = size.label;
btn.addEventListener('click', () => {
selectedSize = size;
sizesWrap.querySelectorAll('.size-btn').forEach((b) => {
const on = b === btn;
b.classList.toggle('size-btn--selected', on);
b.setAttribute('aria-checked', on ? 'true' : 'false');
});
sync();
});
sizesWrap.appendChild(btn);
});
}
overlay.querySelector('.qty-btn--minus').addEventListener('click', () => { qty = Math.max(1, qty - 1); sync(); });
overlay.querySelector('.qty-btn--plus').addEventListener('click', () => { qty = Math.min(QTY_MAX, qty + 1); sync(); });
@ -110,7 +165,7 @@ export function openProductOptions(product, categorySlug) {
overlay.querySelector('#po-cancel').addEventListener('click', close);
overlay.querySelector('#po-add').addEventListener('click', () => {
addToCart(productCartItem(product, categorySlug, qty));
addToCart(productCartItem(product, categorySlug, qty, selectedSize));
refreshCartBadge();
refreshOrderPanel();
close();

View file

@ -63,6 +63,22 @@ final class FakeCatalogueDatabase implements DatabaseInterface
*/
public array $menuSlotRows = [];
/**
* Lignes PLATES (base_id, id, size_cl, price_cents) renvoyees a la requete
* sizesByBase() (R4) ; le repo les groupe lui-meme par base_id.
*
* @var list<array<string, mixed>>
*/
public array $sizesByBaseRows = [];
/**
* Tailles d'un produit (R4) renvoyees par ProductRepository::sizesForProduct() ;
* la requete porte (id = :base OR base_product_id = :base).
*
* @var list<array<string, mixed>>
*/
public array $productSizes = [];
/**
* Trace des lectures pour asserter le court-circuit du detail (id <= 0).
*
@ -93,6 +109,15 @@ final class FakeCatalogueDatabase implements DatabaseInterface
return $this->categoriesRows;
}
// R4 : tailles groupees (sizesByBase) et tailles d'un produit (sizesForProduct).
// Testees avant la branche catalogue : toutes deux lisent FROM product.
if (str_contains($sql, 'AS base_id')) {
return $this->sizesByBaseRows;
}
if (str_contains($sql, '(id = :base OR base_product_id = :base)')) {
return $this->productSizes;
}
if (str_contains($sql, 'FROM product p JOIN category') && str_contains($sql, 'WHERE p.is_available = 1')) {
return $this->productsRows;
}

View file

@ -113,7 +113,7 @@ final class CatalogueControllerTest extends TestCase
$product = $payload['data'][0];
self::assertSame(
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order'],
['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'sizes'],
array_keys($product),
);
self::assertSame(12, $product['id']);
@ -121,6 +121,81 @@ final class CatalogueControllerTest extends TestCase
self::assertSame(890, $product['price_cents']); // chaine -> int
self::assertArrayNotHasKey('vat_rate', $product); // fiscal interne, non expose
self::assertArrayNotHasKey('is_available', $product); // toujours dispo ici -> non expose
self::assertSame([], $product['sizes']); // produit mono-taille -> sizes vide
}
public function testProductsListPresentsSizesArrayForDrinkWithVariants(): void
{
$db = new FakeCatalogueDatabase();
$db->productsRows = [
[
'id' => '14', 'category_id' => '2', 'name' => 'Coca Cola',
'description' => null, 'price_cents' => '190', 'size_cl' => '30',
'image_path' => 'c.png', 'display_order' => '1',
],
];
// sizesByBase() (R4) : la base 14 porte deux tailles (30 cl id 14, 50 cl id 99).
$db->sizesByBaseRows = [
['base_id' => '14', 'id' => '14', 'size_cl' => '30', 'price_cents' => '190'],
['base_id' => '14', 'id' => '99', 'size_cl' => '50', 'price_cents' => '240'],
];
$response = $this->controller($db, '/api/products')->products();
self::assertSame(200, $response->status());
$product = $this->decode($response->body())['data'][0];
// Chaque taille : product_id resolu, volume, prix, label HUMAIN ("30 cl").
self::assertSame(
[
['product_id' => 14, 'size_cl' => 30, 'price_cents' => 190, 'label' => '30 cl'],
['product_id' => 99, 'size_cl' => 50, 'price_cents' => 240, 'label' => '50 cl'],
],
$product['sizes'],
);
// La 30 cl reutilise le product_id de la base : l'ajout direct reste possible.
self::assertSame(14, $product['sizes'][0]['product_id']);
}
public function testProductDetailPresentsSizesWhenVariantsExist(): void
{
$db = new FakeCatalogueDatabase();
$db->productRow = [
'id' => '14', 'category_id' => '2', 'name' => 'Coca Cola',
'description' => null, 'price_cents' => '190', 'size_cl' => '30',
'image_path' => null, 'display_order' => '1',
];
$db->productSizes = [
['id' => '14', 'size_cl' => '30', 'price_cents' => '190'],
['id' => '99', 'size_cl' => '50', 'price_cents' => '240'],
];
$response = $this->controller($db, '/api/products/14')->product(['id' => '14']);
self::assertSame(200, $response->status());
$product = $this->decode($response->body())['data'];
self::assertCount(2, $product['sizes']);
self::assertSame('50 cl', $product['sizes'][1]['label']);
}
public function testProductDetailHasEmptySizesWhenSingleSize(): void
{
$db = new FakeCatalogueDatabase();
$db->productRow = [
'id' => '17', 'category_id' => '2', 'name' => 'Eau',
'description' => null, 'price_cents' => '100', 'size_cl' => null,
'image_path' => null, 'display_order' => '3',
];
// sizesForProduct ne remonte que la base (1 ligne) : pas de dimension taille.
$db->productSizes = [
['id' => '17', 'size_cl' => null, 'price_cents' => '100'],
];
$response = $this->controller($db, '/api/products/17')->product(['id' => '17']);
self::assertSame(200, $response->status());
$product = $this->decode($response->body())['data'];
self::assertSame([], $product['sizes']);
}
public function testProductDetailReturnsData(): void

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Catalogue;
use App\Catalogue\ProductRepository;
use App\Tests\Support\FakeCatalogueDatabase;
use PHPUnit\Framework\TestCase;
/**
* Tailles a la carte des boissons (R4) cote ProductRepository. La grille catalogue
* doit EXCLURE les variantes de taille (base_product_id non nul) et les tailles
* sont assemblees pour une base a variantes / vides pour un produit mono-taille.
* Le double FakeCatalogueDatabase scripte les lignes renvoyees aux requetes.
*/
final class ProductRepositorySizesTest extends TestCase
{
public function testAvailableForCatalogueQueryExcludesVariants(): void
{
$db = new FakeCatalogueDatabase();
$db->productsRows = [
['id' => '14', 'category_id' => '2', 'name' => 'Coca Cola', 'description' => null, 'price_cents' => '190', 'size_cl' => '30', 'image_path' => 'c.png', 'display_order' => '1'],
];
$rows = (new ProductRepository($db))->availableForCatalogue();
// La projection remonte la base 30 cl ; le filtre anti-variante est porte par
// le SQL (base_product_id IS NULL) -> on l'asserte sur la requete tracee.
self::assertCount(1, $rows);
self::assertSame('14', $rows[0]['id']);
self::assertStringContainsString('base_product_id IS NULL', $db->reads[0]['sql']);
}
public function testFindForCatalogueQueryExcludesVariants(): void
{
// Acces direct au detail : meme invariant que la liste. Une variante de
// taille (base_product_id non nul) ne doit jamais etre une fiche autonome
// -> /api/products/{idVariante} rend 404. Le double est scripte, donc on
// verrouille la garde sur le SQL trace (regression = clause retiree).
$db = new FakeCatalogueDatabase();
$db->productRow = ['id' => '14', 'category_id' => '2', 'name' => 'Coca Cola', 'description' => null, 'price_cents' => '190', 'image_path' => 'c.png', 'display_order' => '1'];
(new ProductRepository($db))->findForCatalogue(14);
self::assertStringContainsString('p.id = :id', $db->reads[0]['sql']);
self::assertStringContainsString('base_product_id IS NULL', $db->reads[0]['sql']);
}
public function testSizesByBaseGroupsRowsByBaseId(): void
{
$db = new FakeCatalogueDatabase();
// Lignes plates : base 14 (Coca 30 cl) + sa variante 50 cl (id 99) ; base 15
// (Fanta 30 cl) + sa variante (id 100). Le repo les groupe par base_id.
$db->sizesByBaseRows = [
['base_id' => '14', 'id' => '14', 'size_cl' => '30', 'price_cents' => '190'],
['base_id' => '14', 'id' => '99', 'size_cl' => '50', 'price_cents' => '240'],
['base_id' => '15', 'id' => '15', 'size_cl' => '30', 'price_cents' => '190'],
['base_id' => '15', 'id' => '100', 'size_cl' => '50', 'price_cents' => '240'],
];
$byBase = (new ProductRepository($db))->sizesByBase();
self::assertSame([14, 15], array_keys($byBase));
self::assertSame(
[
['id' => 14, 'size_cl' => 30, 'price_cents' => 190],
['id' => 99, 'size_cl' => 50, 'price_cents' => 240],
],
$byBase[14],
);
self::assertCount(2, $byBase[15]);
}
public function testSizesByBaseEmptyWhenNoVariants(): void
{
$db = new FakeCatalogueDatabase();
// Aucune ligne remontee : aucune base ne porte de variante de taille.
$byBase = (new ProductRepository($db))->sizesByBase();
self::assertSame([], $byBase);
}
public function testSizesForProductReturnsBaseAndVariant(): void
{
$db = new FakeCatalogueDatabase();
$db->productSizes = [
['id' => '14', 'size_cl' => '30', 'price_cents' => '190'],
['id' => '99', 'size_cl' => '50', 'price_cents' => '240'],
];
$sizes = (new ProductRepository($db))->sizesForProduct(14);
self::assertCount(2, $sizes);
self::assertSame('14', $sizes[0]['id']);
self::assertSame('99', $sizes[1]['id']);
// Le parametre :base a bien ete lie a l'id demande.
self::assertSame(14, $db->reads[0]['params']['base'] ?? null);
}
}

View file

@ -49,6 +49,28 @@ final class OrderRepositoryTest extends TestCase
self::assertSame(100, $item['vat']);
}
public function testDrinkSizeVariantIsPricedByItsOwnProductRow(): void
{
// R4 : la 50 cl est une LIGNE produit distincte (id 99, 240c). La borne resout
// la taille en product_id ; le domaine commande la facture comme n'importe quel
// produit (price_cents de SA ligne), sans logique de taille -> flux inchange.
$db = new FakeOrderDatabase();
$db->products[14] = ['id' => 14, 'name' => 'Coca Cola', 'price_cents' => 190, 'vat_rate' => 100, 'is_available' => 1];
$db->products[99] = ['id' => 99, 'name' => 'Coca Cola 50cl', 'price_cents' => 240, 'vat_rate' => 100, 'is_available' => 1, 'base_product_id' => 14, 'size_cl' => 50];
$res = $this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'product', 'product_id' => 99, 'quantity' => 1]],
]);
// Prix = celui de la ligne 50 cl (240c), pas de la base 30 cl (190c).
$item = $db->firstWrite('INSERT INTO order_item ');
self::assertSame(99, $item['pid']);
self::assertSame('Coca Cola 50cl', $item['label']);
self::assertSame(240, $item['price']);
self::assertSame(240, $res['total_ttc_cents']);
}
public function testMenuMaxiUsesBurgerVatAndMaxiPrice(): void
{
$db = new FakeOrderDatabase();

View file

@ -67,10 +67,24 @@ test('loadProducts groupe les produits par slug a la forme borne (type produit)'
const data = await loadProducts();
assert.deepEqual(data.burgers, [
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit' },
// sizes (R4) : tableau vide par defaut quand l'API n'en renvoie pas.
{ id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', sizes: [] },
]);
});
test('loadProducts reporte le tableau sizes du produit (R4) tel quel', async () => {
const fx = fixtures();
fx['/api/products'].data[0].sizes = [
{ product_id: 10, size_cl: 30, price_cents: 600, label: '30 cl' },
{ product_id: 88, size_cl: 50, price_cents: 650, label: '50 cl' },
];
const { loadProducts } = await freshData(fx);
const data = await loadProducts();
assert.equal(data.burgers[0].sizes.length, 2);
assert.equal(data.burgers[0].sizes[1].product_id, 88);
});
test('loadProducts glisse les menus sous la cle menus (type menu, prix = price_normal_cents)', async () => {
const { loadProducts } = await freshData(fixtures());

View file

@ -9,7 +9,7 @@ import { test, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
let productCartItem, openProductOptions;
let productCartItem, openProductOptions, productSizes;
before(async () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://kiosk.test/products.html' });
@ -17,7 +17,7 @@ before(async () => {
global.document = dom.window.document;
global.localStorage = dom.window.localStorage;
global.requestAnimationFrame = (cb) => cb();
({ productCartItem, openProductOptions } =
({ productCartItem, openProductOptions, productSizes } =
await import('../../src/public/borne/assets/js/product-options.js'));
});
@ -88,3 +88,72 @@ test('openProductOptions: Annuler ferme sans rien ajouter', () => {
assert.equal(document.querySelector('.composer-overlay'), null);
assert.equal(localStorage.getItem('wakdo_cart'), null);
});
/* --- Tailles a la carte (R4) --------------------------------------------- */
// Boisson a deux tailles : 30 cl = ligne de base (id du produit), 50 cl = variante.
const soda = {
id: 14, nom: 'Coca', prix: 190, image: 'c.png', categorie: 'boissons',
sizes: [
{ product_id: 14, size_cl: 30, price_cents: 190, label: '30 cl' },
{ product_id: 99, size_cl: 50, price_cents: 240, label: '50 cl' },
],
};
test('productSizes: renvoie les tailles seulement si plus d une', () => {
assert.equal(productSizes(soda).length, 2);
assert.deepEqual(productSizes(product), []); // pas de sizes
assert.deepEqual(productSizes({ ...product, sizes: [soda.sizes[0]] }), []); // une seule taille
});
test('productCartItem: avec une taille, prend SON product_id, prix et libelle', () => {
const it = productCartItem(soda, 'boissons', 1, soda.sizes[1]); // 50 cl
assert.equal(it.id, 99);
assert.equal(it.prix_cents, 240);
assert.equal(it.libelle, 'Coca - 50 cl');
});
test('openProductOptions: rend un picker, defaut = plus petite taille (prix 30 cl)', () => {
openProductOptions(soda, 'boissons');
const btns = document.querySelectorAll('.product-options__sizes .size-btn');
assert.equal(btns.length, 2);
assert.equal(btns[0].textContent, '30 cl');
assert.equal(btns[0].getAttribute('aria-checked'), 'true'); // 30 cl par defaut
assert.equal(btns[1].getAttribute('aria-checked'), 'false');
assert.match(document.querySelector('#po-total').textContent, /1,90/); // prix 30 cl
});
test('openProductOptions: changer de taille met a jour le prix affiche', () => {
openProductOptions(soda, 'boissons');
const btns = document.querySelectorAll('.product-options__sizes .size-btn');
btns[1].click(); // 50 cl
assert.equal(btns[1].getAttribute('aria-checked'), 'true');
assert.equal(btns[0].getAttribute('aria-checked'), 'false');
assert.match(document.querySelector('#po-total').textContent, /2,40/); // prix 50 cl
assert.match(document.querySelector('#po-unit').textContent, /2,40/);
});
test('openProductOptions: ajout avec la taille choisie -> item porte le product_id de la taille', () => {
openProductOptions(soda, 'boissons');
const btns = document.querySelectorAll('.product-options__sizes .size-btn');
btns[1].click(); // 50 cl
document.querySelector('.qty-btn--plus').click(); // qty 2
document.querySelector('#po-add').click();
const cart = JSON.parse(localStorage.getItem('wakdo_cart'));
assert.equal(cart.length, 1);
assert.equal(cart[0].id, 99); // product_id de la 50 cl
assert.equal(cart[0].prix_cents, 240);
assert.equal(cart[0].quantite, 2);
assert.equal(cart[0].libelle, 'Coca - 50 cl');
});
test('openProductOptions: produit sans taille -> aucun picker, ajout direct (product_id de base)', () => {
openProductOptions(product, 'boissons'); // product sans sizes
assert.equal(document.querySelectorAll('.product-options__sizes .size-btn').length, 0);
document.querySelector('#po-add').click();
const cart = JSON.parse(localStorage.getItem('wakdo_cart'));
assert.equal(cart[0].id, 14);
assert.equal(cart[0].prix_cents, 190);
assert.equal(cart[0].libelle, 'Coca'); // pas de suffixe de taille
});