From 0226d1022ba79daf6f46c47e27441d14d6488f9c Mon Sep 17 00:00:00 2001 From: Imugiii Date: Mon, 22 Jun 2026 07:15:01 +0000 Subject: [PATCH] feat(orders): KDS cuisine + transition paid vers delivered (P3 operationnel) --- src/app/Controllers/KitchenController.php | 52 +++++++ src/app/Controllers/OrderAdminController.php | 70 ++++++++- src/app/Order/OrderQueryRepository.php | 52 +++++++ src/app/Order/OrderRepository.php | 51 +++++++ src/app/Views/admin/kitchen/display.php | 59 ++++++++ src/app/Views/admin/layout.php | 1 + src/public/admin/index.php | 6 + tests/Unit/Admin/KitchenControllerTest.php | 135 ++++++++++++++++++ tests/Unit/Admin/OrderAdminControllerTest.php | 17 +++ tests/Unit/Order/OrderRepositoryTest.php | 72 ++++++++++ 10 files changed, 508 insertions(+), 7 deletions(-) create mode 100644 src/app/Controllers/KitchenController.php create mode 100644 src/app/Views/admin/kitchen/display.php create mode 100644 tests/Unit/Admin/KitchenControllerTest.php diff --git a/src/app/Controllers/KitchenController.php b/src/app/Controllers/KitchenController.php new file mode 100644 index 0000000..df605b7 --- /dev/null +++ b/src/app/Controllers/KitchenController.php @@ -0,0 +1,52 @@ + $params + */ + public function display(array $params = []): Response + { + $guard = $this->guard('order.read'); + if ($guard instanceof Response) { + return $guard; + } + + $sources = $this->orderQuery()->visibleSources($guard->roleId ?? 0); + + return $this->adminView('admin/kitchen/display', [ + 'title' => 'Cuisine - Wakdo Admin', + 'activeNav' => 'kitchen', + 'orders' => $this->orderQuery()->paidQueue($sources), + 'canDeliver' => $this->may($guard, 'order.deliver'), + ], $guard); + } + + protected function orderQuery(): OrderQueryRepository + { + return new OrderQueryRepository($this->db()); + } + + private function may(GuardResult $guard, string $permission): bool + { + return $this->authorizer()->can($guard->roleId ?? 0, $permission); + } +} diff --git a/src/app/Controllers/OrderAdminController.php b/src/app/Controllers/OrderAdminController.php index 699172b..71d6d39 100644 --- a/src/app/Controllers/OrderAdminController.php +++ b/src/app/Controllers/OrderAdminController.php @@ -4,15 +4,22 @@ declare(strict_types=1); namespace App\Controllers; +use App\Auth\Csrf; +use App\Catalogue\MenuRepository; +use App\Catalogue\ProductRepository; use App\Core\Response; use App\Order\OrderQueryRepository; +use App\Order\OrderRepository; +use App\Order\OrderValidationException; /** - * Liste des commandes back-office (P4, domaine 7). GET /admin/orders, permission - * order.read. Lecture seule : pas de transition de statut ici (deliver/cancel = - * ecrans operationnels kitchen/counter, hors back-office MVP). + * Domaine commande back-office (P4 + P3 operationnel). GET /admin/orders : liste + * recente (order.read). POST /admin/orders/{number}/deliver : transition paid -> + * delivered (DELIVER_ORDER, geste unique, order.deliver), NON PIN-gated. L'annulation + * (CANCEL_ORDER, PIN + restock) et la file cuisine (KitchenController) sont traitees + * ailleurs. * - * Non `final` : les tests sous-classent (seam db()/orderQuery()). + * Non `final` : les tests sous-classent (seam db()/orderQuery()/orders()). */ class OrderAdminController extends AdminController { @@ -27,14 +34,63 @@ class OrderAdminController extends AdminController } return $this->adminView('admin/orders/index', [ - 'title' => 'Commandes - Wakdo Admin', - 'activeNav' => 'orders', - 'orders' => $this->orderQuery()->recent(50), + 'title' => 'Commandes - Wakdo Admin', + 'activeNav' => 'orders', + 'orders' => $this->orderQuery()->recent(50), ], $guard); } + /** + * Remise au client : paid -> delivered (mlt 6.1). POST + CSRF, garde order.deliver. + * Pas de PIN (geste routinier). Issue affichee en flash, retour a la liste. + * + * @param array $params + */ + public function deliver(array $params = []): Response + { + $guard = $this->guard('order.deliver'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + try { + $this->orders()->deliver((string) ($params['number'] ?? '')); + $this->setFlash('Commande remise (livree).'); + } catch (OrderValidationException $exception) { + $this->setFlash( + $exception->getMessage() === 'ORDER_NOT_FOUND' + ? 'Commande introuvable.' + : 'Transition invalide : la commande n\'est pas au statut paye.', + ); + } + + return $this->redirect('/admin/orders'); + } + 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)); + } + + 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 405e882..81268ab 100644 --- a/src/app/Order/OrderQueryRepository.php +++ b/src/app/Order/OrderQueryRepository.php @@ -38,6 +38,58 @@ class OrderQueryRepository ); } + /** + * Sources de commande visibles par un role (role_visible_source, dictionary 3.16). + * Liste vide en base = vue globale (admin / manager voient tout) : on renvoie alors + * les trois sources. Sert a filtrer la file de preparation par canal. + * + * @return list + */ + public function visibleSources(int $roleId): array + { + $rows = $this->db->fetchAll( + 'SELECT source FROM role_visible_source WHERE role_id = :r', + ['r' => $roleId], + ); + $sources = array_values(array_filter(array_map( + static fn (array $r): string => (string) ($r['source'] ?? ''), + $rows, + ))); + + return $sources === [] ? ['kiosk', 'counter', 'drive'] : $sources; + } + + /** + * File de preparation (KDS) : commandes au statut `paid`, triees par paid_at + * CROISSANT (la plus ancienne d'abord, RG-T12), filtrees par les sources visibles. + * Les sources viennent d'une allowlist (role_visible_source) et sont liees comme + * parametres. Liste de sources vide -> file vide (pas de canal visible). + * + * @param list $sources + * @return list> + */ + public function paidQueue(array $sources): array + { + if ($sources === []) { + return []; + } + + $placeholders = []; + $params = []; + foreach (array_values($sources) as $i => $source) { + $key = 's' . $i; + $placeholders[] = ':' . $key; + $params[$key] = $source; + } + + return $this->db->fetchAll( + 'SELECT order_number, source, service_mode, service_tag, total_ttc_cents, paid_at ' + . 'FROM customer_order WHERE status = \'paid\' AND source IN (' . implode(', ', $placeholders) . ') ' + . 'ORDER BY paid_at ASC, id ASC', + $params, + ); + } + /** * KPIs de vente : CA encaisse (statuts paid + delivered), nombre de commandes * encaissees, panier moyen, CA et nombre du JOUR, total de commandes, et la diff --git a/src/app/Order/OrderRepository.php b/src/app/Order/OrderRepository.php index a556b11..133c0b9 100644 --- a/src/app/Order/OrderRepository.php +++ b/src/app/Order/OrderRepository.php @@ -261,6 +261,57 @@ class OrderRepository return $result; } + /** + * Transition paid -> delivered (DELIVER_ORDER, geste unique de remise, mlt 6.1). + * NON PIN-gated : operation routiniere, hors ensemble sensible RG-T13. Idempotente + * (une commande deja delivered est renvoyee sans erreur). 404 si inconnue ; + * INVALID_TRANSITION si la commande n'est pas au statut paid (pending / cancelled). + * + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException + */ + public function deliver(string $orderNumber): array + { + $order = $this->db->fetch( + 'SELECT id, order_number, total_ttc_cents, status FROM customer_order WHERE order_number = :n', + ['n' => $orderNumber], + ); + if ($order === null) { + throw new OrderValidationException('ORDER_NOT_FOUND'); + } + + $result = [ + 'id' => (int) $order['id'], + 'order_number' => (string) $order['order_number'], + 'total_ttc_cents' => (int) $order['total_ttc_cents'], + 'status' => 'delivered', + ]; + + $status = (string) $order['status']; + if ($status === 'delivered') { + return $result; // idempotent : remise deja actee. + } + if ($status !== 'paid') { + throw new OrderValidationException('INVALID_TRANSITION'); // pending_payment / cancelled. + } + + $affected = $this->db->execute( + 'UPDATE customer_order SET status = \'delivered\', delivered_at = NOW(), ' + . 'updated_at = NOW() WHERE id = :id AND status = \'paid\'', + ['id' => (int) $order['id']], + ); + if ($affected === 0) { + // Course perdue : un autre appel a deja transite. Idempotent si delivered. + $current = (string) ($this->db->fetch('SELECT status FROM customer_order WHERE id = :id', ['id' => (int) $order['id']])['status'] ?? ''); + if ($current === 'delivered') { + return $result; + } + throw new OrderValidationException('INVALID_TRANSITION'); + } + + return $result; + } + /** * Unites de stock a decrementer, AGREGEES par ingredient_id sur toute la * commande (lecture des lignes persistees + recettes des produits supports). diff --git a/src/app/Views/admin/kitchen/display.php b/src/app/Views/admin/kitchen/display.php new file mode 100644 index 0000000..0b1363c --- /dev/null +++ b/src/app/Views/admin/kitchen/display.php @@ -0,0 +1,59 @@ +> $orders + * @var bool $canDeliver + * @var string $csrfToken + */ + +$esc = static fn ($v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$csrf = $esc($csrfToken ?? ''); +$rows = isset($orders) && is_array($orders) ? $orders : []; +$can = !empty($canDeliver); + +$sourceLabel = static fn (string $s): string => ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive'][$s] ?? $s; +$modeLabel = static fn (string $m): string => $m === 'dine_in' ? 'Sur place' : ($m === 'drive' ? 'Drive' : 'A emporter'); +?> + + + +

Aucune commande en attente de preparation.

+ +
+ +
+
+ + +
+
+

Mode :

+ +

Table :

+ +

Payee a :

+
+ + + +
+ +
+ diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index 1db63c7..5da6d80 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -123,6 +123,7 @@ $navClass = static function (string $code, string $current): string { Commandes + Cuisine (KDS) diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 4ae17bb..a095186 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -18,6 +18,7 @@ use App\Controllers\DashboardController; use App\Controllers\HealthController; use App\Controllers\HomeController; use App\Controllers\IngredientController; +use App\Controllers\KitchenController; use App\Controllers\MeController; use App\Controllers\MenuController; use App\Controllers\OrderAdminController; @@ -112,6 +113,11 @@ try { // Commandes (P4, order.read) : liste lecture seule du domaine commande. $router->add('GET', '/admin/orders', [OrderAdminController::class, 'index']); + // Remise au client : paid -> delivered (order.deliver, geste unique, POST + CSRF). + $router->add('POST', '/admin/orders/{number}/deliver', [OrderAdminController::class, 'deliver']); + // Affichage cuisine (KDS) : file des commandes payees (order.read). Landing du role + // kitchen (seed role.default_route = /kitchen/display) ; corrige le 404 d'apres-login. + $router->add('GET', '/kitchen/display', [KitchenController::class, 'display']); // Gestion des comptes (mlt domaine 10). user.read (liste) ; user.create/update/ // deactivate. TOUTES les mutations = PIN equipier + audit (RG-T13/14). {id} = un diff --git a/tests/Unit/Admin/KitchenControllerTest.php b/tests/Unit/Admin/KitchenControllerTest.php new file mode 100644 index 0000000..b933b07 --- /dev/null +++ b/tests/Unit/Admin/KitchenControllerTest.php @@ -0,0 +1,135 @@ + 'K42', 'source' => 'kiosk', 'service_mode' => 'dine_in', 'service_tag' => '12', 'total_ttc_cents' => 990, 'paid_at' => '2026-06-19 12:01:00'], + ]; + } +} + +final class TestKitchenController extends KitchenController +{ + public function __construct( + Request $request, + Config $config, + Database $database, + private readonly SessionManager $testSession, + private readonly FakeDatabase $fakeDb, + ) { + parent::__construct($request, $config, $database); + } + + protected function sessionManager(): SessionManager + { + return $this->testSession; + } + + protected function db(): DatabaseInterface + { + return $this->fakeDb; + } + + protected function orderQuery(): OrderQueryRepository + { + return new StubKitchenQuery($this->fakeDb); + } +} + +final class KitchenControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + private SessionManager $session; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 3); // kitchen + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Kim', 'last_name' => 'C', 'role_label' => 'Cuisine']; + $db->canResult = true; + $db->permissionCodes = ['order.read']; + + return $db; + } + + private function controller(FakeDatabase $db): TestKitchenController + { + $request = new Request('GET', '/kitchen/display', [], [], '', '203.0.113.5'); + + return new TestKitchenController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + public function testRequiresOrderRead(): void + { + $db = $this->permittedDb(); + $db->canResult = false; + + self::assertSame(403, $this->controller($db)->display()->status()); + } + + public function testRendersPaidQueue(): void + { + $response = $this->controller($this->permittedDb())->display(); + + self::assertSame(200, $response->status()); + $body = $response->body(); + self::assertStringContainsString('Cuisine', $body); + self::assertStringContainsString('K42', $body); + self::assertStringContainsString('kitchen-grid', $body); + // order.deliver accorde (canResult=true) -> bouton de remise present. + self::assertStringContainsString('Remettre', $body); + } +} diff --git a/tests/Unit/Admin/OrderAdminControllerTest.php b/tests/Unit/Admin/OrderAdminControllerTest.php index 3bc7007..1ab4da6 100644 --- a/tests/Unit/Admin/OrderAdminControllerTest.php +++ b/tests/Unit/Admin/OrderAdminControllerTest.php @@ -129,4 +129,21 @@ final class OrderAdminControllerTest extends TestCase self::assertStringContainsString('Payee', $body); // statut paid self::assertStringContainsString('A emporter', $body); // takeaway -> libelle } + + public function testDeliverRequiresOrderDeliverPermission(): void + { + $db = $this->permittedDb(); + $db->canResult = false; // pas de order.deliver -> 403 avant toute action + + self::assertSame(403, $this->controller($db)->deliver(['number' => 'K42'])->status()); + } + + public function testDeliverRejectsInvalidCsrf(): void + { + // order.deliver accorde (canResult=true) mais aucun jeton CSRF dans la requete + // -> la garde CSRF refuse (403) avant toute transition. + $response = $this->controller($this->permittedDb())->deliver(['number' => 'K42']); + + self::assertSame(403, $response->status()); + } } diff --git a/tests/Unit/Order/OrderRepositoryTest.php b/tests/Unit/Order/OrderRepositoryTest.php index f917ec9..fabd774 100644 --- a/tests/Unit/Order/OrderRepositoryTest.php +++ b/tests/Unit/Order/OrderRepositoryTest.php @@ -285,4 +285,76 @@ final class OrderRepositoryTest extends TestCase $move = $db->firstWrite('INSERT INTO stock_movement'); self::assertSame(7, $move['uid']); } + + public function testDeliverTransitionsPaidToDelivered(): void + { + $db = new FakeOrderDatabase(); + $db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid']; + + $res = $this->repo($db)->deliver('K100'); + + self::assertSame('delivered', $res['status']); + self::assertSame('K100', $res['order_number']); + self::assertNotSame([], $db->firstWrite('UPDATE customer_order SET status')); + } + + public function testDeliverUnknownThrows(): void + { + $db = new FakeOrderDatabase(); + $db->orderByNumber = null; + + $this->expectException(OrderValidationException::class); + $this->expectExceptionMessage('ORDER_NOT_FOUND'); + $this->repo($db)->deliver('K404'); + } + + public function testDeliverNonPaidThrowsInvalidTransition(): void + { + $db = new FakeOrderDatabase(); + $db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment']; + + $this->expectException(OrderValidationException::class); + $this->expectExceptionMessage('INVALID_TRANSITION'); + $this->repo($db)->deliver('K100'); + } + + public function testDeliverAlreadyDeliveredIsIdempotent(): void + { + $db = new FakeOrderDatabase(); + $db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'delivered']; + + $res = $this->repo($db)->deliver('K100'); + + self::assertSame('delivered', $res['status']); + // Idempotent : aucune transition reecrite. + self::assertSame([], $db->firstWrite('UPDATE customer_order SET status')); + } + + public function testDeliverConcurrentRaceRecoversIdempotent(): void + { + // Course perdue : l'UPDATE garde par status='paid' n'affecte 0 ligne (un autre + // appel a deja transite). Le recheck voit 'delivered' -> on sort idempotent. + $db = new FakeOrderDatabase(); + $db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid']; + $db->payUpdateAffected = 0; + $db->recheckStatus = 'delivered'; + + $res = $this->repo($db)->deliver('K100'); + + self::assertSame('delivered', $res['status']); + } + + public function testDeliverConcurrentRaceToTerminalThrows(): void + { + // Course perdue ET le recheck montre un statut non-delivered (ex. cancelled) + // -> transition invalide. + $db = new FakeOrderDatabase(); + $db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid']; + $db->payUpdateAffected = 0; + $db->recheckStatus = 'cancelled'; + + $this->expectException(OrderValidationException::class); + $this->expectExceptionMessage('INVALID_TRANSITION'); + $this->repo($db)->deliver('K100'); + } }