diff --git a/src/app/Controllers/CounterOrderController.php b/src/app/Controllers/CounterOrderController.php index afcbe6f..c21f377 100644 --- a/src/app/Controllers/CounterOrderController.php +++ b/src/app/Controllers/CounterOrderController.php @@ -23,10 +23,14 @@ use App\Order\OrderValidationException; * non par parametre de route) garantit que counter et drive restent etanches : un * equipier drive ne peut pas creer une commande comptoir en falsifiant un champ. * - * Composeur (sous-lot 3b) : produits ET menus composes (slots accompagnement/ - * boisson/sauce + format Normal/Maxi). Les modificateurs d'ingredients (retrait/ajout) - * sont SUPPORTES cote serveur (decodage + resolveModifiers) mais l'UI de selection - * n'est pas encore exposee dans le composeur (sliver differe). + * Composeur (sous-lot 3c) : produits ET menus composes (slots accompagnement/ + * boisson/sauce + format Normal/Maxi) ET modificateurs d'ingredients (retrait/ajout). + * La composition PROPOSABLE de chaque produit a la carte et du burger de chaque menu + * (ingredients is_removable / is_addable + surcout) est embarquee en data-* pour que + * counter-order.js affiche les cases "retirer" / "ajouter +X.XX EUR". Le serveur reste + * seul juge : resolveModifiers revalide chaque modificateur metier (l'ingredient doit + * appartenir a la recette du produit support, etre retirable pour 'remove' / ajoutable + * pour 'add') et fige extra_price_cents (RG-T16) ; le client ne fait que PROPOSER. * Le panier est construit cote client (counter-order.js) et serialise en JSON dans * le champ cache `items_json` ; le serveur (store) le decode, revalide la forme * (RG-T18) puis delegue a createStaffOrder qui resout/calcule cote serveur (RG-T16). @@ -340,36 +344,87 @@ class CounterOrderController extends AdminController */ private function renderForm(GuardResult $guard, string $source, array $values, ?string $error, int $status = 200): Response { + $productRepository = $this->productRepository(); + $products = $productRepository->availableForCatalogue(); + + // Modificateurs proposables par produit a la carte : seuls les produits dont la + // recette offre au moins un ingredient retirable/ajoutable portent une compo. + $products = array_map(function (array $product) use ($productRepository): array { + $product['modifiers'] = $this->proposableModifiers($productRepository, (int) ($product['id'] ?? 0)); + + return $product; + }, $products); + return $this->channelView('admin/counter/new', $source, [ 'title' => 'Nouvelle commande ' . ($source === 'drive' ? 'drive' : 'comptoir') . ' - Wakdo Admin', - 'products' => $this->productRepository()->availableForCatalogue(), - 'menus' => $this->menusWithSlots(), + 'products' => $products, + 'menus' => $this->menusWithSlots($productRepository), 'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')), 'error' => $error, ], $guard, $status); } /** - * Menus commandables enrichis de leurs slots+options (lecture catalogue), pour - * que counter-order.js compose chaque menu SANS appel reseau supplementaire : - * toute la configuration est embarquee en data-* au rendu (page back-office - * authentifiee). La forme `slots` calque slotsWithOptions() (id, name, slot_type, - * is_required, display_order, option_product_ids), consommable par la meme - * logique que page-product-menu.js cote borne. + * Menus commandables enrichis de leurs slots+options (lecture catalogue) ET des + * modificateurs proposables du burger support, pour que counter-order.js compose + * chaque menu SANS appel reseau supplementaire : toute la configuration est + * embarquee en data-* au rendu (page back-office authentifiee). La forme `slots` + * calque slotsWithOptions() (id, name, slot_type, is_required, display_order, + * option_product_ids), consommable par la meme logique que page-product-menu.js + * cote borne ; `burger_modifiers` calque proposableModifiers() (la selection de + * modificateurs d'un menu cible le burger, comme resolveModifiers cote serveur). * * @return list> */ - private function menusWithSlots(): array + private function menusWithSlots(ProductRepository $productRepository): array { - $menus = $this->menuRepository()->availableForCatalogue(); + $menuRepository = $this->menuRepository(); + $menus = $menuRepository->availableForCatalogue(); - return array_map(function (array $menu): array { - $menu['slots'] = $this->menuRepository()->slotsWithOptions((int) ($menu['id'] ?? 0)); + return array_map(function (array $menu) use ($menuRepository, $productRepository): array { + $menu['slots'] = $menuRepository->slotsWithOptions((int) ($menu['id'] ?? 0)); + $menu['burger_modifiers'] = $this->proposableModifiers($productRepository, (int) ($menu['burger_product_id'] ?? 0)); return $menu; }, $menus); } + /** + * Modificateurs PROPOSABLES d'un produit support : les lignes de composition() + * dont l'ingredient est retirable (is_removable=1) OU ajoutable (is_addable=1), + * projetees a ce dont l'UI a besoin (ingredient_id, name, is_removable, is_addable, + * extra_price_cents). Les ingredients ni retirables ni ajoutables sont ECARTES : + * ils n'offrent aucune case a cocher cote client, donc embarquer leur ligne + * alourdirait le data-* sans usage. Le client ne fait que PROPOSER ces choix ; + * resolveModifiers revalide tout cote serveur et fige le surcout (RG-T16). + * + * @return list + */ + private function proposableModifiers(ProductRepository $productRepository, int $productId): array + { + if ($productId <= 0) { + return []; + } + + $out = []; + foreach ($productRepository->composition($productId) as $line) { + $isRemovable = (int) ($line['is_removable'] ?? 0); + $isAddable = (int) ($line['is_addable'] ?? 0); + if ($isRemovable !== 1 && $isAddable !== 1) { + continue; + } + $out[] = [ + 'ingredient_id' => (int) ($line['ingredient_id'] ?? 0), + 'name' => (string) ($line['ingredient_name'] ?? ''), + 'is_removable' => $isRemovable, + 'is_addable' => $isAddable, + 'extra_price_cents' => (int) ($line['extra_price_cents'] ?? 0), + ]; + } + + return $out; + } + /** * Vue de canal : injecte les liens et le titre derives de la source pour que les * vues partagees (comptoir/drive) s'adaptent sans connaitre le decoupage par chemin. diff --git a/src/app/Views/admin/counter/new.php b/src/app/Views/admin/counter/new.php index 2c537e8..9b7771a 100644 --- a/src/app/Views/admin/counter/new.php +++ b/src/app/Views/admin/counter/new.php @@ -3,14 +3,16 @@ declare(strict_types=1); /** - * Composeur de commande comptoir/drive COMPLET (sous-lot 3b), injecte dans + * Composeur de commande comptoir/drive COMPLET (sous-lot 3c), injecte dans * admin/layout.php. Produits commandables ET menus composes (slots * accompagnement/boisson/sauce + format Normal/Maxi + modificateurs d'ingredients). * * Le panier est construit cote client par counter-order.js (CSP 'self', vanilla JS, - * zero handler inline) : il lit produits et menus depuis les data-* de #order-composer, - * et serialise les items en JSON dans le champ cache #items_json a la soumission. Le - * serveur revalide tout (RG-T18) et recalcule les prix (RG-T16). Le tableau de + * zero handler inline) : il lit produits et menus depuis les data-* de + * #counter-order-form (dont la composition PROPOSABLE de chaque produit et du burger + * de chaque menu : ingredients retirables / ajoutables + surcout), et serialise les + * items en JSON dans le champ cache #items_json a la soumission. Le serveur revalide + * tout (RG-T18, resolveModifiers) et recalcule les prix (RG-T16). Le tableau de * quantites produit `qty_` reste present comme repli sans JS (3a). * * Partage par les deux canaux ; la source/landing viennent du controleur. Au canal @@ -50,25 +52,41 @@ $menuRows = isset($menus) && is_array($menus) ? $menus : []; // Projection compacte pour le JS : seules les cles utiles a la composition. Les // prix sont passes pour l'affichage local (le serveur reste seul juge, RG-T16). +// modifiers : ingredients retirables / ajoutables proposables (le client les affiche +// en cases a cocher ; resolveModifiers revalide chacun cote serveur). +$jsModifiers = static fn (mixed $rows): array => array_map( + static fn (array $r): array => [ + 'ingredient_id' => (int) ($r['ingredient_id'] ?? 0), + 'name' => (string) ($r['name'] ?? ''), + 'is_removable' => (int) ($r['is_removable'] ?? 0), + 'is_addable' => (int) ($r['is_addable'] ?? 0), + 'extra_price_cents' => (int) ($r['extra_price_cents'] ?? 0), + ], + is_array($rows) ? $rows : [], +); $jsProducts = array_map( static fn (array $p): array => [ - 'id' => (int) ($p['id'] ?? 0), - 'name' => (string) ($p['name'] ?? ''), - 'price' => (int) ($p['price_cents'] ?? 0), + 'id' => (int) ($p['id'] ?? 0), + 'name' => (string) ($p['name'] ?? ''), + 'price' => (int) ($p['price_cents'] ?? 0), + 'modifiers' => $jsModifiers($p['modifiers'] ?? null), ], $productRows, ); $jsMenus = array_map( - static function (array $m): array { + static function (array $m) use ($jsModifiers): array { /** @var list> $slots */ $slots = isset($m['slots']) && is_array($m['slots']) ? $m['slots'] : []; return [ - 'id' => (int) ($m['id'] ?? 0), - 'name' => (string) ($m['name'] ?? ''), - 'price_normal' => (int) ($m['price_normal_cents'] ?? 0), - 'price_maxi' => (int) ($m['price_maxi_cents'] ?? 0), - 'slots' => array_map( + 'id' => (int) ($m['id'] ?? 0), + 'name' => (string) ($m['name'] ?? ''), + 'price_normal' => (int) ($m['price_normal_cents'] ?? 0), + 'price_maxi' => (int) ($m['price_maxi_cents'] ?? 0), + // Modificateurs du burger support : la selection d'un menu cible le burger + // (resolveModifiers cote serveur le resout sur burger_product_id). + 'burger_modifiers' => $jsModifiers($m['burger_modifiers'] ?? null), + 'slots' => array_map( static fn (array $s): array => [ 'id' => (int) ($s['id'] ?? 0), 'name' => (string) ($s['name'] ?? ''), @@ -123,11 +141,17 @@ $modeOptions = $chan === 'drive' Produit Prix Quantite + Personnaliser - + @@ -137,6 +161,13 @@ $modeOptions = $chan === 'drive' data-product-id="" aria-label="Quantite "> + + + + + diff --git a/src/public/admin/assets/js/counter-order.js b/src/public/admin/assets/js/counter-order.js index 70c31d3..96cfeed 100644 --- a/src/public/admin/assets/js/counter-order.js +++ b/src/public/admin/assets/js/counter-order.js @@ -1,20 +1,26 @@ /* - * counter-order.js — Composeur de commande comptoir/drive (back-office, sous-lot 3b). + * counter-order.js — Composeur de commande comptoir/drive (back-office, sous-lot 3c). * * CSP 'self' : script externe (pas d'inline, zero handler dans le HTML). Les donnees - * (produits commandables, menus + slots + options) sont lues depuis les attributs - * data-* de #counter-order-form. L'equipier ajoute des produits (champ quantite) et - * configure des menus (slots accompagnement/boisson/sauce + format Normal/Maxi). A la - * soumission, le panier est serialise en JSON dans le champ cache #items_json - * (Request::formBody cote serveur ne garde que les scalaires, d'ou le passage par une - * chaine JSON). Le serveur revalide la forme (RG-T18) et recalcule les prix (RG-T16) : - * les libelles/prix affiches ici sont indicatifs, jamais source de verite. + * (produits commandables + leurs modificateurs, menus + slots + format + modificateurs + * du burger) sont lues depuis les attributs data-* de #counter-order-form. L'equipier + * ajoute des produits (champ quantite), personnalise un produit a la carte (retrait/ + * ajout d'ingredients) ou configure un menu (slots + format + retrait/ajout sur le + * burger). A la soumission, le panier est serialise en JSON dans le champ cache + * #items_json (Request::formBody cote serveur ne garde que les scalaires, d'ou le + * passage par une chaine JSON). Le serveur revalide la forme (RG-T18), revalide chaque + * modificateur metier (resolveModifiers) et recalcule les prix (RG-T16) : les libelles/ + * prix affiches ici sont indicatifs, jamais source de verite. * * La logique de slots (un pas par slot, requis/optionnel, format) calque - * page-product-menu.js (borne) ; seul le rendu differe (idiome back-office, pas de - * style borne). Les menus configures vivent dans un etat JS et sont rendus dans le - * panier ; les produits sont derives a la soumission depuis les champs qty_ (repli - * sans JS conserve : le serveur accepte aussi qty_ si #items_json est vide). + * page-product-menu.js (borne) ; la logique de modificateurs (cases "retirer" pour les + * ingredients is_removable, "ajouter +X.XX EUR" pour les is_addable) calque l'UX + * borne. Seul le rendu differe (idiome back-office, pas de style borne). Les lignes + * configurees (produit personnalise / menu) vivent dans un etat JS et sont rendues dans + * le panier ; les produits sans modificateur sont derives a la soumission depuis les + * champs qty_ (repli sans JS conserve : le serveur accepte aussi qty_ si + * #items_json est vide). Un produit personnalisable est routé par la modale (sa + * quantite directe est ignoree quand JS s'execute) pour ne pas le compter deux fois. * * Module CommonJS (admin = racine CommonJS, comme pin-modal.js) : init(doc) est * exporte pour les tests et auto-appele au DOMContentLoaded en production. @@ -35,6 +41,12 @@ } } + // Surcout d'un ajout, formate en euros (affichage local indicatif ; le serveur + // refige extra_price_cents, RG-T16). + function formatExtra(cents) { + return '+' + (Number(cents) / 100).toFixed(2).replace('.', ',') + ' EUR'; + } + // Etapes composables d'un menu : burger impose ignore (non choisi ici), un pas par // slot gere, trie par display_order, options resolues via l'index produit. Pur. function composerSteps(menu, productById) { @@ -70,19 +82,30 @@ return; } - var products = parseData(form, 'products', '[]'); // [{id, name, price}] - var menus = parseData(form, 'menus', '[]'); // [{id, name, price_normal, price_maxi, slots:[...]}] + var products = parseData(form, 'products', '[]'); // [{id, name, price, modifiers:[...]}] + var menus = parseData(form, 'menus', '[]'); // [{id, name, price_normal, price_maxi, burger_modifiers:[...], slots:[...]}] - // Index produit par id : resolution des libelles d'options de slot (affichage). + // Index produit par id : resolution des libelles d'options de slot + acces aux + // modificateurs proposables d'un produit a la carte. var productById = {}; products.forEach(function (p) { productById[Number(p.id)] = p; }); - // Menus configures par l'equipier : items prets a serialiser, avec libelle recap. + // Lignes configurees par l'equipier : items prets a serialiser, avec libelle recap. + // menuLines : menus configures ; productLines : produits personnalises (modifiers). var menuLines = []; + var productLines = []; var lineSeq = 0; + // Produits routes par la modale (ils portent un bouton "Personnaliser") : leur + // quantite directe qty_ est ignoree a la serialisation pour eviter le double + // comptage (le champ reste present pour le repli sans JS). + var configurableIds = {}; + Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) { + configurableIds[Number(btn.dataset.productId)] = true; + }); + function el(tag, className) { var e = doc.createElement(tag); if (className) { @@ -91,23 +114,127 @@ return e; } + /* ----------------------------------------------------------------- */ + /* Modificateurs : cases "retirer" / "ajouter +X.XX EUR" (UX borne) */ + /* ----------------------------------------------------------------- */ + + // Rend les controles de modificateurs d'un produit support dans un conteneur. + // selectedRemove/selectedAdd : maps ingredient_id -> bool, mutees au changement. + // Calque la borne : un ingredient is_removable propose "retirer", un is_addable + // propose "ajouter (+surcout)". Un meme ingredient peut etre les deux. + function renderModifierControls(modifiers, selectedRemove, selectedAdd) { + var block = el('div', 'menu-composer__modifiers'); + if (!modifiers || !modifiers.length) { + return block; + } + var legend = el('p', 'menu-composer__legend'); + legend.textContent = 'Personnalisation'; + block.appendChild(legend); + + modifiers.forEach(function (mod) { + var ingId = Number(mod.ingredient_id); + if (Number(mod.is_removable) === 1) { + var remLab = el('label', 'menu-composer__modifier'); + var remBox = el('input'); + remBox.type = 'checkbox'; + remBox.className = 'menu-composer__modifier-remove'; + remBox.dataset.ingredientId = String(ingId); + remBox.addEventListener('change', function () { + if (remBox.checked) { + selectedRemove[ingId] = true; + } else { + delete selectedRemove[ingId]; + } + }); + remLab.appendChild(remBox); + remLab.appendChild(doc.createTextNode(' Sans ' + String(mod.name))); + block.appendChild(remLab); + } + if (Number(mod.is_addable) === 1) { + var addLab = el('label', 'menu-composer__modifier'); + var addBox = el('input'); + addBox.type = 'checkbox'; + addBox.className = 'menu-composer__modifier-add'; + addBox.dataset.ingredientId = String(ingId); + addBox.addEventListener('change', function () { + if (addBox.checked) { + selectedAdd[ingId] = true; + } else { + delete selectedAdd[ingId]; + } + }); + addLab.appendChild(addBox); + addLab.appendChild(doc.createTextNode(' Extra ' + String(mod.name) + ' (' + formatExtra(mod.extra_price_cents) + ')')); + block.appendChild(addLab); + } + }); + + return block; + } + + // Construit la liste serialisable [{ingredient_id, action}] depuis les maps + // selectedRemove / selectedAdd (remove d'abord, puis add ; un ingredient a la + // fois retire et ajoute resterait deux entrees, mais l'UX coche rarement les deux). + function buildModifiers(selectedRemove, selectedAdd) { + var out = []; + Object.keys(selectedRemove).forEach(function (id) { + out.push({ ingredient_id: Number(id), action: 'remove' }); + }); + Object.keys(selectedAdd).forEach(function (id) { + out.push({ ingredient_id: Number(id), action: 'add' }); + }); + return out; + } + + // Libelle recap des modificateurs choisis (ex. "sans Oignon, +Bacon"), resolu + // via la liste de modificateurs proposables (pour le nom de l'ingredient). + function modifierLabel(modifiers, chosen) { + if (!chosen || !chosen.length) { + return ''; + } + var nameById = {}; + (modifiers || []).forEach(function (m) { + nameById[Number(m.ingredient_id)] = String(m.name); + }); + var parts = chosen.map(function (c) { + var name = nameById[Number(c.ingredient_id)] || ('#' + c.ingredient_id); + return c.action === 'add' ? ('+' + name) : ('sans ' + name); + }); + return parts.join(', '); + } + /* ----------------------------------------------------------------- */ /* Serialisation du panier -> #items_json */ /* ----------------------------------------------------------------- */ - // Produits : derives des champs qty_ (>= 1). Menus : items configures. La - // forme calque ce qu'attend OrderRepository::resolveLine (revalide cote serveur). + // Produits sans modificateur : derives des champs qty_ (>= 1) NON routes par + // la modale. Produits personnalises : productLines. Menus : menuLines. La forme + // calque ce qu'attend OrderRepository::resolveLine (revalide cote serveur). function serialize() { var items = []; Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) { var productId = Number(input.dataset.productId); + if (configurableIds[productId]) { + return; // route par la modale -> pas de double comptage. + } var quantity = parseInt(input.value, 10); if (productId > 0 && quantity >= 1) { items.push({ type: 'product', product_id: productId, quantity: quantity }); } }); + productLines.forEach(function (line) { + items.push({ + type: 'product', + product_id: line.productId, + quantity: line.quantity, + modifiers: line.modifiers.map(function (m) { + return { ingredient_id: m.ingredient_id, action: m.action }; + }), + }); + }); + menuLines.forEach(function (line) { items.push({ type: 'menu', @@ -117,6 +244,9 @@ selections: line.selections.map(function (s) { return { menu_slot_id: s.slotId, product_id: s.productId }; }), + modifiers: line.modifiers.map(function (m) { + return { ingredient_id: m.ingredient_id, action: m.action }; + }), }); }); @@ -124,7 +254,7 @@ } /* ----------------------------------------------------------------- */ - /* Rendu du panier (recap des menus configures) */ + /* Rendu du panier (recap des lignes configurees) */ /* ----------------------------------------------------------------- */ function renderCart() { @@ -132,6 +262,30 @@ node.parentNode.removeChild(node); }); + productLines.forEach(function (line) { + var li = el('li', 'order-cart__line'); + + var label = el('span', 'order-cart__label'); + var text = line.productName + ' x' + line.quantity; + var modLabel = modifierLabel(line.proposable, line.modifiers); + if (modLabel) { + text += ' (' + modLabel + ')'; + } + label.textContent = text; + li.appendChild(label); + + var removeBtn = el('button', 'btn btn-secondary order-cart__remove'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Retirer'; + removeBtn.addEventListener('click', function () { + productLines = productLines.filter(function (l) { return l.localId !== line.localId; }); + renderCart(); + }); + li.appendChild(removeBtn); + + cart.appendChild(li); + }); + menuLines.forEach(function (line) { var li = el('li', 'order-cart__line'); @@ -143,7 +297,12 @@ parts.push(p.name); } }); - label.textContent = parts.join(' - '); + var text = parts.join(' - '); + var modLabel = modifierLabel(line.proposable, line.modifiers); + if (modLabel) { + text += ' (' + modLabel + ')'; + } + label.textContent = text; li.appendChild(label); var removeBtn = el('button', 'btn btn-secondary order-cart__remove'); @@ -159,12 +318,12 @@ }); if (cartEmpty) { - cartEmpty.style.display = menuLines.length ? 'none' : ''; + cartEmpty.style.display = (productLines.length || menuLines.length) ? 'none' : ''; } } /* ----------------------------------------------------------------- */ - /* Modale de configuration d'un menu */ + /* Modales de configuration */ /* ----------------------------------------------------------------- */ function closeComposer() { @@ -172,11 +331,74 @@ modalHost.setAttribute('hidden', ''); } - // Ouvre la modale : choix du format puis une selection par slot. Pre-selectionne - // le 1er choix de chaque slot requis (calque page-product-menu.js). + // Modale d'un produit a la carte : quantite + modificateurs (retrait/ajout). + function openProductComposer(product) { + var proposable = product.modifiers || []; + var state = { quantity: 1, selectedRemove: {}, selectedAdd: {} }; + + modalHost.textContent = ''; + var panel = el('div', 'menu-composer'); + + var title = el('h2', 'menu-composer__title'); + title.textContent = product.name; + panel.appendChild(title); + + // Quantite + var qtyBlock = el('div', 'menu-composer__slot'); + var qtyLab = el('label', 'menu-composer__legend'); + qtyLab.textContent = 'Quantite'; + qtyLab.setAttribute('for', 'composer-product-qty'); + qtyBlock.appendChild(qtyLab); + var qtyInput = el('input', 'form-input menu-composer__qty'); + qtyInput.type = 'number'; + qtyInput.id = 'composer-product-qty'; + qtyInput.min = '1'; + qtyInput.value = '1'; + qtyInput.addEventListener('change', function () { + var v = parseInt(qtyInput.value, 10); + state.quantity = v >= 1 ? v : 1; + }); + qtyBlock.appendChild(qtyInput); + panel.appendChild(qtyBlock); + + // Modificateurs + panel.appendChild(renderModifierControls(proposable, state.selectedRemove, state.selectedAdd)); + + var actions = el('div', 'menu-composer__actions'); + var addBtn = el('button', 'btn btn-primary menu-composer__add'); + addBtn.type = 'button'; + addBtn.textContent = 'Ajouter au panier'; + addBtn.addEventListener('click', function () { + productLines.push({ + localId: ++lineSeq, + productId: Number(product.id), + productName: product.name, + quantity: state.quantity, + proposable: proposable, + modifiers: buildModifiers(state.selectedRemove, state.selectedAdd), + }); + renderCart(); + closeComposer(); + }); + actions.appendChild(addBtn); + + var cancelBtn = el('button', 'btn btn-secondary menu-composer__cancel'); + cancelBtn.type = 'button'; + cancelBtn.textContent = 'Annuler'; + cancelBtn.addEventListener('click', closeComposer); + actions.appendChild(cancelBtn); + + panel.appendChild(actions); + modalHost.appendChild(panel); + modalHost.removeAttribute('hidden'); + } + + // Ouvre la modale d'un menu : choix du format, une selection par slot, puis les + // modificateurs du burger. Pre-selectionne le 1er choix de chaque slot requis. function openComposer(menu) { var steps = composerSteps(menu, productById); - var state = { format: 'normal', selections: {} }; + var proposable = menu.burger_modifiers || []; + var state = { format: 'normal', selections: {}, selectedRemove: {}, selectedAdd: {} }; steps.forEach(function (step) { if (step.isRequired && step.options[0]) { state.selections[step.id] = step.options[0].id; @@ -253,6 +475,9 @@ panel.appendChild(block); }); + // Modificateurs du burger support (retrait/ajout d'ingredients). + panel.appendChild(renderModifierControls(proposable, state.selectedRemove, state.selectedAdd)); + // Actions : ajouter (si tous les requis choisis) / annuler. var actions = el('div', 'menu-composer__actions'); var addBtn = el('button', 'btn btn-primary menu-composer__add'); @@ -277,6 +502,8 @@ menuName: menu.name, format: state.format, selections: selections, + proposable: proposable, + modifiers: buildModifiers(state.selectedRemove, state.selectedAdd), }); renderCart(); closeComposer(); @@ -298,6 +525,16 @@ /* Cablage */ /* ----------------------------------------------------------------- */ + Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) { + btn.addEventListener('click', function () { + var productId = Number(btn.dataset.productId); + var product = productById[productId]; + if (product) { + openProductComposer(product); + } + }); + }); + Array.prototype.forEach.call(doc.querySelectorAll('.menu-configure'), function (btn) { btn.addEventListener('click', function () { var menuId = Number(btn.dataset.menuId); diff --git a/tests/Unit/Admin/CounterOrderControllerTest.php b/tests/Unit/Admin/CounterOrderControllerTest.php index d12c769..0279bfa 100644 --- a/tests/Unit/Admin/CounterOrderControllerTest.php +++ b/tests/Unit/Admin/CounterOrderControllerTest.php @@ -322,6 +322,104 @@ final class CounterOrderControllerTest extends TestCase self::assertFalse($db->wrote('INSERT INTO customer_order')); } + public function testCreateExposesProductComposition(): void + { + // create() joint la composition PROPOSABLE (modificateurs) de chaque produit au + // composeur : embarquee en data-* JSON (htmlspecialchars(json_encode), RG-T15). + $db = $this->permittedDb(); + $db->productsRows = [ + ['id' => 12, 'category_id' => 1, 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1], + ]; + // composition() : un ingredient retirable (Oignon) + un ajoutable (Bacon, +50c). + $db->compositionRows = [ + ['product_id' => 12, 'ingredient_id' => 3, 'ingredient_name' => 'Oignon', 'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 0, 'quantity_normal' => 1, 'quantity_maxi' => 1], + ['product_id' => 12, 'ingredient_id' => 8, 'ingredient_name' => 'Bacon', 'is_removable' => 0, 'is_addable' => 1, 'extra_price_cents' => 50, 'quantity_normal' => 1, 'quantity_maxi' => 1], + ]; + + $response = $this->controller($this->get('/counter/orders/new'), $db)->create(); + + self::assertSame(200, $response->status()); + $body = $response->body(); + // data-products encode le JSON avec htmlspecialchars : les guillemets sont + // echappes en ". On cherche les fragments echappes (forme reellement rendue). + self::assertStringContainsString('ingredient_id":3', $body); + self::assertStringContainsString('ingredient_id":8', $body); + self::assertStringContainsString('Oignon', $body); + self::assertStringContainsString('Bacon', $body); + // Bouton de personnalisation expose pour le produit a modificateurs. + self::assertStringContainsString('product-configure', $body); + self::assertStringContainsString('Personnaliser', $body); + } + + public function testStoreCreatesProductOrderWithModifiers(): void + { + // store() decode items_json portant des modifiers, resolveModifiers les valide + // contre la recette du produit, et persiste les lignes order_item_modifier. + $db = $this->permittedDb(); + $db->productRow = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1]; + // Recette : Oignon retirable, Bacon ajoutable (+50c). resolveModifiers lit ces lignes. + $db->compositionRows = [ + ['ingredient_id' => 3, 'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 0, 'quantity_normal' => 1, 'quantity_maxi' => 1], + ['ingredient_id' => 8, 'is_removable' => 0, 'is_addable' => 1, 'extra_price_cents' => 50, 'quantity_normal' => 1, 'quantity_maxi' => 1], + ]; + $db->lastInsertId = 100; + $db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 940, 'status' => 'pending_payment']; + + $items = json_encode([ + ['type' => 'product', 'product_id' => 12, 'quantity' => 1, 'modifiers' => [ + ['ingredient_id' => 3, 'action' => 'remove'], + ['ingredient_id' => 8, 'action' => 'add'], + ]], + ]); + $request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'dine_in', 'items_json' => (string) $items], '/counter/orders'); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(302, $response->status()); + self::assertSame('/counter/orders', $response->header('Location')); + // Deux lignes order_item_modifier persistees (remove Oignon + add Bacon). + self::assertTrue($db->wrote('INSERT INTO order_item_modifier')); + $modifierWrites = array_values(array_filter( + $db->writes, + static fn (array $w): bool => str_contains($w['sql'], 'INSERT INTO order_item_modifier'), + )); + self::assertCount(2, $modifierWrites); + // L'ajout fige extra_price_cents=50 (snapshot recette, RG-T16) ; le retrait 0. + $byAction = []; + foreach ($modifierWrites as $w) { + $byAction[(string) $w['params']['act']] = $w['params']; + } + self::assertSame(3, $byAction['remove']['ing']); + self::assertSame(0, $byAction['remove']['extra']); + self::assertSame(8, $byAction['add']['ing']); + self::assertSame(50, $byAction['add']['extra']); + } + + public function testStoreRejectsModifierOnNonAddableIngredient(): void + { + // RG-T18 / INGREDIENT_NOT_ADDABLE : un 'add' sur un ingredient is_addable=0 est + // rejete par resolveModifiers -> re-rendu 422, rien de persiste. + $db = $this->permittedDb(); + $db->productRow = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1]; + // Oignon est retirable mais PAS ajoutable : un 'add' doit etre refuse. + $db->compositionRows = [ + ['ingredient_id' => 3, 'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 0, 'quantity_normal' => 1, 'quantity_maxi' => 1], + ]; + + $items = json_encode([ + ['type' => 'product', 'product_id' => 12, 'quantity' => 1, 'modifiers' => [ + ['ingredient_id' => 3, 'action' => 'add'], + ]], + ]); + $request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'dine_in', 'items_json' => (string) $items], '/counter/orders'); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO customer_order')); + self::assertFalse($db->wrote('INSERT INTO order_item_modifier')); + } + public function testStoreRejectsMenuSelectionOutsideSlotOptions(): void { // RG-T18/INVALID_SELECTION : une selection hors des options du slot est rejetee diff --git a/tests/js/counter-order.test.js b/tests/js/counter-order.test.js index aece427..11704ce 100644 --- a/tests/js/counter-order.test.js +++ b/tests/js/counter-order.test.js @@ -1,12 +1,13 @@ /* - * Tests du composeur de commande comptoir/drive (counter-order.js, sous-lot 3b). + * Tests du composeur de commande comptoir/drive (counter-order.js, sous-lot 3c). * node:test + jsdom. Couvre la serialisation du panier dans #items_json : * - ajout produit (champ quantite) -> item {type:'product', ...} - * - configuration menu (slots + format Maxi) -> item {type:'menu', menu_id, format, selections} + * - personnalisation produit (retrait + ajout d'ingredients) -> modifiers:[...] + * - configuration menu (slots + format Maxi + modificateurs burger) -> item menu * - menu non configurable (slot_type non gere) ignore (anti-perte silencieuse) * - * Le serveur revalide la forme (RG-T18) et recalcule les prix (RG-T16) : on - * n'asserte que la FORME emise, pas un prix. + * Le serveur revalide la forme (RG-T18), revalide chaque modificateur (resolveModifiers) + * et recalcule les prix (RG-T16) : on n'asserte que la FORME emise, pas un prix. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -16,10 +17,16 @@ import { JSDOM } from 'jsdom'; import counterOrder from '../../src/public/admin/assets/js/counter-order.js'; const PRODUCTS = [ - { id: 12, name: 'Cheeseburger', price: 890 }, - { id: 22, name: 'Frites', price: 250 }, - { id: 14, name: 'Coca', price: 200 }, - { id: 47, name: 'Ketchup', price: 0 }, + { + id: 12, name: 'Cheeseburger', price: 890, + modifiers: [ + { ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 }, + { ingredient_id: 8, name: 'Bacon', is_removable: 0, is_addable: 1, extra_price_cents: 50 }, + ], + }, + { id: 22, name: 'Frites', price: 250, modifiers: [] }, + { id: 14, name: 'Coca', price: 200, modifiers: [] }, + { id: 47, name: 'Ketchup', price: 0, modifiers: [] }, ]; const MENUS = [ @@ -28,6 +35,10 @@ const MENUS = [ name: 'Menu Cheeseburger', price_normal: 990, price_maxi: 1190, + burger_modifiers: [ + { ingredient_id: 3, name: 'Oignon', is_removable: 1, is_addable: 0, extra_price_cents: 0 }, + { ingredient_id: 8, name: 'Bacon', is_removable: 0, is_addable: 1, extra_price_cents: 50 }, + ], slots: [ { id: 16, name: 'Accompagnement', slot_type: 'side', is_required: 1, display_order: 2, option_product_ids: [22] }, { id: 1, name: 'Boisson', slot_type: 'drink', is_required: 1, display_order: 1, option_product_ids: [14] }, @@ -40,15 +51,22 @@ function setup(menus = MENUS) { const menuItems = menus .map(m => `
  • `) .join(''); - const qtyInputs = PRODUCTS - .map(p => ``) + // qty_ pour tous les produits (repli sans JS) ; bouton "Personnaliser" pour + // ceux dont la recette offre des modificateurs (calque la vue new.php). + const productRows = PRODUCTS + .map(p => { + const configure = (p.modifiers && p.modifiers.length) + ? `` + : ''; + return `${configure}`; + }) .join(''); const dom = new JSDOM( '' + '
    ` + ' ' + - qtyInputs + + productRows + ' ' + '
    • Panier vide.
    ' + ' ' + @@ -68,16 +86,66 @@ function itemsJson(dom) { return JSON.parse(dom.window.document.getElementById('items_json').value || '[]'); } -test('ajout produit (quantite) -> items_json contient {type:product}', () => { +test('ajout produit sans modificateur (quantite) -> items_json contient {type:product}', () => { const dom = setup(); counterOrder.init(dom.window.document); - const qty = dom.window.document.getElementById('qty_12'); + // Frites (22) n'a pas de modificateur -> pas de bouton Personnaliser -> chemin qty_. + const qty = dom.window.document.getElementById('qty_22'); qty.value = '2'; fireSubmit(dom); const items = itemsJson(dom); - assert.deepEqual(items, [{ type: 'product', product_id: 12, quantity: 2 }]); + assert.deepEqual(items, [{ type: 'product', product_id: 22, quantity: 2 }]); +}); + +test('produit personnalisable : qty directe ignoree (route par la modale, anti-double-comptage)', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + // Cheeseburger (12) porte un bouton Personnaliser : son qty_ est ignore par le + // JS pour eviter le double comptage avec la ligne configuree. + doc.getElementById('qty_12').value = '3'; + fireSubmit(dom); + + assert.deepEqual(itemsJson(dom), []); +}); + +test('personnalisation produit (retrait + ajout) -> items_json porte modifiers:[remove, add]', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + // Ouvre la modale du produit 12 (Cheeseburger). + doc.querySelector('.product-configure[data-product-id="12"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + const modal = doc.getElementById('menu-composer-modal'); + assert.equal(modal.hasAttribute('hidden'), false); + + // Coche "sans Oignon" (retrait, ingredient 3) et "extra Bacon" (ajout, ingredient 8). + const removeBox = modal.querySelector('.menu-composer__modifier-remove[data-ingredient-id="3"]'); + removeBox.checked = true; + removeBox.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + const addBox = modal.querySelector('.menu-composer__modifier-add[data-ingredient-id="8"]'); + addBox.checked = true; + addBox.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + + assert.equal(modal.hasAttribute('hidden'), true); + assert.ok(doc.querySelector('.order-cart__line')); + + fireSubmit(dom); + const items = itemsJson(dom); + assert.equal(items.length, 1); + assert.equal(items[0].type, 'product'); + assert.equal(items[0].product_id, 12); + assert.equal(items[0].quantity, 1); + assert.deepEqual(items[0].modifiers, [ + { ingredient_id: 3, action: 'remove' }, + { ingredient_id: 8, action: 'add' }, + ]); }); test('quantite 0 ignoree -> panier vide serialise []', () => { @@ -168,7 +236,8 @@ test('produit + menu combines -> items_json contient les deux lignes', () => { const doc = dom.window.document; counterOrder.init(doc); - doc.getElementById('qty_12').value = '1'; + // Frites (22) sans modificateur -> chemin qty_. + doc.getElementById('qty_22').value = '1'; doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); const modal = doc.getElementById('menu-composer-modal'); @@ -181,6 +250,27 @@ test('produit + menu combines -> items_json contient les deux lignes', () => { assert.equal(items.filter(i => i.type === 'menu').length, 1); }); +test('configuration menu avec modificateur burger -> item menu porte modifiers:[remove]', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + const modal = doc.getElementById('menu-composer-modal'); + + // Retire l'oignon du burger (ingredient 3, is_removable). + const removeBox = modal.querySelector('.menu-composer__modifier-remove[data-ingredient-id="3"]'); + removeBox.checked = true; + removeBox.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + fireSubmit(dom); + + const items = itemsJson(dom); + assert.equal(items[0].type, 'menu'); + assert.deepEqual(items[0].modifiers, [{ ingredient_id: 3, action: 'remove' }]); +}); + test('composerSteps: slot_type non gere (dessert) ignore, slots tries par display_order', () => { const productById = {}; PRODUCTS.forEach(p => { productById[p.id] = p; });