diff --git a/src/app/Controllers/CounterOrderController.php b/src/app/Controllers/CounterOrderController.php new file mode 100644 index 0000000..623b16f --- /dev/null +++ b/src/app/Controllers/CounterOrderController.php @@ -0,0 +1,231 @@ + '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. + * + * 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. + * + * 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 $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('order.create'); + if ($guard instanceof Response) { + return $guard; + } + + $source = $this->source(); + + // 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( + $this->orderQuery()->recent(50), + static fn (array $o): bool => (string) ($o['source'] ?? '') === $source, + )); + + return $this->channelView('admin/counter/index', $source, [ + 'title' => $this->channelTitle($source) . ' - Wakdo Admin', + 'orders' => $orders, + ], $guard); + } + + /** + * Composeur de commande (GET .../new) : produits commandables + select service_mode. + * + * @param array $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). 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. + * + * @param array $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'] ?? ''); + + // Panier = une ligne produit par quantite >= 1. Le champ s'appelle qty_ + // (un champ nombre par produit listable) ; on ne retient que les positifs. + $items = []; + foreach ($form as $key => $value) { + if (!str_starts_with($key, 'qty_')) { + continue; + } + $productId = (int) substr($key, 4); + $quantity = ctype_digit(trim($value)) ? (int) $value : 0; + if ($productId > 0 && $quantity >= 1) { + $items[] = ['type' => 'product', 'product_id' => $productId, 'quantity' => $quantity]; + } + } + + if ($items === []) { + return $this->renderForm($guard, $source, $form, 'Ajoutez au moins un produit (quantite >= 1).', 422); + } + + try { + $order = $this->orders()->createStaffOrder( + ['service_mode' => $serviceMode, 'items' => $items], + $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)); + } + + 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()); + } + + /** + * 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 $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 + { + return $this->channelView('admin/counter/new', $source, [ + 'title' => 'Nouvelle commande ' . ($source === 'drive' ? 'drive' : 'comptoir') . ' - Wakdo Admin', + 'products' => $this->productRepository()->availableForCatalogue(), + 'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')), + 'error' => $error, + ], $guard, $status); + } + + /** + * 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 $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.', + '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.', + }; + } + + 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']); + } +} diff --git a/src/app/Order/OrderQueryRepository.php b/src/app/Order/OrderQueryRepository.php index 81268ab..e6bf285 100644 --- a/src/app/Order/OrderQueryRepository.php +++ b/src/app/Order/OrderQueryRepository.php @@ -33,7 +33,7 @@ class OrderQueryRepository $limit = max(1, min(200, $limit)); return $this->db->fetchAll( - 'SELECT order_number, service_mode, service_tag, status, total_ttc_cents, created_at, paid_at ' + 'SELECT order_number, source, service_mode, service_tag, status, total_ttc_cents, created_at, paid_at ' . 'FROM customer_order ORDER BY created_at DESC, id DESC LIMIT ' . $limit, ); } diff --git a/src/app/Order/OrderRepository.php b/src/app/Order/OrderRepository.php index 54cb782..3cfc3e1 100644 --- a/src/app/Order/OrderRepository.php +++ b/src/app/Order/OrderRepository.php @@ -106,6 +106,70 @@ class OrderRepository return $existing; } + // Borne anonyme : source 'kiosk', prefixe 'K', aucun acteur (acting_user_id NULL). + // Aucune contrainte croisee de service_mode (mlt RG-6 : kiosk n'implique rien). + return $this->persist($req, 'kiosk', 'K', null); + } + + /** + * Cree une commande comptoir/drive (CREATE_COUNTER_ORDER, mlt 4.1). Meme logique + * de creation que le kiosk (resolution + INSERT pending_payment), MAIS la commande + * est immediatement encaissee (paid + decrement de stock, RG-T20) : POST-1 exige + * status='paid', paid_at et acting_user_id poses des la creation. Aucun PIN : la + * permission order.create suffit (la creation n'est pas dans l'ensemble sensible). + * + * - source auto-tagguee depuis le role de l'equipier (counter / drive, RG-1) ; + * - service_mode choisi par l'equipier (dine_in / takeaway / drive) ; + * - RG-T09 : source 'drive' impose service_mode='drive' (verifie avant l'INSERT) ; + * - acting_user_id + stock_movement.user_id = id de l'equipier authentifie (RG-5). + * + * Numero : prefixe 'C' (counter) / 'D' (drive) + id, coherent avec le 'K'+id du + * kiosk (decision projet, diverge du C-AAAA-MM-JJ-NNN de la spec RG-3 : plus + * simple, pas de compteur sequentiel par jour ni de service_day a tenir). + * + * @param array $req + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException source invalide, RG-T09, reference indisponible. + */ + public function createStaffOrder(array $req, int $actingUserId, string $source): array + { + if (!in_array($source, ['counter', 'drive'], true)) { + throw new OrderValidationException('INVALID_SOURCE'); + } + + // RG-T09 / RG-2 (4.1) : la contrainte croisee drive est verifiee AVANT l'INSERT. + // service_mode est valide par persist() (in [dine_in, takeaway, drive]) ; on + // n'ajoute ici que le resserrement specifique au canal drive. + if ($source === 'drive' && (string) ($req['service_mode'] ?? '') !== 'drive') { + throw new OrderValidationException('INVALID_SERVICE_MODE'); + } + + $prefix = $source === 'drive' ? 'D' : 'C'; + $created = $this->persist($req, $source, $prefix, $actingUserId); + + // POST-1 : encaissement immediat (paid + paid_at + decrement stock RG-T20 avec + // user_id=equipier). pay() est idempotent et porte l'acteur dans acting_user_id + // (COALESCE) et stock_movement.user_id. Le numero (prefixe canal + id) sert de + // cle de transition. + return $this->pay($created['order_number'], $actingUserId); + } + + /** + * Corps partage de la creation (resolution des lignes + transaction d'INSERT + * customer_order / order_item / selections / modifiers) en pending_payment. La + * source, le prefixe de numero et l'acteur sont des PARAMETRES : c'est la seule + * difference structurelle entre le kiosk (kiosk / 'K' / NULL) et le comptoir-drive + * (counter|drive / 'C'|'D' / id equipier). Le calcul de prix (RG-T16) et les + * snapshots (RG-T05) sont identiques quelle que soit l'origine. + * + * @param array $req + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException si une reference est invalide / indisponible. + */ + private function persist(array $req, string $source, string $prefix, ?int $actingUserId): array + { + $key = trim((string) ($req['idempotency_key'] ?? '')); + $serviceMode = (string) ($req['service_mode'] ?? ''); if (!in_array($serviceMode, ['dine_in', 'takeaway', 'drive'], true)) { throw new OrderValidationException('INVALID_SERVICE_MODE'); @@ -136,23 +200,25 @@ class OrderRepository $result = ['id' => 0, 'order_number' => '', 'total_ttc_cents' => $totalTtc, 'status' => 'pending_payment']; - $this->db->transaction(function (DatabaseInterface $db) use ($key, $serviceMode, $serviceTag, $lines, $totalTtc, $totalHt, $totalVat, &$result): void { + $this->db->transaction(function (DatabaseInterface $db) use ($key, $source, $prefix, $actingUserId, $serviceMode, $serviceTag, $lines, $totalTtc, $totalHt, $totalVat, &$result): void { $db->execute( 'INSERT INTO customer_order ' . '(order_number, idempotency_key, source, service_mode, service_tag, status, ' - . ' total_ht_cents, total_vat_cents, total_ttc_cents) ' - . "VALUES ('', :idem, 'kiosk', :mode, :tag, 'pending_payment', :ht, :vat, :ttc)", + . ' acting_user_id, total_ht_cents, total_vat_cents, total_ttc_cents) ' + . "VALUES ('', :idem, :source, :mode, :tag, 'pending_payment', :acting, :ht, :vat, :ttc)", [ - 'idem' => $key !== '' ? $key : null, - 'mode' => $serviceMode, - 'tag' => $serviceTag !== '' ? $serviceTag : null, - 'ht' => $totalHt, - 'vat' => $totalVat, - 'ttc' => $totalTtc, + 'idem' => $key !== '' ? $key : null, + 'source' => $source, + 'mode' => $serviceMode, + 'tag' => $serviceTag !== '' ? $serviceTag : null, + 'acting' => $actingUserId, + 'ht' => $totalHt, + 'vat' => $totalVat, + 'ttc' => $totalTtc, ], ); $orderId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); - $orderNumber = 'K' . $orderId; + $orderNumber = $prefix . $orderId; $db->execute( 'UPDATE customer_order SET order_number = :num WHERE id = :id', ['num' => $orderNumber, 'id' => $orderId], diff --git a/src/app/Views/admin/counter/index.php b/src/app/Views/admin/counter/index.php new file mode 100644 index 0000000..025bda8 --- /dev/null +++ b/src/app/Views/admin/counter/index.php @@ -0,0 +1,80 @@ +> $orders + * @var string $channelTitle + * @var string $newPath + */ + +$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'; + +$modeLabel = static fn (string $m): string => match ($m) { + 'dine_in' => 'Sur place', + 'takeaway' => 'A emporter', + 'drive' => 'Drive', + default => $m, +}; + +$statusLabel = static fn (string $s): string => match ($s) { + 'pending_payment' => 'En attente', + 'paid' => 'Payee', + 'delivered' => 'Livree', + 'cancelled' => 'Annulee', + default => $s, +}; + +$statusPill = static fn (string $s): string => match ($s) { + 'paid', 'delivered' => 'pill-success', + 'cancelled' => 'pill-danger', + default => 'pill-warning', +}; + +/** @var list> $rows */ +$rows = isset($orders) && is_array($orders) ? $orders : []; +$heading = isset($channelTitle) && is_string($channelTitle) ? $channelTitle : 'Commandes'; +$createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orders/new'; +?> + +
+ +

commande(s) recente(s)

+ + +

Aucune commande pour ce canal.

+ + + + + + + + + + + + + + + + + + + + + + + +
NumeroModeStatutTotalDate
+ +
diff --git a/src/app/Views/admin/counter/new.php b/src/app/Views/admin/counter/new.php new file mode 100644 index 0000000..49fcb63 --- /dev/null +++ b/src/app/Views/admin/counter/new.php @@ -0,0 +1,89 @@ +) + 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. + * + * @var list> $products + * @var string $source 'counter' | 'drive' + * @var string $serviceMode valeur preselectionnee / reaffichee + * @var string $landing retour a la liste du canal + * @var string|null $error + * @var string $csrfToken + */ + +$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'; + +$csrf = $esc($csrfToken ?? ''); +$chan = isset($source) && $source === 'drive' ? 'drive' : 'counter'; +$action = $chan === 'drive' ? '/drive/orders' : '/counter/orders'; +$backTo = isset($landing) && is_string($landing) ? $landing : '/counter/orders'; +$mode = isset($serviceMode) && is_string($serviceMode) ? $serviceMode : ($chan === 'drive' ? 'drive' : 'dine_in'); +$errorMessage = isset($error) && is_string($error) ? $error : null; + +/** @var list> $rows */ +$rows = isset($products) && is_array($products) ? $products : []; + +// RG-T09 : au drive, le seul mode possible est 'drive'. Le comptoir choisit librement. +$modeOptions = $chan === 'drive' + ? ['drive' => 'Drive'] + : ['dine_in' => 'Sur place', 'takeaway' => 'A emporter']; +?> + + + + + + +
+ + +
+ + +
+ + +

Aucun produit commandable pour le moment.

+ + + + + + + + + + + + + + + + + + + +
ProduitPrixQuantite
+ +
+ + +
+ + Annuler +
+
diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index 5da6d80..5707473 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -115,9 +115,14 @@ $navClass = static function (string $code, string $current): string { - +