feat(orders): composeur de menus (slots + format) saisie comptoir/drive (#86)
All checks were successful
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 21s
CI / static-tests (push) Successful in 48s
CI / js-tests (push) Successful in 28s

This commit is contained in:
Corentin JOGUET 2026-06-22 12:28:13 +02:00
parent 5cc879c3ea
commit 6347c66a7e
5 changed files with 986 additions and 56 deletions

View file

@ -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_<id>` (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<string, string> $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_<id>` (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<string, string> $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_<id>
// (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_<id> 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<array<string, mixed>>
*/
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<array{menu_slot_id:int, product_id:int}>
*/
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<array{ingredient_id:int, action:string}>
*/
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_<id>` (3a).
* Conserve pour ne pas casser la saisie quand counter-order.js ne s'execute pas.
*
* @param array<string, mixed> $form
* @return list<array<string, mixed>>
*/
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<array<string, mixed>>
*/
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.',
};
}

View file

@ -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_<id>) + 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_<id>` 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<array<string, mixed>> $products
* @var list<array<string, mixed>> $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<array<string, mixed>> $rows */
$rows = isset($products) && is_array($products) ? $products : [];
/** @var list<array<string, mixed>> $productRows */
$productRows = isset($products) && is_array($products) ? $products : [];
/** @var list<array<string, mixed>> $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<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(
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'
<p class="form-error" role="alert"><?= $esc($errorMessage) ?></p>
<?php endif; ?>
<form method="post" action="<?= $esc($action) ?>" class="form-card">
<form method="post" action="<?= $esc($action) ?>" class="form-card" id="counter-order-form"
data-products="<?= $attr($jsProducts) ?>"
data-menus="<?= $attr($jsMenus) ?>">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<input type="hidden" name="items_json" id="items_json" value="">
<div class="form-group">
<label class="form-label" for="service_mode">Mode de service</label>
@ -54,36 +112,72 @@ $modeOptions = $chan === 'drive'
</select>
</div>
<?php if ($rows === []): ?>
<p class="admin-empty">Aucun produit commandable pour le moment.</p>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<th>Produit</th>
<th>Prix</th>
<th>Quantite</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $p): ?>
<?php $pid = (int) ($p['id'] ?? 0); ?>
<fieldset class="form-group">
<legend>Produits</legend>
<?php if ($productRows === []): ?>
<p class="admin-empty">Aucun produit commandable pour le moment.</p>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<td><?= $esc($p['name'] ?? '') ?></td>
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td>
<td>
<input class="form-input" type="number" min="0" value="0"
id="qty_<?= $pid ?>" name="qty_<?= $pid ?>"
aria-label="Quantite <?= $esc($p['name'] ?? '') ?>">
</td>
<th>Produit</th>
<th>Prix</th>
<th>Quantite</th>
</tr>
</thead>
<tbody>
<?php foreach ($productRows as $p): ?>
<?php $pid = (int) ($p['id'] ?? 0); ?>
<tr>
<td><?= $esc($p['name'] ?? '') ?></td>
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td>
<td>
<input class="form-input order-qty" type="number" min="0" value="0"
id="qty_<?= $pid ?>" name="qty_<?= $pid ?>"
data-product-id="<?= $pid ?>"
aria-label="Quantite <?= $esc($p['name'] ?? '') ?>">
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</fieldset>
<fieldset class="form-group">
<legend>Menus</legend>
<?php if ($menuRows === []): ?>
<p class="admin-empty">Aucun menu commandable pour le moment.</p>
<?php else: ?>
<ul class="menu-list" id="menu-list">
<?php foreach ($menuRows as $m): ?>
<?php $mid = (int) ($m['id'] ?? 0); ?>
<li class="menu-list__item">
<span class="menu-list__name"><?= $esc($m['name'] ?? '') ?></span>
<span class="menu-list__price"><?= $esc($euros($m['price_normal_cents'] ?? 0)) ?></span>
<button class="btn btn-secondary menu-configure" type="button" data-menu-id="<?= $mid ?>">
Configurer
</button>
</li>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</ul>
<?php endif; ?>
</fieldset>
<fieldset class="form-group">
<legend>Panier</legend>
<ul class="order-cart" id="order-cart" aria-live="polite">
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
</ul>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Encaisser la commande</button>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
</div>
</form>
<!-- Conteneur de la modale de configuration de menu (rempli par counter-order.js). -->
<div id="menu-composer-modal" hidden></div>
<script src="/assets/js/counter-order.js"></script>

View file

@ -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_<id> (repli
* sans JS conserve : le serveur accepte aussi qty_<id> 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_<id> (>= 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);
});
}
})();

View file

@ -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_<id>, 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.

View file

@ -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 => `<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">`)
.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 +
' <ul id="menu-list">' + menuItems + '</ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <button type="submit">Encaisser</button>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
);
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
});