feat(orders): UI modificateurs (retrait/ajout) dans le composeur comptoir/drive (P3 operationnel)
All checks were successful
CI / secret-scan (pull_request) Successful in 10s
CI / php-lint (pull_request) Successful in 25s
CI / php-lint (push) Successful in 31s
CI / js-tests (push) Successful in 31s
CI / static-tests (pull_request) Successful in 56s
CI / js-tests (pull_request) Successful in 32s
CI / secret-scan (push) Successful in 12s
CI / static-tests (push) Successful in 58s

Picker de modificateurs CSP-safe dans le composeur comptoir/drive : pour
chaque produit personnalisable, exposition des ingredients retirables/ajoutables
(proposableModifiers) en data-*, toggles construits via createElement/addEventListener.
La selection est serialisee dans items_json ; le serveur reste autoritatif
(resolveModifiers refige extra_price depuis la recette, rejette tout ingredient
hors recette - RG-T16). Anti-double-comptage : qty_<id> supprime pour les produits
configurables. Revue bmad-compliance : approve, 0 must_fix, mantras 100%.
This commit is contained in:
Imugiii 2026-06-22 11:37:10 +00:00
parent 6347c66a7e
commit 862e057d87
5 changed files with 581 additions and 70 deletions

View file

@ -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<array<string, mixed>>
*/
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<array{ingredient_id:int, name:string, is_removable:int, is_addable:int, extra_price_cents:int}>
*/
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.

View file

@ -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_<id>` 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<array<string, mixed>> $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'
<th>Produit</th>
<th>Prix</th>
<th>Quantite</th>
<th>Personnaliser</th>
</tr>
</thead>
<tbody>
<?php foreach ($productRows as $p): ?>
<?php $pid = (int) ($p['id'] ?? 0); ?>
<?php
$pid = (int) ($p['id'] ?? 0);
// Un produit ne porte un bouton "Personnaliser" que si sa recette
// offre au moins un ingredient retirable/ajoutable (data-* modifiers).
$hasModifiers = isset($p['modifiers']) && is_array($p['modifiers']) && $p['modifiers'] !== [];
?>
<tr>
<td><?= $esc($p['name'] ?? '') ?></td>
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td>
@ -137,6 +161,13 @@ $modeOptions = $chan === 'drive'
data-product-id="<?= $pid ?>"
aria-label="Quantite <?= $esc($p['name'] ?? '') ?>">
</td>
<td>
<?php if ($hasModifiers): ?>
<button class="btn btn-secondary product-configure" type="button" data-product-id="<?= $pid ?>">
Personnaliser
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>

View file

@ -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_<id> (repli
* sans JS conserve : le serveur accepte aussi qty_<id> 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_<id> (repli sans JS conserve : le serveur accepte aussi qty_<id> 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_<id> 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_<id> (>= 1). Menus : items configures. La
// forme calque ce qu'attend OrderRepository::resolveLine (revalide cote serveur).
// Produits sans modificateur : derives des champs qty_<id> (>= 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);

View file

@ -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 &quot;. On cherche les fragments echappes (forme reellement rendue).
self::assertStringContainsString('ingredient_id&quot;:3', $body);
self::assertStringContainsString('ingredient_id&quot;: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

View file

@ -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 => `<li><button class="menu-configure" type="button" data-menu-id="${m.id}">Configurer</button></li>`)
.join('');
const qtyInputs = PRODUCTS
.map(p => `<input class="order-qty" type="number" id="qty_${p.id}" name="qty_${p.id}" data-product-id="${p.id}" value="0">`)
// qty_<id> 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)
? `<button class="product-configure" type="button" data-product-id="${p.id}">Personnaliser</button>`
: '';
return `<input class="order-qty" type="number" id="qty_${p.id}" name="qty_${p.id}" data-product-id="${p.id}" value="0">${configure}`;
})
.join('');
const dom = new JSDOM(
'<!DOCTYPE html><html><body>' +
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRODUCTS)}' data-menus='${JSON.stringify(menus)}'>` +
' <input type="hidden" name="items_json" id="items_json" value="">' +
qtyInputs +
productRows +
' <ul id="menu-list">' + menuItems + '</ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <button type="submit">Encaisser</button>' +
@ -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_<id>.
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_<id> 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_<id>.
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; });