diff --git a/src/app/Controllers/CounterOrderController.php b/src/app/Controllers/CounterOrderController.php index 623b16f..afcbe6f 100644 --- a/src/app/Controllers/CounterOrderController.php +++ b/src/app/Controllers/CounterOrderController.php @@ -23,9 +23,16 @@ 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. * - * Version PRODUITS uniquement (sous-lot 3a) : les menus composes (slots) viendront - * dans un sous-lot ulterieur. La commande est creee directement `paid` (encaissement - * immediat, RG-5/POST-1) sans PIN : la permission order.create suffit. + * 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). + * 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). + * Le chemin legacy `qty_` (3a) reste accepte en repli quand `items_json` est + * absent (degradation sans JS). La commande est creee directement `paid` + * (encaissement immediat, RG-5/POST-1) sans PIN : la permission order.create suffit. * * Non `final` : les tests sous-classent pour injecter des doubles (db/orderQuery/orders). */ @@ -61,7 +68,9 @@ class CounterOrderController extends AdminController } /** - * Composeur de commande (GET .../new) : produits commandables + select service_mode. + * Composeur de commande (GET .../new) : produits commandables, menus composes + * (slots + options) + select service_mode. Tout est passe a la vue qui l'embarque + * en data-* pour counter-order.js (aucun endpoint slots : page back-office authentifiee). * * @param array $params */ @@ -78,9 +87,12 @@ class CounterOrderController extends AdminController } /** - * Soumission de la commande (POST). Construit le panier depuis les quantites - * saisies, encaisse via createStaffOrder (source derivee du chemin, acteur = - * equipier authentifie). Panier vide / RG-T09 / indisponibilite -> flash + re-rendu. + * Soumission de la commande (POST). Le panier est decode depuis le champ cache + * `items_json` (produits + menus composes construits cote client) ; en repli + * sans JS, les quantites legacy `qty_` (3a) sont relues. Chaque item est + * revalide dans sa FORME (RG-T18) cote serveur, puis createStaffOrder resout les + * references, recalcule les prix (RG-T16) et encaisse (source derivee du chemin, + * acteur = equipier authentifie). Panier vide / RG-T09 / indisponibilite -> flash + re-rendu. * * @param array $params */ @@ -99,22 +111,16 @@ class CounterOrderController extends AdminController $source = $this->source(); $serviceMode = (string) ($form['service_mode'] ?? ''); - // Panier = une ligne produit par quantite >= 1. Le champ s'appelle qty_ - // (un champ nombre par produit listable) ; on ne retient que les positifs. - $items = []; - foreach ($form as $key => $value) { - if (!str_starts_with($key, 'qty_')) { - continue; - } - $productId = (int) substr($key, 4); - $quantity = ctype_digit(trim($value)) ? (int) $value : 0; - if ($productId > 0 && $quantity >= 1) { - $items[] = ['type' => 'product', 'product_id' => $productId, 'quantity' => $quantity]; - } - } + // Chemin unifie : le panier construit par counter-order.js arrive serialise + // dans items_json. Quand il est present, il fait foi ; les quantites legacy + // qty_ ne servent qu'au repli sans JS (degradation gracieuse). + $itemsJson = (string) ($form['items_json'] ?? ''); + $items = trim($itemsJson) !== '' + ? $this->decodeItems($itemsJson) + : $this->legacyQuantities($form); if ($items === []) { - return $this->renderForm($guard, $source, $form, 'Ajoutez au moins un produit (quantite >= 1).', 422); + return $this->renderForm($guard, $source, $form, 'Ajoutez au moins un produit ou un menu.', 422); } try { @@ -132,6 +138,154 @@ class CounterOrderController extends AdminController return $this->redirect($this->landing($source)); } + /** + * Decode + normalise le panier soumis en JSON par counter-order.js (RG-T18 : + * revalidation de la FORME cote serveur ; le client n'est jamais cru). Chaque + * item mal forme est ECARTE silencieusement (un client falsifie ne bloque pas le + * traitement des items valides ; un panier integralement invalide retombe vide -> + * 422). La validation METIER (existence, disponibilite, options de slot, recette) + * et le calcul de prix restent dans OrderRepository::resolveLine (source unique). + * + * Forme produite (calque sur ce qu'attend resolveLine) : + * - produit : {type:'product', product_id:int>0, quantity:int>=1, modifiers?:[...]} + * - menu : {type:'menu', menu_id:int>0, quantity:int>=1, format:'normal'|'maxi', + * selections:[{menu_slot_id:int>0, product_id:int>0}], modifiers?:[...]} + * - modifier: {ingredient_id:int>0, action:'add'|'remove'} + * + * @return list> + */ + private function decodeItems(string $json): array + { + /** @var mixed $decoded */ + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + return []; + } + + $items = []; + foreach ($decoded as $raw) { + if (!is_array($raw)) { + continue; + } + $type = (string) ($raw['type'] ?? ''); + $quantity = $this->positiveInt($raw['quantity'] ?? null, 1); + $modifiers = $this->normaliseModifiers($raw['modifiers'] ?? null); + + if ($type === 'product') { + $productId = $this->positiveInt($raw['product_id'] ?? null, 0); + if ($productId > 0) { + $items[] = [ + 'type' => 'product', + 'product_id' => $productId, + 'quantity' => $quantity, + 'modifiers' => $modifiers, + ]; + } + continue; + } + + if ($type === 'menu') { + $menuId = $this->positiveInt($raw['menu_id'] ?? null, 0); + if ($menuId > 0) { + $items[] = [ + 'type' => 'menu', + 'menu_id' => $menuId, + 'quantity' => $quantity, + 'format' => ($raw['format'] ?? 'normal') === 'maxi' ? 'maxi' : 'normal', + 'selections' => $this->normaliseSelections($raw['selections'] ?? null), + 'modifiers' => $modifiers, + ]; + } + } + } + + return $items; + } + + /** + * Selections de slot normalisees (forme), revalidees metier par resolveSelections. + * + * @return list + */ + private function normaliseSelections(mixed $raw): array + { + if (!is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $sel) { + if (!is_array($sel)) { + continue; + } + $slotId = $this->positiveInt($sel['menu_slot_id'] ?? null, 0); + $productId = $this->positiveInt($sel['product_id'] ?? null, 0); + if ($slotId > 0 && $productId > 0) { + $out[] = ['menu_slot_id' => $slotId, 'product_id' => $productId]; + } + } + + return $out; + } + + /** + * Modificateurs d'ingredients normalises (forme), revalides metier par resolveModifiers. + * + * @return list + */ + private function normaliseModifiers(mixed $raw): array + { + if (!is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $mod) { + if (!is_array($mod)) { + continue; + } + $ingredientId = $this->positiveInt($mod['ingredient_id'] ?? null, 0); + $action = ($mod['action'] ?? '') === 'add' ? 'add' : 'remove'; + if ($ingredientId > 0) { + $out[] = ['ingredient_id' => $ingredientId, 'action' => $action]; + } + } + + return $out; + } + + /** + * Entier positif tolerant (le JSON decode peut livrer int|string|float|null). + */ + private function positiveInt(mixed $value, int $minimum): int + { + $int = is_numeric($value) ? (int) $value : 0; + + return $int >= $minimum ? max($int, $minimum) : $minimum; + } + + /** + * Repli sans JS : panier produit construit depuis les champs `qty_` (3a). + * Conserve pour ne pas casser la saisie quand counter-order.js ne s'execute pas. + * + * @param array $form + * @return list> + */ + private function legacyQuantities(array $form): array + { + $items = []; + foreach ($form as $key => $value) { + if (!is_string($key) || !str_starts_with($key, 'qty_')) { + continue; + } + $productId = (int) substr($key, 4); + $quantity = ctype_digit(trim((string) $value)) ? (int) $value : 0; + if ($productId > 0 && $quantity >= 1) { + $items[] = ['type' => 'product', 'product_id' => $productId, 'quantity' => $quantity]; + } + } + + return $items; + } + protected function orderQuery(): OrderQueryRepository { return new OrderQueryRepository($this->db()); @@ -149,6 +303,11 @@ class CounterOrderController extends AdminController return new ProductRepository($this->db()); } + protected function menuRepository(): MenuRepository + { + return new MenuRepository($this->db()); + } + /** * Canal derive du chemin de la requete : tout chemin sous /drive est le canal * drive, le reste (/counter...) est le comptoir. Source unique de la verite pour @@ -184,11 +343,33 @@ class CounterOrderController extends AdminController return $this->channelView('admin/counter/new', $source, [ 'title' => 'Nouvelle commande ' . ($source === 'drive' ? 'drive' : 'comptoir') . ' - Wakdo Admin', 'products' => $this->productRepository()->availableForCatalogue(), + 'menus' => $this->menusWithSlots(), '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. + * + * @return list> + */ + private function menusWithSlots(): array + { + $menus = $this->menuRepository()->availableForCatalogue(); + + return array_map(function (array $menu): array { + $menu['slots'] = $this->menuRepository()->slotsWithOptions((int) ($menu['id'] ?? 0)); + + return $menu; + }, $menus); + } + /** * 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. @@ -212,10 +393,15 @@ class CounterOrderController extends AdminController private function messageFor(string $code): string { return match ($code) { - 'EMPTY_ORDER' => 'La commande est vide : ajoutez au moins un produit.', - 'INVALID_SERVICE_MODE' => 'Mode de service invalide (le drive impose le mode drive).', - 'PRODUCT_UNAVAILABLE' => 'Un produit selectionne est indisponible.', - default => 'Commande invalide, verifiez votre saisie.', + 'EMPTY_ORDER' => 'La commande est vide : ajoutez au moins un produit ou un menu.', + 'INVALID_SERVICE_MODE' => 'Mode de service invalide (le drive impose le mode drive).', + 'PRODUCT_UNAVAILABLE' => 'Un produit selectionne est indisponible.', + 'MENU_UNAVAILABLE' => 'Un menu selectionne est indisponible.', + 'INVALID_SELECTION' => 'Un choix de menu (accompagnement / boisson / sauce) est invalide.', + 'INVALID_MODIFIER', + 'INGREDIENT_NOT_REMOVABLE', + 'INGREDIENT_NOT_ADDABLE' => 'Une modification d\'ingredient est invalide.', + default => 'Commande invalide, verifiez votre saisie.', }; } diff --git a/src/app/Views/admin/counter/new.php b/src/app/Views/admin/counter/new.php index 49fcb63..2c537e8 100644 --- a/src/app/Views/admin/counter/new.php +++ b/src/app/Views/admin/counter/new.php @@ -3,12 +3,21 @@ declare(strict_types=1); /** - * Composeur de commande comptoir/drive (version PRODUITS, sous-lot 3a), injecte dans - * admin/layout.php. Une quantite par produit commandable (champ qty_) + un select - * service_mode. Partage par les deux canaux ; la source/landing viennent du controleur. - * Au canal drive, service_mode est verrouille a 'drive' (RG-T09). Echappement RG-T15. + * Composeur de commande comptoir/drive COMPLET (sous-lot 3b), 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 + * quantites produit `qty_` reste present comme repli sans JS (3a). + * + * Partage par les deux canaux ; la source/landing viennent du controleur. Au canal + * drive, service_mode est verrouille a 'drive' (RG-T09). Echappement RG-T15. * * @var list> $products + * @var list> $menus menus + slots (option_product_ids) * @var string $source 'counter' | 'drive' * @var string $serviceMode valeur preselectionnee / reaffichee * @var string $landing retour a la liste du canal @@ -19,6 +28,14 @@ declare(strict_types=1); $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); $euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR'; +// Donnees pour counter-order.js, passees en attributs data-* (CSP 'self' : pas de +// script inline). htmlspecialchars rend le JSON sur-able comme valeur d'attribut. +$attr = static fn (mixed $data): string => htmlspecialchars( + (string) json_encode($data, JSON_UNESCAPED_UNICODE), + ENT_QUOTES, + 'UTF-8', +); + $csrf = $esc($csrfToken ?? ''); $chan = isset($source) && $source === 'drive' ? 'drive' : 'counter'; $action = $chan === 'drive' ? '/drive/orders' : '/counter/orders'; @@ -26,8 +43,46 @@ $backTo = isset($landing) && is_string($landing) ? $landing : '/counter/orders'; $mode = isset($serviceMode) && is_string($serviceMode) ? $serviceMode : ($chan === 'drive' ? 'drive' : 'dine_in'); $errorMessage = isset($error) && is_string($error) ? $error : null; -/** @var list> $rows */ -$rows = isset($products) && is_array($products) ? $products : []; +/** @var list> $productRows */ +$productRows = isset($products) && is_array($products) ? $products : []; +/** @var list> $menuRows */ +$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). +$jsProducts = array_map( + static fn (array $p): array => [ + 'id' => (int) ($p['id'] ?? 0), + 'name' => (string) ($p['name'] ?? ''), + 'price' => (int) ($p['price_cents'] ?? 0), + ], + $productRows, +); +$jsMenus = array_map( + static function (array $m): 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( + static fn (array $s): array => [ + 'id' => (int) ($s['id'] ?? 0), + 'name' => (string) ($s['name'] ?? ''), + 'slot_type' => (string) ($s['slot_type'] ?? ''), + 'is_required' => (int) ($s['is_required'] ?? 0), + 'display_order' => (int) ($s['display_order'] ?? 0), + 'option_product_ids' => array_map('intval', is_array($s['option_product_ids'] ?? null) ? $s['option_product_ids'] : []), + ], + $slots, + ), + ]; + }, + $menuRows, +); // RG-T09 : au drive, le seul mode possible est 'drive'. Le comptoir choisit librement. $modeOptions = $chan === 'drive' @@ -42,8 +97,11 @@ $modeOptions = $chan === 'drive' -
+ +
@@ -54,36 +112,72 @@ $modeOptions = $chan === 'drive'
- -

Aucun produit commandable pour le moment.

- - - - - - - - - - - - +
+ Produits + +

Aucun produit commandable pour le moment.

+ +
ProduitPrixQuantite
+ - - - + + + + + + + + + + + + + + +
- - ProduitPrixQuantite
+ +
+ + + +
+ Menus + +

Aucun menu commandable pour le moment.

+ + + +
+ +
+ Panier +
    +
  • Panier vide.
  • +
+
Annuler
+ + + + + diff --git a/src/public/admin/assets/js/counter-order.js b/src/public/admin/assets/js/counter-order.js new file mode 100644 index 0000000..70c31d3 --- /dev/null +++ b/src/public/admin/assets/js/counter-order.js @@ -0,0 +1,326 @@ +/* + * counter-order.js — Composeur de commande comptoir/drive (back-office, sous-lot 3b). + * + * 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. + * + * 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). + * + * Module CommonJS (admin = racine CommonJS, comme pin-modal.js) : init(doc) est + * exporte pour les tests et auto-appele au DOMContentLoaded en production. + */ +(function () { + 'use strict'; + + // SLOT_LABEL : seuls les slot_type geres deviennent une etape (l'enum DB autorise + // aussi dessert/extra). Aligne sur page-product-menu.js (anti-perte silencieuse). + var SLOT_LABEL = { side: 'Accompagnement', drink: 'Boisson', sauce: 'Sauce' }; + + function parseData(form, key, fallback) { + try { + var v = JSON.parse(form.dataset[key] || fallback); + return Array.isArray(v) ? v : JSON.parse(fallback); + } catch (e) { + return JSON.parse(fallback); + } + } + + // 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) { + return (menu.slots || []) + .filter(function (slot) { + return Object.prototype.hasOwnProperty.call(SLOT_LABEL, slot.slot_type); + }) + .slice() + .sort(function (a, b) { + return (Number(a.display_order) || 0) - (Number(b.display_order) || 0); + }) + .map(function (slot) { + var options = (slot.option_product_ids || []) + .map(function (pid) { return productById[Number(pid)]; }) + .filter(Boolean); + return { + id: Number(slot.id), + name: slot.name || SLOT_LABEL[slot.slot_type], + slotType: slot.slot_type, + isRequired: Number(slot.is_required) === 1, + options: options, + }; + }); + } + + function init(doc) { + var form = doc.getElementById('counter-order-form'); + var hidden = doc.getElementById('items_json'); + var cart = doc.getElementById('order-cart'); + var cartEmpty = doc.getElementById('order-cart-empty'); + var modalHost = doc.getElementById('menu-composer-modal'); + if (!form || !hidden || !cart || !modalHost) { + return; + } + + var products = parseData(form, 'products', '[]'); // [{id, name, price}] + var menus = parseData(form, 'menus', '[]'); // [{id, name, price_normal, price_maxi, slots:[...]}] + + // Index produit par id : resolution des libelles d'options de slot (affichage). + var productById = {}; + products.forEach(function (p) { + productById[Number(p.id)] = p; + }); + + // Menus configures par l'equipier : items prets a serialiser, avec libelle recap. + var menuLines = []; + var lineSeq = 0; + + function el(tag, className) { + var e = doc.createElement(tag); + if (className) { + e.className = className; + } + return e; + } + + /* ----------------------------------------------------------------- */ + /* 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). + function serialize() { + var items = []; + + Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) { + var productId = Number(input.dataset.productId); + var quantity = parseInt(input.value, 10); + if (productId > 0 && quantity >= 1) { + items.push({ type: 'product', product_id: productId, quantity: quantity }); + } + }); + + menuLines.forEach(function (line) { + items.push({ + type: 'menu', + menu_id: line.menuId, + quantity: 1, + format: line.format, + selections: line.selections.map(function (s) { + return { menu_slot_id: s.slotId, product_id: s.productId }; + }), + }); + }); + + hidden.value = JSON.stringify(items); + } + + /* ----------------------------------------------------------------- */ + /* Rendu du panier (recap des menus configures) */ + /* ----------------------------------------------------------------- */ + + function renderCart() { + Array.prototype.forEach.call(cart.querySelectorAll('.order-cart__line'), function (node) { + node.parentNode.removeChild(node); + }); + + menuLines.forEach(function (line) { + var li = el('li', 'order-cart__line'); + + var label = el('span', 'order-cart__label'); + var parts = [line.menuName + ' (' + (line.format === 'maxi' ? 'Maxi' : 'Normal') + ')']; + line.selections.forEach(function (s) { + var p = productById[Number(s.productId)]; + if (p) { + parts.push(p.name); + } + }); + label.textContent = parts.join(' - '); + li.appendChild(label); + + var removeBtn = el('button', 'btn btn-secondary order-cart__remove'); + removeBtn.type = 'button'; + removeBtn.textContent = 'Retirer'; + removeBtn.addEventListener('click', function () { + menuLines = menuLines.filter(function (l) { return l.localId !== line.localId; }); + renderCart(); + }); + li.appendChild(removeBtn); + + cart.appendChild(li); + }); + + if (cartEmpty) { + cartEmpty.style.display = menuLines.length ? 'none' : ''; + } + } + + /* ----------------------------------------------------------------- */ + /* Modale de configuration d'un menu */ + /* ----------------------------------------------------------------- */ + + function closeComposer() { + modalHost.textContent = ''; + 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). + function openComposer(menu) { + var steps = composerSteps(menu, productById); + var state = { format: 'normal', selections: {} }; + steps.forEach(function (step) { + if (step.isRequired && step.options[0]) { + state.selections[step.id] = step.options[0].id; + } + }); + + modalHost.textContent = ''; + var panel = el('div', 'menu-composer'); + + var title = el('h2', 'menu-composer__title'); + title.textContent = menu.name; + panel.appendChild(title); + + // Format Normal / Maxi + var formatGroup = el('div', 'menu-composer__format'); + var formatLegend = el('p', 'menu-composer__legend'); + formatLegend.textContent = 'Format'; + formatGroup.appendChild(formatLegend); + [ + { value: 'normal', label: 'Normal' }, + { value: 'maxi', label: 'Maxi' }, + ].forEach(function (fmt) { + var lab = el('label', 'menu-composer__radio'); + var radio = el('input'); + radio.type = 'radio'; + radio.name = 'composer-format'; + radio.value = fmt.value; + radio.className = 'menu-composer__format-input'; + if (state.format === fmt.value) { + radio.checked = true; + } + radio.addEventListener('change', function () { + state.format = fmt.value; + }); + lab.appendChild(radio); + lab.appendChild(doc.createTextNode(' ' + fmt.label)); + formatGroup.appendChild(lab); + }); + panel.appendChild(formatGroup); + + // Un bloc par slot : select des options (+ "Sans" si optionnel). + steps.forEach(function (step) { + var block = el('div', 'menu-composer__slot'); + var lab = el('label', 'menu-composer__legend'); + lab.textContent = step.name + (step.isRequired ? '' : ' (optionnel)'); + block.appendChild(lab); + + var select = el('select', 'form-input menu-composer__slot-select'); + select.dataset.slotId = String(step.id); + if (!step.isRequired) { + var none = el('option'); + none.value = ''; + none.textContent = 'Sans'; + select.appendChild(none); + } + step.options.forEach(function (opt) { + var o = el('option'); + o.value = String(opt.id); + o.textContent = String(opt.name); + if (state.selections[step.id] === opt.id) { + o.selected = true; + } + select.appendChild(o); + }); + select.addEventListener('change', function () { + var raw = select.value; + if (raw === '') { + delete state.selections[step.id]; + } else { + state.selections[step.id] = parseInt(raw, 10); + } + }); + block.appendChild(select); + panel.appendChild(block); + }); + + // 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'); + addBtn.type = 'button'; + addBtn.textContent = 'Ajouter au panier'; + addBtn.addEventListener('click', function () { + var allRequired = steps.filter(function (s) { return s.isRequired; }) + .every(function (s) { return state.selections[s.id] != null; }); + if (!allRequired) { + return; + } + var selections = []; + steps.forEach(function (step) { + var chosen = state.selections[step.id]; + if (chosen != null) { + selections.push({ slotId: step.id, productId: chosen }); + } + }); + menuLines.push({ + localId: ++lineSeq, + menuId: Number(menu.id), + menuName: menu.name, + format: state.format, + selections: selections, + }); + 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'); + } + + /* ----------------------------------------------------------------- */ + /* Cablage */ + /* ----------------------------------------------------------------- */ + + Array.prototype.forEach.call(doc.querySelectorAll('.menu-configure'), function (btn) { + btn.addEventListener('click', function () { + var menuId = Number(btn.dataset.menuId); + var menu = menus.filter(function (m) { return Number(m.id) === menuId; })[0]; + if (menu) { + openComposer(menu); + } + }); + }); + + form.addEventListener('submit', function () { + serialize(); + }); + + renderCart(); + } + + if (typeof module !== 'undefined' && module.exports) { + module.exports = { init: init, composerSteps: composerSteps }; + } + if (typeof document !== 'undefined' && document.addEventListener) { + document.addEventListener('DOMContentLoaded', function () { + init(document); + }); + } +})(); diff --git a/tests/Unit/Admin/CounterOrderControllerTest.php b/tests/Unit/Admin/CounterOrderControllerTest.php index 685ff98..d12c769 100644 --- a/tests/Unit/Admin/CounterOrderControllerTest.php +++ b/tests/Unit/Admin/CounterOrderControllerTest.php @@ -216,6 +216,134 @@ final class CounterOrderControllerTest extends TestCase self::assertTrue($db->wrote('UPDATE customer_order SET status')); } + public function testCreateRendersMenuComposer(): void + { + // create() expose les menus + leurs slots au composeur (data-* JSON). + $db = $this->permittedDb(); + $db->menusRows = [ + ['id' => 5, 'category_id' => 1, 'burger_product_id' => 12, 'name' => 'Menu Cheeseburger', 'description' => null, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'image_path' => null, 'display_order' => 1], + ]; + $db->menuSlotRows = [ + ['id' => 16, 'name' => 'Accompagnement', 'slot_type' => 'side', 'is_required' => 1, 'display_order' => 1, 'product_id' => 22], + ]; + + $response = $this->controller($this->get('/counter/orders/new'), $db)->create(); + + self::assertSame(200, $response->status()); + $body = $response->body(); + self::assertStringContainsString('Menu Cheeseburger', $body); + self::assertStringContainsString('data-menu-id="5"', $body); // bouton configurer + self::assertStringContainsString('items_json', $body); // champ cache du panier + self::assertStringContainsString('counter-order.js', $body); // script du composeur + } + + public function testStoreCreatesMenuOrderViaItemsJson(): void + { + // store() decode items_json, revalide la forme, et createStaffOrder persiste + // l'item menu (order_item type menu + order_item_selection). + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Menu Cheeseburger', 'burger_product_id' => 12, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'is_available' => 1]; + // productRow sert le burger (vat_rate) ET le produit selectionne (label snapshot). + $db->productRow = ['id' => 22, 'name' => 'Frites', 'price_cents' => 250, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1]; + // slotsWithOptions : slot 16 (side) propose le produit 22 -> selection valide. + $db->menuSlotRows = [ + ['id' => 16, 'name' => 'Accompagnement', 'slot_type' => 'side', 'is_required' => 1, 'display_order' => 1, 'product_id' => 22], + ]; + $db->lastInsertId = 100; + $db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 990, 'status' => 'pending_payment']; + + $items = json_encode([ + ['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'normal', 'selections' => [['menu_slot_id' => 16, 'product_id' => 22]]], + ]); + $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')); + // Ligne menu persistee. + $itemInsert = $this->writeParams($db, 'INSERT INTO order_item '); + self::assertSame('menu', $itemInsert['type']); + self::assertSame(5, $itemInsert['mid']); + self::assertSame('normal', $itemInsert['fmt']); + // Selection persistee (slot 16 -> produit 22). + self::assertTrue($db->wrote('INSERT INTO order_item_selection')); + $selInsert = $this->writeParams($db, 'INSERT INTO order_item_selection'); + self::assertSame(16, $selInsert['slot']); + self::assertSame(22, $selInsert['pid']); + } + + public function testStoreCreatesProductOrderViaItemsJson(): void + { + // Chemin unifie : items_json est prefere a qty_, et un produit y passe aussi. + $db = $this->permittedDb(); + $db->productRow = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1]; + $db->lastInsertId = 100; + $db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 890, 'status' => 'pending_payment']; + + $items = json_encode([['type' => 'product', 'product_id' => 12, 'quantity' => 3]]); + $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()); + $itemInsert = $this->writeParams($db, 'INSERT INTO order_item '); + self::assertSame('product', $itemInsert['type']); + self::assertSame(12, $itemInsert['pid']); + self::assertSame(3, $itemInsert['qty']); + } + + public function testStoreRejectsMalformedItemsJson(): void + { + // items_json non-JSON / sans item valide -> panier vide -> 422, aucun INSERT. + $db = $this->permittedDb(); + $request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'dine_in', 'items_json' => 'not-json'], '/counter/orders'); + + $response = $this->controller($request, $db)->store(); + + self::assertSame(422, $response->status()); + self::assertFalse($db->wrote('INSERT INTO customer_order')); + } + + public function testStoreRejectsItemsJsonWithOnlyInvalidEntries(): void + { + // Entrees mal formees (type inconnu, ids non positifs) ecartees -> panier vide -> 422. + $db = $this->permittedDb(); + $items = json_encode([ + ['type' => 'unknown', 'product_id' => 12], + ['type' => 'product', 'product_id' => 0], + ['type' => 'menu', 'menu_id' => -1], + ]); + $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')); + } + + public function testStoreRejectsMenuSelectionOutsideSlotOptions(): void + { + // RG-T18/INVALID_SELECTION : une selection hors des options du slot est rejetee + // par resolveSelections -> re-rendu 422, rien de persiste. + $db = $this->permittedDb(); + $db->menuRow = ['id' => 5, 'name' => 'Menu Cheeseburger', 'burger_product_id' => 12, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'is_available' => 1]; + $db->productRow = ['id' => 22, 'name' => 'Frites', 'price_cents' => 250, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1]; + $db->menuSlotRows = [ + ['id' => 16, 'name' => 'Accompagnement', 'slot_type' => 'side', 'is_required' => 1, 'display_order' => 1, 'product_id' => 22], + ]; + + $items = json_encode([ + ['type' => 'menu', 'menu_id' => 5, 'quantity' => 1, 'format' => 'normal', 'selections' => [['menu_slot_id' => 16, 'product_id' => 999]]], + ]); + $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 order_item_selection')); + } + public function testStoreDriveRejectsNonDriveServiceMode(): void { // RG-T09 : au drive, service_mode doit etre drive ; sinon re-rendu 422, pas d'INSERT. diff --git a/tests/js/counter-order.test.js b/tests/js/counter-order.test.js new file mode 100644 index 0000000..aece427 --- /dev/null +++ b/tests/js/counter-order.test.js @@ -0,0 +1,196 @@ +/* + * Tests du composeur de commande comptoir/drive (counter-order.js, sous-lot 3b). + * 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} + * - 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. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +// counter-order.js est du CommonJS (admin = racine CommonJS) ; import par defaut. +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 }, +]; + +const MENUS = [ + { + id: 5, + name: 'Menu Cheeseburger', + price_normal: 990, + price_maxi: 1190, + 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] }, + { id: 31, name: 'Sauce', slot_type: 'sauce', is_required: 0, display_order: 3, option_product_ids: [47] }, + ], + }, +]; + +function setup(menus = MENUS) { + const menuItems = menus + .map(m => `
  • `) + .join(''); + const qtyInputs = PRODUCTS + .map(p => ``) + .join(''); + const dom = new JSDOM( + '' + + '
    ` + + ' ' + + qtyInputs + + ' ' + + '
    • Panier vide.
    ' + + ' ' + + '
    ' + + '' + + '', + ); + return dom; +} + +function fireSubmit(dom) { + const form = dom.window.document.getElementById('counter-order-form'); + form.dispatchEvent(new dom.window.Event('submit', { cancelable: true, bubbles: true })); +} + +function itemsJson(dom) { + return JSON.parse(dom.window.document.getElementById('items_json').value || '[]'); +} + +test('ajout produit (quantite) -> items_json contient {type:product}', () => { + const dom = setup(); + counterOrder.init(dom.window.document); + + const qty = dom.window.document.getElementById('qty_12'); + qty.value = '2'; + fireSubmit(dom); + + const items = itemsJson(dom); + assert.deepEqual(items, [{ type: 'product', product_id: 12, quantity: 2 }]); +}); + +test('quantite 0 ignoree -> panier vide serialise []', () => { + const dom = setup(); + counterOrder.init(dom.window.document); + + fireSubmit(dom); + assert.deepEqual(itemsJson(dom), []); +}); + +test('configuration menu (format Maxi + slots) -> items_json contient {type:menu, format:maxi, selections}', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + // Ouvre la modale du menu 5. + doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + + const modal = doc.getElementById('menu-composer-modal'); + assert.equal(modal.hasAttribute('hidden'), false); + + // Passe en Maxi. + const maxiRadio = Array.prototype.find.call( + modal.querySelectorAll('.menu-composer__format-input'), + r => r.value === 'maxi', + ); + maxiRadio.checked = true; + maxiRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + // Slots requis (side/drink) sont pre-selectionnes (1er choix) ; on ajoute la sauce. + const sauceSelect = Array.prototype.find.call( + modal.querySelectorAll('.menu-composer__slot-select'), + s => s.dataset.slotId === '31', + ); + sauceSelect.value = '47'; + sauceSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + + // Modale fermee, panier recap mis a jour. + 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, 'menu'); + assert.equal(items[0].menu_id, 5); + assert.equal(items[0].format, 'maxi'); + assert.equal(items[0].quantity, 1); + // Selections : slots tries par display_order (drink=1, side=2, sauce=3). + assert.deepEqual(items[0].selections, [ + { menu_slot_id: 1, product_id: 14 }, + { menu_slot_id: 16, product_id: 22 }, + { menu_slot_id: 31, product_id: 47 }, + ]); +}); + +test('menu Normal sans la sauce optionnelle -> selections ne contient que les requis', () => { + 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'); + + // Laisse la sauce a "Sans" (valeur vide) ; ajoute directement. + const sauceSelect = Array.prototype.find.call( + modal.querySelectorAll('.menu-composer__slot-select'), + s => s.dataset.slotId === '31', + ); + sauceSelect.value = ''; + sauceSelect.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].format, 'normal'); + assert.deepEqual(items[0].selections, [ + { menu_slot_id: 1, product_id: 14 }, + { menu_slot_id: 16, product_id: 22 }, + ]); +}); + +test('produit + menu combines -> items_json contient les deux lignes', () => { + const dom = setup(); + const doc = dom.window.document; + counterOrder.init(doc); + + doc.getElementById('qty_12').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'); + modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true })); + + fireSubmit(dom); + const items = itemsJson(dom); + assert.equal(items.length, 2); + assert.equal(items.filter(i => i.type === 'product').length, 1); + assert.equal(items.filter(i => i.type === 'menu').length, 1); +}); + +test('composerSteps: slot_type non gere (dessert) ignore, slots tries par display_order', () => { + const productById = {}; + PRODUCTS.forEach(p => { productById[p.id] = p; }); + const menu = { + id: 9, + slots: [ + { id: 99, name: 'Dessert', slot_type: 'dessert', is_required: 1, display_order: 4, option_product_ids: [22] }, + ...MENUS[0].slots, + ], + }; + const steps = counterOrder.composerSteps(menu, productById); + assert.deepEqual(steps.map(s => s.slotType), ['drink', 'side', 'sauce']); // dessert exclu, tri display_order +});