corentin_wakdo/src/app/Controllers/CounterOrderController.php
Imugiii eed2daffb0
All checks were successful
CI / secret-scan (push) Successful in 16s
CI / secret-scan (pull_request) Successful in 17s
CI / php-lint (pull_request) Successful in 40s
CI / php-lint (push) Successful in 41s
CI / static-tests (push) Successful in 1m14s
CI / js-tests (push) Successful in 46s
CI / static-tests (pull_request) Successful in 1m15s
CI / js-tests (pull_request) Successful in 42s
feat(back-office): refonte saisie commande comptoir/drive (prix, verrou, nav, file)
7 ameliorations de la page de saisie comptoir/drive, inutilisable au quotidien :
- prix par ligne + total + bouton "Encaisser X,XX EUR" (l'equipier encaissait
  sans voir aucun montant) ;
- verrou drive reel (affichage fige + input hidden ; readonly sur un select est
  sans effet HTML) ;
- lien de nav "Saisie commande" route selon le canal du role (un equipier drive
  atterrissait au comptoir) ;
- champ quantite desactive pour un produit personnalisable (sa saisie etait
  ignoree en silence) ;
- file "En cours" (commandes payees du canal, plus ancienne d'abord) au-dessus
  de l'historique ;
- feedback prix Normal/Maxi dans la liste et le total de ligne ;
- numero de table (dine_in comptoir), groupage par categorie, modale a overlay
  + fermeture Echap + message requis inline.

Serveur autoritatif inchange (les prix cote client sont indicatifs).
availableForCatalogue expose category_name et trie par categorie ; la borne
regroupe deja par categorie (ordre intra-categorie preserve) donc son rendu ne
bouge pas. Tests : JS 104, PHP unit 398, PHPStan L6.
2026-06-24 09:59:18 +00:00

490 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth\Csrf;
use App\Auth\GuardResult;
use App\Catalogue\MenuRepository;
use App\Catalogue\ProductRepository;
use App\Core\DatabaseInterface;
use App\Core\Response;
use App\Order\OrderQueryRepository;
use App\Order\OrderRepository;
use App\Order\OrderValidationException;
/**
* Saisie de commande comptoir / drive en back-office (CREATE_COUNTER_ORDER, mlt 4.1).
* UN seul controleur sert les DEUX canaux : la `source` est derivee du CHEMIN de la
* requete (un chemin commencant par '/drive' -> 'drive', sinon 'counter'). Ce choix
* evite un controleur par canal alors que la logique est identique ; seules la source
* auto-tagguee, le titre et les liens d'action changent. Le decoupage par chemin (et
* non par parametre de route) garantit que counter et drive restent etanches : un
* equipier drive ne peut pas creer une commande comptoir en falsifiant un champ.
*
* Composeur (sous-lot 3c) : produits ET menus composes (slots accompagnement/
* boisson/sauce + format Normal/Maxi) ET modificateurs d'ingredients (retrait/ajout).
* La composition PROPOSABLE de chaque produit a la carte et du burger de chaque menu
* (ingredients is_removable / is_addable + surcout) est embarquee en data-* pour que
* counter-order.js affiche les cases "retirer" / "ajouter +X.XX EUR". Le serveur reste
* seul juge : resolveModifiers revalide chaque modificateur metier (l'ingredient doit
* appartenir a la recette du produit support, etre retirable pour 'remove' / ajoutable
* pour 'add') et fige extra_price_cents (RG-T16) ; le client ne fait que PROPOSER.
* Le panier est construit cote client (counter-order.js) et serialise en JSON dans
* le champ cache `items_json` ; le serveur (store) le decode, revalide la forme
* (RG-T18) puis delegue a createStaffOrder qui resout/calcule cote serveur (RG-T16).
* 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).
*/
class CounterOrderController extends AdminController
{
/**
* Liste des commandes recentes du canal courant + lien "Nouvelle commande".
* Corrige le 404 des landings /counter/orders et /drive/orders (role.default_route).
*
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
$guard = $this->guard('order.create');
if ($guard instanceof Response) {
return $guard;
}
$source = $this->source();
$orderQuery = $this->orderQuery();
// RG-1 (5.1, source filter) : ne lister que les commandes du canal. recent()
// ramene les plus recentes tous canaux ; on filtre sur la source derivee du
// chemin pour que le comptoir ne voie pas le drive et inversement.
$orders = array_values(array_filter(
$orderQuery->recent(50),
static fn (array $o): bool => (string) ($o['source'] ?? '') === $source,
));
// File "En cours" (RG-T12) : commandes du canal au statut paid non livrees,
// la plus ancienne d'abord (tri paid_at croissant fait par paidQueue). Filtree
// a la SEULE source du canal pour que l'equipier ne voie que ce qu'il sert.
$inProgress = $orderQuery->paidQueue([$source]);
return $this->channelView('admin/counter/index', $source, [
'title' => $this->channelTitle($source) . ' - Wakdo Admin',
'orders' => $orders,
'inProgress' => $inProgress,
], $guard);
}
/**
* 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
*/
public function create(array $params = []): Response
{
$guard = $this->guard('order.create');
if ($guard instanceof Response) {
return $guard;
}
$source = $this->source();
return $this->renderForm($guard, $source, [], null);
}
/**
* 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
*/
public function store(array $params = []): Response
{
$guard = $this->guard('order.create');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$source = $this->source();
$serviceMode = (string) ($form['service_mode'] ?? '');
// Numero de table (confort comptoir) : ne porte de sens qu'en sur place. On ne
// le transmet qu'en dine_in ; persist() le rejette de toute facon hors dine_in,
// mais ne pas le passer evite un INVALID_SERVICE_TAG sur une saisie residuelle.
$serviceTag = $serviceMode === 'dine_in' ? trim((string) ($form['service_tag'] ?? '')) : '';
// 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 ou un menu.', 422);
}
$req = ['service_mode' => $serviceMode, 'items' => $items];
if ($serviceTag !== '') {
$req['service_tag'] = $serviceTag;
}
try {
$order = $this->orders()->createStaffOrder(
$req,
$guard->userId ?? 0,
$source,
);
} catch (OrderValidationException $exception) {
return $this->renderForm($guard, $source, $form, $this->messageFor($exception->getMessage()), 422);
}
$this->setFlash('Commande ' . $order['order_number'] . ' enregistree et encaissee.');
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());
}
protected function orders(): OrderRepository
{
$db = $this->db();
return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db));
}
protected function productRepository(): ProductRepository
{
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
* la source auto-tagguee, les titres et les liens.
*/
private function source(): string
{
return str_starts_with($this->request->path(), '/drive') ? 'drive' : 'counter';
}
private function landing(string $source): string
{
return $source === 'drive' ? '/drive/orders' : '/counter/orders';
}
private function newPath(string $source): string
{
return $source === 'drive' ? '/drive/orders/new' : '/counter/orders/new';
}
private function channelTitle(string $source): string
{
return $source === 'drive' ? 'Commandes drive' : 'Commandes comptoir';
}
/**
* Rend le composeur produits (vue partagee par les deux canaux).
*
* @param array<string, mixed> $values valeurs du formulaire a reafficher (re-rendu d'erreur)
*/
private function renderForm(GuardResult $guard, string $source, array $values, ?string $error, int $status = 200): Response
{
$productRepository = $this->productRepository();
$products = $productRepository->availableForCatalogue();
// Modificateurs proposables par produit a la carte : seuls les produits dont la
// recette offre au moins un ingredient retirable/ajoutable portent une compo.
$products = array_map(function (array $product) use ($productRepository): array {
$product['modifiers'] = $this->proposableModifiers($productRepository, (int) ($product['id'] ?? 0));
return $product;
}, $products);
return $this->channelView('admin/counter/new', $source, [
'title' => 'Nouvelle commande ' . ($source === 'drive' ? 'drive' : 'comptoir') . ' - Wakdo Admin',
'products' => $products,
'menus' => $this->menusWithSlots($productRepository),
'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')),
'serviceTag' => (string) ($values['service_tag'] ?? ''),
'error' => $error,
], $guard, $status);
}
/**
* Menus commandables enrichis de leurs slots+options (lecture catalogue) ET des
* modificateurs proposables du burger support, pour que counter-order.js compose
* chaque menu SANS appel reseau supplementaire : toute la configuration est
* embarquee en data-* au rendu (page back-office authentifiee). La forme `slots`
* calque slotsWithOptions() (id, name, slot_type, is_required, display_order,
* option_product_ids), consommable par la meme logique que page-product-menu.js
* cote borne ; `burger_modifiers` calque proposableModifiers() (la selection de
* modificateurs d'un menu cible le burger, comme resolveModifiers cote serveur).
*
* @return list<array<string, mixed>>
*/
private function menusWithSlots(ProductRepository $productRepository): array
{
$menuRepository = $this->menuRepository();
$menus = $menuRepository->availableForCatalogue();
return array_map(function (array $menu) use ($menuRepository, $productRepository): array {
$menu['slots'] = $menuRepository->slotsWithOptions((int) ($menu['id'] ?? 0));
$menu['burger_modifiers'] = $this->proposableModifiers($productRepository, (int) ($menu['burger_product_id'] ?? 0));
return $menu;
}, $menus);
}
/**
* Modificateurs PROPOSABLES d'un produit support : les lignes de composition()
* dont l'ingredient est retirable (is_removable=1) OU ajoutable (is_addable=1),
* projetees a ce dont l'UI a besoin (ingredient_id, name, is_removable, is_addable,
* extra_price_cents). Les ingredients ni retirables ni ajoutables sont ECARTES :
* ils n'offrent aucune case a cocher cote client, donc embarquer leur ligne
* alourdirait le data-* sans usage. Le client ne fait que PROPOSER ces choix ;
* resolveModifiers revalide tout cote serveur et fige le surcout (RG-T16).
*
* @return list<array{ingredient_id:int, name:string, is_removable:int, is_addable:int, extra_price_cents:int}>
*/
private function proposableModifiers(ProductRepository $productRepository, int $productId): array
{
if ($productId <= 0) {
return [];
}
$out = [];
foreach ($productRepository->composition($productId) as $line) {
$isRemovable = (int) ($line['is_removable'] ?? 0);
$isAddable = (int) ($line['is_addable'] ?? 0);
if ($isRemovable !== 1 && $isAddable !== 1) {
continue;
}
$out[] = [
'ingredient_id' => (int) ($line['ingredient_id'] ?? 0),
'name' => (string) ($line['ingredient_name'] ?? ''),
'is_removable' => $isRemovable,
'is_addable' => $isAddable,
'extra_price_cents' => (int) ($line['extra_price_cents'] ?? 0),
];
}
return $out;
}
/**
* Vue de canal : injecte les liens et le titre derives de la source pour que les
* vues partagees (comptoir/drive) s'adaptent sans connaitre le decoupage par chemin.
*
* @param array<string, mixed> $data
*/
private function channelView(string $name, string $source, array $data, GuardResult $guard, int $status = 200): Response
{
return $this->adminView($name, $data + [
'activeNav' => $source === 'drive' ? 'drive' : 'counter',
'source' => $source,
'channelTitle' => $this->channelTitle($source),
'landing' => $this->landing($source),
'newPath' => $this->newPath($source),
], $guard, $status);
}
/**
* Message lisible pour un code d'erreur metier (re-rendu de formulaire).
*/
private function messageFor(string $code): string
{
return match ($code) {
'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.',
};
}
private function redirect(string $location): Response
{
return Response::make('', 302, ['Location' => $location]);
}
private function invalidCsrf(): Response
{
return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']);
}
}