diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php index 0fabfd0..98a46de 100644 --- a/src/app/Catalogue/ProductRepository.php +++ b/src/app/Catalogue/ProductRepository.php @@ -75,10 +75,16 @@ final class ProductRepository */ public function availableForCatalogue(): array { + // mv.name (LEFT JOIN sur la variante Maxi) : la borne affiche ce nom quand le + // menu est commande en Maxi, sans refaire un aller-retour pour resoudre la + // variante. NULL si le produit n'a pas de variante Maxi. La SUBSTITUTION reelle + // a la commande reste serveur (OrderRepository::resolveSelections) ; ici c'est + // un libelle d'affichage seulement. return $this->db->fetchAll( 'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, p.size_cl, ' - . 'p.image_path, p.display_order ' + . 'p.image_path, p.display_order, mv.name AS maxi_variant_name ' . 'FROM product p JOIN category c ON c.id = p.category_id ' + . 'LEFT JOIN product mv ON mv.id = p.maxi_variant_product_id ' . 'WHERE p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL ' . 'ORDER BY p.display_order, p.name', ); @@ -158,10 +164,13 @@ final class ProductRepository */ public function findForCatalogue(int $id): ?array { + // Meme projection (et meme LEFT JOIN variante Maxi) que la liste : la borne + // recoit maxi_variant_name aussi par lien direct (NULL si pas de variante). return $this->db->fetch( 'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, ' - . 'p.image_path, p.display_order ' + . 'p.image_path, p.display_order, mv.name AS maxi_variant_name ' . 'FROM product p JOIN category c ON c.id = p.category_id ' + . 'LEFT JOIN product mv ON mv.id = p.maxi_variant_product_id ' . 'WHERE p.id = :id AND p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL', ['id' => $id], ); diff --git a/src/app/Controllers/CatalogueController.php b/src/app/Controllers/CatalogueController.php index 14e9ac7..c549cf7 100644 --- a/src/app/Controllers/CatalogueController.php +++ b/src/app/Controllers/CatalogueController.php @@ -200,7 +200,7 @@ class CatalogueController extends Controller * variantes ; vide si le produit n'a pas de dimension taille. Chaque entree * devient {product_id, size_cl, price_cents, label} ; le label humain est * derive du volume ("30 cl") -- aucun slug/enum ne fuit a l'ecran. - * @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, sizes: list} + * @return array{id: int, category_id: int, name: string, description: ?string, price_cents: int, image_path: ?string, display_order: int, maxi_variant_name: ?string, sizes: list} */ private function presentProduct(array $row, array $sizes = []): array { @@ -212,6 +212,10 @@ 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), + // Nom de la variante Maxi de l'accompagnement (ex. "Grande Frite") ; NULL si + // le produit n'a pas de variante. La borne l'affiche en format Maxi pour ne + // pas montrer "Moyenne Frite" sur un menu agrandi. + 'maxi_variant_name' => $this->nullableString($row['maxi_variant_name'] ?? null), 'sizes' => array_map( static function (array $size): array { $cl = (int) ($size['size_cl'] ?? 0); diff --git a/src/public/borne/assets/js/data.js b/src/public/borne/assets/js/data.js index 3145883..741003b 100644 --- a/src/public/borne/assets/js/data.js +++ b/src/public/borne/assets/js/data.js @@ -85,6 +85,9 @@ export function loadProducts() { // borne ne montre un picker que si sizes a plus d'une entree. bySlug[slug].push({ id: p.id, nom: p.name, prix: p.price_cents, image: p.image_path, type: 'produit', + // maxiNom : nom de la variante Maxi (ex. "Grande Frite") quand le produit + // en a une, sinon null. Le composeur de menu l'affiche en format Maxi. + maxiNom: p.maxi_variant_name ?? null, sizes: Array.isArray(p.sizes) ? p.sizes : [], }); } diff --git a/src/public/borne/assets/js/order-panel.js b/src/public/borne/assets/js/order-panel.js index b0726e5..ed57f00 100644 --- a/src/public/borne/assets/js/order-panel.js +++ b/src/public/borne/assets/js/order-panel.js @@ -49,11 +49,14 @@ export function compositionLabels(c) { : ''; out.push(`${c.burger.libelle}${opts}`); } + // libelle fait foi : en Maxi l'accompagnement porte deja sa variante par nom + // ("Grande Frite"). Plus de suffixe " grande" -- il doublait le nom ("Grande Frite + // grande") et mentait pour la boisson (le menu Maxi ne l'agrandit pas). if (c.accompagnement) { - out.push(`${c.accompagnement.libelle}${c.accompagnement.taille === 'G' ? ' grande' : ''}`); + out.push(c.accompagnement.libelle); } if (c.boisson) { - out.push(`${c.boisson.libelle}${c.boisson.taille === 'G' ? ' grande' : ''}`); + out.push(c.boisson.libelle); } if (c.sauce) { out.push(c.sauce.libelle); diff --git a/src/public/borne/assets/js/page-cart.js b/src/public/borne/assets/js/page-cart.js index 1f010d1..505e633 100644 --- a/src/public/borne/assets/js/page-cart.js +++ b/src/public/borne/assets/js/page-cart.js @@ -141,8 +141,9 @@ function renderCart() { /** * Builds the composition breakdown HTML for a menu cart line. - * Renders burger (with personalisation options), accompagnement with taille, - * boisson with taille, sauce, and the supplement summary if applicable. + * 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 @@ -160,11 +161,14 @@ function renderCompositionBlock(item) { : ''; 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)}${c.accompagnement.taille === 'G' ? ' grande' : ' normale'}`); + parts.push(escHtml(c.accompagnement.libelle)); } if (c.boisson) { - parts.push(`${escHtml(c.boisson.libelle)}${c.boisson.taille === 'G' ? ' grande' : ' normale'}`); + parts.push(escHtml(c.boisson.libelle)); } if (c.sauce) { parts.push(escHtml(c.sauce.libelle)); diff --git a/src/public/borne/assets/js/page-product-menu.js b/src/public/borne/assets/js/page-product-menu.js index 624188f..d395fa0 100644 --- a/src/public/borne/assets/js/page-product-menu.js +++ b/src/public/borne/assets/js/page-product-menu.js @@ -26,6 +26,20 @@ import { refreshCartBadge } from './nav.js'; /* slot_type de l'API -> champ de composition attendu par le rendu panier existant. */ const SLOT_FIELD = { side: 'accompagnement', drink: 'boisson', sauce: 'sauce' }; +/** + * Libelle a afficher pour une option selon le format. En Maxi ('M'), un + * accompagnement a une variante agrandie (maxiNom, ex. "Grande Frite") : c'est ce + * nom que le client doit voir au moment de CHOISIR, pas le "Moyenne Frite" de base. + * Sans maxiNom (ex. les boissons, que le menu Maxi n'agrandit pas) ou en Normal, + * on garde le nom de base. Pur. + * @param {Object} option — produit borne {nom, maxiNom?} + * @param {'N'|'M'} size + * @returns {string} + */ +export function optionLabel(option, size) { + return (size === 'M' && option.maxiNom) ? option.maxiNom : option.nom; +} + /* ------------------------------------------------------------------ */ /* Fonctions PURES (cible des tests, sans DOM ni fetch) */ /* ------------------------------------------------------------------ */ @@ -89,9 +103,13 @@ export function buildMenuCartItem(menu, model, { size, selections }) { if (!chosen) continue; // slot optionnel laisse "sans" const field = SLOT_FIELD[slot.slotType]; if (!field) continue; + // libelle PORTE le nom affiche : en Maxi, l'accompagnement prend sa variante + // ("Grande Frite") ; la boisson n'a pas de maxiNom (le menu Maxi ne l'agrandit + // pas) donc garde son nom de base. Plus de suffixe " grande" cote rendu. + const libelle = (isMaxi && chosen.maxiNom) ? chosen.maxiNom : chosen.nom; composition[field] = field === 'sauce' ? { id: chosen.id, libelle: chosen.nom } - : { id: chosen.id, libelle: chosen.nom, taille }; + : { id: chosen.id, libelle, taille }; } return { @@ -294,18 +312,23 @@ function renderSlotStep(body, footer, modal, state, slot) { Sans ` : ''} - ${slot.options.map(o => ` + ${slot.options.map(o => { + // En Maxi, l'accompagnement s'affiche sous sa variante agrandie + // ("Grande Frite") : le client choisit en connaissance de cause. + const label = optionLabel(o, state.size); + return `
  • - `).join('')} + `; + }).join('')} `; body.querySelectorAll('#slot-grid .composer-card').forEach(btn => { diff --git a/tests/Unit/Catalogue/CatalogueControllerTest.php b/tests/Unit/Catalogue/CatalogueControllerTest.php index 1b7d8c7..954c0df 100644 --- a/tests/Unit/Catalogue/CatalogueControllerTest.php +++ b/tests/Unit/Catalogue/CatalogueControllerTest.php @@ -102,6 +102,8 @@ final class CatalogueControllerTest extends TestCase 'id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger', 'description' => 'Pain, steak, cheddar', 'price_cents' => '890', 'vat_rate' => '100', 'image_path' => 'cheese.png', 'display_order' => '1', + // LEFT JOIN variante Maxi : NULL pour un produit sans variante. + 'maxi_variant_name' => null, ], ]; @@ -113,7 +115,7 @@ final class CatalogueControllerTest extends TestCase $product = $payload['data'][0]; self::assertSame( - ['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'sizes'], + ['id', 'category_id', 'name', 'description', 'price_cents', 'image_path', 'display_order', 'maxi_variant_name', 'sizes'], array_keys($product), ); self::assertSame(12, $product['id']); @@ -121,9 +123,31 @@ 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::assertNull($product['maxi_variant_name']); // pas de variante -> null self::assertSame([], $product['sizes']); // produit mono-taille -> sizes vide } + public function testProductsListExposesMaxiVariantName(): void + { + $db = new FakeCatalogueDatabase(); + // "Moyenne Frite" (accompagnement) a une variante Maxi "Grande Frite" : le + // LEFT JOIN remonte mv.name AS maxi_variant_name, expose tel quel a la borne. + $db->productsRows = [ + [ + 'id' => '23', 'category_id' => '4', 'name' => 'Moyenne Frite', + 'description' => null, 'price_cents' => '250', + 'image_path' => 'frite.png', 'display_order' => '1', + 'maxi_variant_name' => 'Grande Frite', + ], + ]; + + $response = $this->controller($db, '/api/products')->products(); + + self::assertSame(200, $response->status()); + $product = $this->decode($response->body())['data'][0]; + self::assertSame('Grande Frite', $product['maxi_variant_name']); + } + public function testProductsListPresentsSizesArrayForDrinkWithVariants(): void { $db = new FakeCatalogueDatabase(); @@ -205,6 +229,8 @@ final class CatalogueControllerTest extends TestCase 'id' => '12', 'category_id' => '3', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => '890', 'vat_rate' => '100', 'image_path' => null, 'display_order' => '1', + // Detail d'un accompagnement avec variante Maxi : le nom doit ressortir. + 'maxi_variant_name' => 'Grande Frite', ]; $response = $this->controller($db, '/api/products/12')->product(['id' => '12']); @@ -215,6 +241,7 @@ final class CatalogueControllerTest extends TestCase self::assertSame(12, $product['id']); self::assertSame(890, $product['price_cents']); self::assertNull($product['description']); + self::assertSame('Grande Frite', $product['maxi_variant_name']); // variante exposee self::assertArrayNotHasKey('vat_rate', $product); // L'id a bien ete lie a la lecture, converti en entier (le repo a recu :id = 12). self::assertSame(12, $db->reads[0]['params']['id'] ?? null); diff --git a/tests/js/composer-slots.test.js b/tests/js/composer-slots.test.js index 5f9df64..ad6c3c9 100644 --- a/tests/js/composer-slots.test.js +++ b/tests/js/composer-slots.test.js @@ -9,14 +9,14 @@ import { test, before } from 'node:test'; import assert from 'node:assert/strict'; import { JSDOM } from 'jsdom'; -let buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable; +let buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable, optionLabel; before(async () => { const dom = new JSDOM('', { url: 'https://kiosk.test/product.html' }); global.window = dom.window; global.document = dom.window.document; global.localStorage = dom.window.localStorage; - ({ buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable } = + ({ buildComposerSteps, buildMenuCartItem, selectionsComplete, composerIsViable, optionLabel } = await import('../../src/public/borne/assets/js/page-product-menu.js')); }); @@ -33,12 +33,14 @@ const detail = () => ({ }); const byId = () => ({ - 100: { id: 100, nom: 'Le 280', prix: 0, image: 'b.png', type: 'produit' }, - 22: { id: 22, nom: 'Frites', prix: 0, image: 'f.png', type: 'produit' }, - 23: { id: 23, nom: 'Potatoes', prix: 0, image: 'p.png', type: 'produit' }, - 14: { id: 14, nom: 'Coca', prix: 0, image: 'c.png', type: 'produit' }, - 15: { id: 15, nom: 'Eau', prix: 0, image: 'e.png', type: 'produit' }, - 47: { id: 47, nom: 'Ketchup', prix: 0, image: 'k.png', type: 'produit' }, + 100: { id: 100, nom: 'Le 280', prix: 0, image: 'b.png', type: 'produit', maxiNom: null }, + // Accompagnements : variante Maxi (maxiNom) renseignee -> agrandissable. + 22: { id: 22, nom: 'Moyenne Frite', prix: 0, image: 'f.png', type: 'produit', maxiNom: 'Grande Frite' }, + 23: { id: 23, nom: 'Potatoes', prix: 0, image: 'p.png', type: 'produit', maxiNom: 'Grande Potatoes' }, + // Boissons : pas de variante Maxi (le menu Maxi n'agrandit pas la boisson). + 14: { id: 14, nom: 'Coca', prix: 0, image: 'c.png', type: 'produit', maxiNom: null }, + 15: { id: 15, nom: 'Eau', prix: 0, image: 'e.png', type: 'produit', maxiNom: null }, + 47: { id: 47, nom: 'Ketchup', prix: 0, image: 'k.png', type: 'produit', maxiNom: null }, }); const menu = { id: 1, nom: 'Menu Le 280', image: 'b.png', type: 'menu' }; @@ -70,7 +72,8 @@ test('buildMenuCartItem Normal: prix normal, pas de supplement, taille N, compos assert.equal(item.prix_cents, 880); assert.equal(item.supplement_cents, 0); assert.equal(item.composition.burger.libelle, 'Le 280'); - assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Frites', taille: 'N' }); + // Normal : l'accompagnement garde son nom de base (pas la variante Maxi). + assert.deepEqual(item.composition.accompagnement, { id: 22, libelle: 'Moyenne Frite', taille: 'N' }); assert.deepEqual(item.composition.boisson, { id: 14, libelle: 'Coca', taille: 'N' }); assert.deepEqual(item.composition.sauce, { id: 47, libelle: 'Ketchup' }); }); @@ -84,6 +87,31 @@ test('buildMenuCartItem Maxi: supplement = maxi - normal, taille G sur side/drin assert.equal(item.composition.boisson.taille, 'G'); }); +test('buildMenuCartItem Maxi: l accompagnement prend sa variante (Grande Frite), pas le nom de base', () => { + const m = buildComposerSteps(detail(), byId()); + const item = buildMenuCartItem(menu, m, { size: 'M', selections: { 1: 14, 16: 22, 31: 47 } }); + assert.equal(item.composition.accompagnement.libelle, 'Grande Frite'); // pas "Moyenne Frite" + // Boisson sans maxiNom : garde son nom de base meme en Maxi (le Maxi ne l agrandit pas). + assert.equal(item.composition.boisson.libelle, 'Coca'); +}); + +test('buildMenuCartItem Normal: l accompagnement garde "Moyenne Frite" (pas de variante)', () => { + const m = buildComposerSteps(detail(), byId()); + const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22, 31: 47 } }); + assert.equal(item.composition.accompagnement.libelle, 'Moyenne Frite'); +}); + +/* --- optionLabel (pur) : libelle affiche au CHOIX selon le format -------- */ + +test('optionLabel: Maxi affiche la variante quand elle existe, sinon le nom de base', () => { + const frite = { nom: 'Moyenne Frite', maxiNom: 'Grande Frite' }; + const coca = { nom: 'Coca', maxiNom: null }; + assert.equal(optionLabel(frite, 'M'), 'Grande Frite'); + assert.equal(optionLabel(frite, 'N'), 'Moyenne Frite'); + assert.equal(optionLabel(coca, 'M'), 'Coca'); // pas de variante -> nom de base + assert.equal(optionLabel(coca, 'N'), 'Coca'); +}); + test('buildMenuCartItem: slot optionnel non choisi -> champ absent de composition', () => { const m = buildComposerSteps(detail(), byId()); const item = buildMenuCartItem(menu, m, { size: 'N', selections: { 1: 14, 16: 22 } }); // pas de sauce diff --git a/tests/js/data.test.js b/tests/js/data.test.js index a50a4fc..325ee55 100644 --- a/tests/js/data.test.js +++ b/tests/js/data.test.js @@ -68,10 +68,20 @@ test('loadProducts groupe les produits par slug a la forme borne (type produit)' const data = await loadProducts(); assert.deepEqual(data.burgers, [ // 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: [] }, + // maxiNom : null par defaut quand l'API n'envoie pas maxi_variant_name. + { id: 10, nom: 'Big Mac', prix: 600, image: 'assets/images/produits/burgers/bigmac.png', type: 'produit', maxiNom: null, sizes: [] }, ]); }); +test('loadProducts reporte maxi_variant_name -> maxiNom (variante Maxi de l accompagnement)', async () => { + const fx = fixtures(); + fx['/api/products'].data[0].maxi_variant_name = 'Grande Frite'; + const { loadProducts } = await freshData(fx); + + const data = await loadProducts(); + assert.equal(data.burgers[0].maxiNom, 'Grande Frite'); +}); + test('loadProducts reporte le tableau sizes du produit (R4) tel quel', async () => { const fx = fixtures(); fx['/api/products'].data[0].sizes = [ diff --git a/tests/js/order-panel.test.js b/tests/js/order-panel.test.js index d6bff38..c60217e 100644 --- a/tests/js/order-panel.test.js +++ b/tests/js/order-panel.test.js @@ -40,8 +40,10 @@ const menu = (over = {}) => ({ supplement_cents: 50, image: 'm.png', composition: { burger: { libelle: 'Big Mac', options: ['sans-oignon', 'avec-fromage'] }, - accompagnement: { libelle: 'Frites', taille: 'G' }, - boisson: { libelle: 'Coca', taille: 'M' }, + // Maxi : l accompagnement porte deja sa variante par NOM (le serveur substitue + // Moyenne -> Grande). Le libelle fait foi, plus de suffixe " grande". + accompagnement: { libelle: 'Grande Frite', taille: 'G' }, + boisson: { libelle: 'Coca', taille: 'G' }, sauce: { libelle: 'Ketchup' }, }, ...over, @@ -63,14 +65,19 @@ test('compositionLabels: undefined -> []', () => { assert.deepEqual(compositionLabels(undefined), []); }); -test('compositionLabels: liste burger(options)/accompagnement(taille)/boisson/sauce', () => { +test('compositionLabels: libelle fait foi, le suffixe " grande" trompeur est supprime', () => { const labels = compositionLabels(menu().composition); assert.deepEqual(labels, [ 'Big Mac (sans oignon, avec fromage)', - 'Frites grande', - 'Coca', + 'Grande Frite', // variante par nom, plus de "Moyenne Frite grande" + 'Coca', // boisson non agrandie : pas de faux " grande" 'Ketchup', ]); + // Garde-fou explicite contre la regression du bug rapporte. + const sideLabel = labels[1]; + assert.equal(sideLabel.includes('Moyenne Frite grande'), false); + assert.equal(sideLabel.endsWith(' grande'), false); + assert.ok(sideLabel.includes('Grande Frite')); }); test('compositionLabels: composants absents ignores sans jeter', () => {