feat(orders): composeur de menus (slots + format) pour la saisie comptoir/drive (P3 operationnel)
All checks were successful
CI / secret-scan (push) Successful in 10s
CI / php-lint (push) Successful in 21s
CI / php-lint (pull_request) Successful in 21s
CI / js-tests (pull_request) Successful in 26s
CI / static-tests (push) Successful in 54s
CI / js-tests (push) Successful in 28s
CI / secret-scan (pull_request) Successful in 9s
CI / static-tests (pull_request) Successful in 47s
All checks were successful
CI / secret-scan (push) Successful in 10s
CI / php-lint (push) Successful in 21s
CI / php-lint (pull_request) Successful in 21s
CI / js-tests (pull_request) Successful in 26s
CI / static-tests (push) Successful in 54s
CI / js-tests (push) Successful in 28s
CI / secret-scan (pull_request) Successful in 9s
CI / static-tests (pull_request) Successful in 47s
This commit is contained in:
parent
5cc879c3ea
commit
562d14611b
5 changed files with 986 additions and 56 deletions
|
|
@ -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.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
326
src/public/admin/assets/js/counter-order.js
Normal file
326
src/public/admin/assets/js/counter-order.js
Normal 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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
196
tests/js/counter-order.test.js
Normal file
196
tests/js/counter-order.test.js
Normal 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
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue