diff --git a/src/app/Controllers/OrderAdminController.php b/src/app/Controllers/OrderAdminController.php index 71d6d39..dd348d7 100644 --- a/src/app/Controllers/OrderAdminController.php +++ b/src/app/Controllers/OrderAdminController.php @@ -5,8 +5,13 @@ declare(strict_types=1); namespace App\Controllers; use App\Auth\Csrf; +use App\Auth\GuardResult; +use App\Auth\PasswordHasher; +use App\Auth\PinThrottle; +use App\Auth\PinVerifier; use App\Catalogue\MenuRepository; use App\Catalogue\ProductRepository; +use App\Core\DatabaseInterface; use App\Core\Response; use App\Order\OrderQueryRepository; use App\Order\OrderRepository; @@ -15,11 +20,12 @@ use App\Order\OrderValidationException; /** * 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. + * delivered (DELIVER_ORDER, geste unique, order.deliver), NON PIN-gated. + * GET/POST /admin/orders/{number}/cancel : annulation (CANCEL_ORDER, mlt 7.1, + * order.cancel) avec PIN equipier + audit + restock conditionnel (RG-T13/T14). La + * file cuisine (KitchenController) est traitee ailleurs. * - * Non `final` : les tests sous-classent (seam db()/orderQuery()/orders()). + * Non `final` : les tests sous-classent (seam db()/orderQuery()/orders()/pin*()). */ class OrderAdminController extends AdminController { @@ -37,6 +43,9 @@ class OrderAdminController extends AdminController 'title' => 'Commandes - Wakdo Admin', 'activeNav' => 'orders', 'orders' => $this->orderQuery()->recent(50), + // RG-T03 : adapte l'affichage (bouton Annuler) sans remplacer la garde + // par-action de cancel(). manager n'a PAS order.cancel (decision D5). + 'canCancel' => $this->may($guard, 'order.cancel'), ], $guard); } @@ -72,6 +81,98 @@ class OrderAdminController extends AdminController return $this->redirect('/admin/orders'); } + /** + * Page de confirmation d'annulation (CANCEL_ORDER, mlt 7.1). Garde order.cancel. + * Affiche numero/statut/total + le formulaire PIN equipier (modele RG-T13). La + * commande est chargee en lecture seule (OrderRepository::findByNumber) ; statut + * terminal (delivered/cancelled) -> message bloquant, pas de formulaire. + * + * @param array $params + */ + public function confirmCancel(array $params): Response + { + $guard = $this->guard('order.cancel'); + if ($guard instanceof Response) { + return $guard; + } + + $order = $this->orders()->findByNumber((string) ($params['number'] ?? '')); + if ($order === null) { + return $this->notFound($guard); + } + + return $this->renderCancel($guard, $order, null); + } + + /** + * Annulation effective (CANCEL_ORDER, mlt 7.1). POST + CSRF + garde order.cancel, + * puis flux PIN equipier IDENTIQUE a IngredientController::inventory (RG-T13/T22) : + * verrou throttle par utilisateur AGISSANT evalue AVANT la verification (leurre de + * timing, message generique) ; sur echec PIN -> pin.failed + increment throttle dans + * UNE transaction. Sur PIN OK -> OrderRepository::cancel (transition + restock + * conditionnel + audit dans sa propre transaction), reset du throttle, flash. + * + * @param array $params + */ + public function cancel(array $params): Response + { + $guard = $this->guard('order.cancel'); + if ($guard instanceof Response) { + return $guard; + } + + $form = $this->request->formBody(); + if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) { + return $this->invalidCsrf(); + } + + $number = (string) ($params['number'] ?? ''); + $order = $this->orders()->findByNumber($number); + if ($order === null) { + return $this->notFound($guard); + } + + // RG-T22 : verrou du throttle par utilisateur AGISSANT (session), evalue AVANT + // la verification ; sous verrou, leurre de timing + message generique, pas de + // nouvelle ligne pin.failed (les echecs ayant arme le verrou sont deja audites). + $actorId = $guard->userId ?? 0; + if ($actorId > 0 && $this->pinThrottle()->isLocked($actorId)) { + $this->pinVerifier()->payTimingDecoy($form['pin'] ?? ''); + + return $this->renderCancel($guard, $order, 'Email ou PIN invalide (requis pour annuler).', 422); + } + + $actor = $this->pinVerifier()->resolveActingUser(trim($form['pin_email'] ?? ''), $form['pin'] ?? ''); + if ($actor === null) { + // RG-T08 : trace pin.failed (RG-T14) + increment throttle (RG-T22) dans UNE + // transaction. pin.failed est un evenement securite (pas l'effet metier). + $email = trim($form['pin_email'] ?? ''); + $entityId = (int) ($order['id'] ?? 0); + $this->db()->transaction(function (DatabaseInterface $db) use ($email, $entityId, $actorId): void { + $this->logFailedPin($db, $email, $entityId); + $this->pinThrottle()->recordFailureWithin($db, $actorId); + }); + + return $this->renderCancel($guard, $order, 'Email ou PIN invalide (requis pour annuler).', 422); + } + + try { + $this->orders()->cancel($number, (int) $actor['id'], (int) $actor['role_id']); + // PIN valide : reinitialise le compteur de l'acteur de SESSION (RG-T22, cle + // = $actorId), surtout pas $actor['id'] (l'equipier resolu par le PIN). + $this->pinThrottle()->reset($actorId); + $this->setFlash('Commande annulee.'); + } catch (OrderValidationException $exception) { + $this->setFlash(match ($exception->getMessage()) { + 'ORDER_NOT_FOUND' => 'Commande introuvable.', + 'CANNOT_CANCEL_IN_STATE' => 'Annulation impossible : la commande est livree ou deja annulee.', + default => 'Transition invalide : la commande a change d\'etat.', + }); + } + + return $this->redirect('/admin/orders'); + } + protected function orderQuery(): OrderQueryRepository { return new OrderQueryRepository($this->db()); @@ -84,6 +185,68 @@ class OrderAdminController extends AdminController return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db)); } + protected function pinVerifier(): PinVerifier + { + return new PinVerifier($this->db(), $this->config, $this->passwordHasher()); + } + + protected function pinThrottle(): PinThrottle + { + return new PinThrottle($this->db(), $this->config); + } + + protected function passwordHasher(): PasswordHasher + { + return new PasswordHasher($this->config); + } + + /** + * RG-T03 : la permission est-elle detenue par le role de la session courante ? + * Sert a adapter l'affichage (bouton Annuler) sans remplacer la garde par-action. + */ + private function may(GuardResult $guard, string $permission): bool + { + return $guard->roleId !== null && $this->authorizer()->can($guard->roleId, $permission); + } + + /** + * Trace une tentative de PIN echouee sur l'annulation (RG-T14) : rend le + * brute-force d'attribution detectable. Acteur inconnu (PIN non resolu). + */ + private function logFailedPin(DatabaseInterface $db, string $email, int $orderId): void + { + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => null, + 'rid' => null, + 'code' => 'pin.failed', + 'etype' => 'customer_order', + 'eid' => $orderId, + 'summary' => 'Echec PIN annulation (email tente: ' . $email . ')', + ], + ); + } + + /** + * @param array{id:int, order_number:string, total_ttc_cents:int, status:string} $order + */ + private function renderCancel(GuardResult $guard, array $order, ?string $error, int $status = 200): Response + { + return $this->adminView('admin/orders/cancel', [ + 'title' => 'Annuler une commande - Wakdo Admin', + 'activeNav' => 'orders', + 'order' => $order, + 'error' => $error, + ], $guard, $status); + } + + private function notFound(GuardResult $guard): Response + { + return $this->adminView('admin/not_found', ['title' => 'Introuvable', 'activeNav' => 'orders'], $guard, 404); + } + private function redirect(string $location): Response { return Response::make('', 302, ['Location' => $location]); diff --git a/src/app/Order/OrderRepository.php b/src/app/Order/OrderRepository.php index 623c36e..54cb782 100644 --- a/src/app/Order/OrderRepository.php +++ b/src/app/Order/OrderRepository.php @@ -341,6 +341,123 @@ class OrderRepository return $result; } + /** + * Annulation d'une commande (CANCEL_ORDER, mlt 7.1). Transition gardee + * pending_payment|paid -> cancelled, re-credit de stock CONDITIONNEL et ecriture + * audit_log dans UNE transaction (RG-T07/T08/T11/T14). + * + * Le re-credit n'a lieu que si la commande etait `paid` AVANT l'annulation : une + * commande `pending_payment` n'avait jamais decremente le stock (le decrement est + * pose a la transition `paid`, cf. pay()), il n'y a donc rien a re-crediter. Le + * re-credit reutilise consumption() (memes unites que le decrement de pay()), + * inversees (delta positif) ; un ingredient entierement retire (modifieur remove) + * n'a pas ete decremente -> consumption() ne le retourne pas -> pas de re-credit. + * + * Concurrence (RG-T07/RG-T20) : le statut est relu A L'INTERIEUR de la transaction + * via l'UPDATE garde par `status IN ('pending_payment','paid')` ; 0 ligne affectee + * = course perdue (un autre appel a deja transite) -> INVALID_TRANSITION. Le + * re-credit se base sur le pre-status lu en entree (coherent : seul l'appel qui a + * remporte la garde poursuit, et il n'y a pas de SELECT FOR UPDATE — RG-T20). + * + * @param int|null $actingUserId equipier resolu par PIN (audit_log.actor_user_id + + * stock_movement.user_id) ; le controleur le fournit. + * @param int|null $actingRoleId role de l'equipier resolu par PIN (audit_log.actor_role_id). + * @return array{id:int, order_number:string, total_ttc_cents:int, status:string} + * @throws OrderValidationException + */ + public function cancel(string $orderNumber, ?int $actingUserId, ?int $actingRoleId): 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'); + } + + $preStatus = (string) $order['status']; + if (!in_array($preStatus, ['pending_payment', 'paid'], true)) { + throw new OrderValidationException('CANNOT_CANCEL_IN_STATE'); // delivered / cancelled (statut terminal). + } + + $result = [ + 'id' => (int) $order['id'], + 'order_number' => (string) $order['order_number'], + 'total_ttc_cents' => (int) $order['total_ttc_cents'], + 'status' => 'cancelled', + ]; + + $orderId = (int) $order['id']; + $totalTtc = (int) $order['total_ttc_cents']; + $this->db->transaction(function (DatabaseInterface $db) use ($orderId, $preStatus, $totalTtc, $actingUserId, $actingRoleId): void { + $affected = $db->execute( + 'UPDATE customer_order SET status = \'cancelled\', cancelled_at = NOW(), updated_at = NOW() ' + . 'WHERE id = :id AND status IN (\'pending_payment\', \'paid\')', + ['id' => $orderId], + ); + if ($affected === 0) { + // Course perdue : la garde RG-T07 n'a affecte aucune ligne (un autre + // appel a deja transite vers un statut terminal). Pas d'issue idempotente + // pour l'annulation (a la difference de pay/deliver) : on signale la + // transition invalide et la transaction est annulee (aucun re-credit). + throw new OrderValidationException('INVALID_TRANSITION'); + } + + // RG-3 : re-credit CONDITIONNEL. On le decide sur l'EXISTENCE de mouvements + // 'sale' pour cette commande (poses au decrement de pay()), PAS sur le + // pre-status lu hors transaction : insensible a la course + // pending_payment -> paid -> cancel (sinon un pay() concurrent gagnant + // laisserait le stock decremente sans re-credit, derive silencieuse). De + // fait idempotent : sans mouvement 'sale', rien a re-crediter. Memes unites + // que pay() (consumption), inversees (delta positif). + $restocked = $this->hasSaleMovements($db, $orderId); + if ($restocked) { + foreach ($this->consumption($db, $orderId) as $ingredientId => $units) { + $db->execute( + 'UPDATE ingredient SET stock_quantity = stock_quantity + :u WHERE id = :id', + ['u' => $units, 'id' => $ingredientId], + ); + $db->execute( + 'INSERT INTO stock_movement (ingredient_id, movement_type, delta, order_id, user_id, note) ' + . 'VALUES (:ing, \'cancellation\', :delta, :oid, :uid, NULL)', + ['ing' => $ingredientId, 'delta' => $units, 'oid' => $orderId, 'uid' => $actingUserId], + ); + } + } + + // RG-6/RG-T14 : trace d'audit immuable dans la meme transaction que l'effet. + $recredit = $restocked ? $totalTtc : 0; + $summary = 'Annulation depuis ' . $preStatus . ', re-credit ' . $recredit . 'c'; + $db->execute( + 'INSERT INTO audit_log (actor_user_id, actor_role_id, action_code, entity_type, entity_id, summary) ' + . 'VALUES (:uid, :rid, :code, :etype, :eid, :summary)', + [ + 'uid' => $actingUserId, + 'rid' => $actingRoleId, + 'code' => 'order.cancel', + 'etype' => 'customer_order', + 'eid' => $orderId, + 'summary' => $summary, + ], + ); + }); + + return $result; + } + + /** + * Vrai si la commande porte au moins un mouvement de stock `sale` (donc elle a + * deja ete encaissee/decrementee par pay()). Sert a decider le re-credit a + * l'annulation independamment du statut observe hors transaction (anti-course). + */ + private function hasSaleMovements(DatabaseInterface $db, int $orderId): bool + { + return $db->fetch( + 'SELECT 1 AS x FROM stock_movement WHERE order_id = :oid AND movement_type = \'sale\' LIMIT 1', + ['oid' => $orderId], + ) !== null; + } + /** * 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/orders/cancel.php b/src/app/Views/admin/orders/cancel.php new file mode 100644 index 0000000..a0c282b --- /dev/null +++ b/src/app/Views/admin/orders/cancel.php @@ -0,0 +1,79 @@ + $order + * @var string|null $error + * @var string $csrfToken + */ + +$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); +/** @var array $o */ +$o = isset($order) && is_array($order) ? $order : []; +$errorMessage = isset($error) && is_string($error) ? $error : null; + +$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'; + +$number = (string) ($o['order_number'] ?? ''); +$status = (string) ($o['status'] ?? ''); + +$statusLabel = static fn (string $s): string => match ($s) { + 'pending_payment' => 'En attente', + 'paid' => 'Payee', + 'delivered' => 'Livree', + 'cancelled' => 'Annulee', + default => $s, +}; + +// PRE-3 (7.1) : seuls pending_payment / paid peuvent transiter vers cancelled. +$cancellable = in_array($status, ['pending_payment', 'paid'], true); +?> + + +
+ + + + + +

Cette commande est livree ou deja annulee : elle ne peut plus etre annulee.

+
+ Retour +
+ +
+ + +

L'annulation est tracee (audit) et re-credite le stock si la commande etait payee. Renseignez votre email et votre PIN.

+ +
+ Confirmation par PIN equipier +
+ + +
+
+ + +
+
+ +
+ + Retour +
+
+ +
diff --git a/src/app/Views/admin/orders/index.php b/src/app/Views/admin/orders/index.php index 7e8a06a..4bad74b 100644 --- a/src/app/Views/admin/orders/index.php +++ b/src/app/Views/admin/orders/index.php @@ -4,12 +4,17 @@ declare(strict_types=1); /** * Liste des commandes (order.read), injectee dans admin/layout.php. Lecture seule : - * numero, mode, chevalet, statut, total ttc, date. Tri du plus recent au plus ancien - * (cf. OrderQueryRepository::recent). Toute valeur est echappee (RG-T15). + * numero, mode, chevalet, statut, total ttc, date. Une colonne d'actions affiche le + * lien Annuler (CANCEL_ORDER 7.1) pour les commandes pending_payment/paid quand le + * role detient order.cancel (manager ne l'a PAS, D5). Tri du plus recent au plus + * ancien (cf. OrderQueryRepository::recent). Toute valeur est echappee (RG-T15). * * @var list> $orders + * @var bool $canCancel */ +$canCancelOrder = isset($canCancel) && $canCancel === true; + $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'; @@ -54,17 +59,31 @@ $rows = isset($orders) && is_array($orders) ? $orders : []; Statut Total Date + + - + - + + + + + Annuler + + + diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 0915a23..a71ae5d 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -117,6 +117,11 @@ try { $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']); + // Annulation : pending_payment|paid -> cancelled (CANCEL_ORDER mlt 7.1, order.cancel). + // PIN equipier + audit + restock conditionnel (RG-T13/T14). {number} = un seul + // segment (numero K/C/D + id) ; /cancel ne chevauche ni /deliver ni la liste. + $router->add('GET', '/admin/orders/{number}/cancel', [OrderAdminController::class, 'confirmCancel']); + $router->add('POST', '/admin/orders/{number}/cancel', [OrderAdminController::class, 'cancel']); // 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']); diff --git a/tests/Support/FakeDatabase.php b/tests/Support/FakeDatabase.php index 7c9fcf5..0c0aa2f 100644 --- a/tests/Support/FakeDatabase.php +++ b/tests/Support/FakeDatabase.php @@ -141,6 +141,14 @@ final class FakeDatabase implements DatabaseInterface */ public ?array $menuRow = null; + /** + * Ligne renvoyee par OrderRepository::findByNumber() / cancel() (lecture par + * order_number) ; null = numero inconnu. + * + * @var array|null + */ + public ?array $orderByNumberRow = null; + /** * Lignes renvoyees par MenuRepository::all(). * @@ -428,6 +436,10 @@ final class FakeDatabase implements DatabaseInterface return $this->menuRow; } + if (str_contains($sql, 'FROM customer_order WHERE order_number')) { + return $this->orderByNumberRow; + } + if (str_contains($sql, 'FROM order_item WHERE menu_id')) { return $this->menuReferenced ? ['menu_id' => 1] : null; } diff --git a/tests/Support/FakeOrderDatabase.php b/tests/Support/FakeOrderDatabase.php index 34d4af7..0886068 100644 --- a/tests/Support/FakeOrderDatabase.php +++ b/tests/Support/FakeOrderDatabase.php @@ -40,6 +40,10 @@ final class FakeOrderDatabase implements DatabaseInterface /** Statut relu apres une transition gardee a 0 ligne (course concurrente). */ public string $recheckStatus = 'paid'; + /** La commande porte-t-elle des mouvements 'sale' (= deja encaissee/decrementee) ? + * Pilote le re-credit a l'annulation (OrderRepository::hasSaleMovements). */ + public bool $saleMovementsExist = false; + /** Lignes order_item renvoyees pour la commande encaissee. */ /** @var list> */ public array $orderItems = []; @@ -77,6 +81,9 @@ final class FakeOrderDatabase implements DatabaseInterface if (str_contains($sql, 'FROM menu WHERE id = :id')) { return $this->menus[(int) $params['id']] ?? null; } + if (str_contains($sql, 'FROM stock_movement WHERE order_id')) { + return $this->saleMovementsExist ? ['x' => 1] : null; + } return null; } @@ -133,6 +140,18 @@ final class FakeOrderDatabase implements DatabaseInterface return []; } + /** SQL de la premiere ecriture dont le texte contient $needle (chaine vide sinon). */ + public function firstWriteSql(string $needle): string + { + foreach ($this->writes as $write) { + if (str_contains($write['sql'], $needle)) { + return $write['sql']; + } + } + + return ''; + } + public function countWrites(string $needle): int { return count(array_filter($this->writes, static fn (array $w): bool => str_contains($w['sql'], $needle))); diff --git a/tests/Unit/Admin/OrderAdminControllerTest.php b/tests/Unit/Admin/OrderAdminControllerTest.php index 1ab4da6..0a928b8 100644 --- a/tests/Unit/Admin/OrderAdminControllerTest.php +++ b/tests/Unit/Admin/OrderAdminControllerTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace App\Tests\Unit\Admin; use PHPUnit\Framework\TestCase; +use App\Auth\Csrf; +use App\Auth\PasswordHasher; use App\Auth\SessionManager; use App\Controllers\OrderAdminController; use App\Core\Config; @@ -62,17 +64,24 @@ final class OrderAdminControllerTest extends TestCase /** @var list */ private array $touchedKeys = []; private SessionManager $session; + private string $csrf = ''; protected function setUp(): void { $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->setEnv('STAFF_PIN_MIN_LENGTH', '4'); + $this->setEnv('STAFF_PIN_MAX_LENGTH', '12'); + $this->setEnv('ARGON2_MEMORY_COST', '1024'); + $this->setEnv('ARGON2_TIME_COST', '1'); + $this->setEnv('ARGON2_THREADS', '1'); $this->session = new SessionManager(new Config(), true); $now = time(); $this->session->set('user_id', 1); $this->session->set('role_id', 2); $this->session->set('logged_in_at', $now - 100); $this->session->set('last_activity', $now - 50); + $this->csrf = Csrf::token($this->session); } protected function tearDown(): void @@ -107,6 +116,33 @@ final class OrderAdminControllerTest extends TestCase return new TestOrderAdminController($request, new Config(), new Database(new Config()), $this->session, $db); } + private function controllerWith(Request $request, FakeDatabase $db): TestOrderAdminController + { + return new TestOrderAdminController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + /** + * @param array $form + */ + private function post(array $form, string $path): Request + { + return new Request('POST', $path, [], ['content-type' => 'application/x-www-form-urlencoded'], http_build_query($form), '203.0.113.5'); + } + + private function cancelDb(): FakeDatabase + { + $db = $this->permittedDb(); + $db->permissionCodes = ['order.read', 'order.cancel']; + $db->orderByNumberRow = ['id' => 100, 'order_number' => 'K42', 'total_ttc_cents' => 1990, 'status' => 'paid']; + + return $db; + } + + private function actingPin(FakeDatabase $db): void + { + $db->actingUserRow = ['id' => 9, 'role_id' => 4, 'pin_hash' => (new PasswordHasher(new Config()))->hash('4729')]; + } + public function testRequiresOrderRead(): void { $db = $this->permittedDb(); @@ -146,4 +182,86 @@ final class OrderAdminControllerTest extends TestCase self::assertSame(403, $response->status()); } + + // --- CANCEL_ORDER (7.1, order.cancel + PIN equipier RG-T13) --- + + public function testCancelRequiresOrderCancelPermission(): void + { + $db = $this->cancelDb(); + $db->canResult = false; // pas de order.cancel -> 403 avant toute action + + $request = $this->post([ + '_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729', + ], '/admin/orders/K42/cancel'); + + self::assertSame(403, $this->controllerWith($request, $db)->cancel(['number' => 'K42'])->status()); + } + + public function testCancelRejectsInvalidCsrf(): void + { + // order.cancel accorde mais jeton CSRF absent -> 403 avant toute transition. + $db = $this->cancelDb(); + $request = $this->post(['pin_email' => 'sam@wakdo.local', 'pin' => '4729'], '/admin/orders/K42/cancel'); + + self::assertSame(403, $this->controllerWith($request, $db)->cancel(['number' => 'K42'])->status()); + } + + public function testCancelWithBadPinLogsFailedAndDoesNotCancel(): void + { + $db = $this->cancelDb(); + $db->actingUserRow = null; // email/PIN non resolu + + $request = $this->post([ + '_csrf' => $this->csrf, 'pin_email' => 'ghost@wakdo.local', 'pin' => '0000', + ], '/admin/orders/K42/cancel'); + + $response = $this->controllerWith($request, $db)->cancel(['number' => 'K42']); + + self::assertSame(422, $response->status()); + self::assertSame(['pin.failed'], $db->auditActions()); // trace detective (RG-T22) + self::assertFalse($db->wrote('UPDATE customer_order SET status')); // aucune transition + } + + public function testCancelWithValidPinTransitionsToCancelled(): void + { + $db = $this->cancelDb(); + $this->actingPin($db); // equipier id 9, PIN 4729 + + $request = $this->post([ + '_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729', + ], '/admin/orders/K42/cancel'); + + $response = $this->controllerWith($request, $db)->cancel(['number' => 'K42']); + + self::assertSame(302, $response->status()); + self::assertSame('/admin/orders', $response->header('Location')); + self::assertTrue($db->wrote('UPDATE customer_order SET status')); + // L'annulation est tracee avec l'acteur resolu par PIN (RG-T14). + self::assertSame(['order.cancel'], $db->auditActions()); + } + + public function testCancelUnknownOrderReturns404(): void + { + $db = $this->cancelDb(); + $db->orderByNumberRow = null; // numero inconnu + + $request = $this->post([ + '_csrf' => $this->csrf, 'pin_email' => 'sam@wakdo.local', 'pin' => '4729', + ], '/admin/orders/K99/cancel'); + + self::assertSame(404, $this->controllerWith($request, $db)->cancel(['number' => 'K99'])->status()); + } + + public function testConfirmCancelRendersPinForm(): void + { + $db = $this->cancelDb(); + $request = new Request('GET', '/admin/orders/K42/cancel', [], [], '', '203.0.113.5'); + + $response = $this->controllerWith($request, $db)->confirmCancel(['number' => 'K42']); + + self::assertSame(200, $response->status()); + $body = $response->body(); + self::assertStringContainsString('K42', $body); + self::assertStringContainsString('PIN', $body); + } } diff --git a/tests/Unit/Order/OrderRepositoryTest.php b/tests/Unit/Order/OrderRepositoryTest.php index 8454a68..c92bce7 100644 --- a/tests/Unit/Order/OrderRepositoryTest.php +++ b/tests/Unit/Order/OrderRepositoryTest.php @@ -403,4 +403,133 @@ final class OrderRepositoryTest extends TestCase $this->expectExceptionMessage('INVALID_TRANSITION'); $this->repo($db)->deliver('K100'); } + + // --- cancel() : transition gardee + re-credit conditionnel + audit (RG-T07/T11/T14) --- + + public function testCancelPendingTransitionsWithoutRecredit(): void + { + // pending_payment n'avait jamais decremente le stock (le decrement est pose a + // `paid`) : annulation = transition + audit, AUCUN re-credit (RG-3). + $db = new FakeOrderDatabase(); + $db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment']; + $db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]]; + $db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]]; + + $res = $this->repo($db)->cancel('K100', 9, 4); + + self::assertSame('cancelled', $res['status']); + self::assertSame('K100', $res['order_number']); + self::assertSame(1, $db->countWrites('UPDATE customer_order SET status')); + // Aucun mouvement de stock (re-credit) : la commande n'etait pas payee. + self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity')); + self::assertSame(0, $db->countWrites('INSERT INTO stock_movement')); + // Trace d'audit ecrite avec l'acteur resolu par PIN. + self::assertSame(1, $db->countWrites('INSERT INTO audit_log')); + $audit = $db->firstWrite('INSERT INTO audit_log'); + self::assertSame('order.cancel', $audit['code']); + self::assertSame('customer_order', $audit['etype']); + self::assertSame(100, $audit['eid']); + self::assertSame(9, $audit['uid']); + self::assertSame(4, $audit['rid']); + } + + public function testCancelPaidRecreditsStockAndWritesMovementAndAudit(): void + { + $db = new FakeOrderDatabase(); + $db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid']; + $db->saleMovementsExist = true; // payee -> mouvements 'sale' poses -> re-credit attendu + $db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]]; + $db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]]; + + $res = $this->repo($db)->cancel('K100', 9, 4); + + self::assertSame('cancelled', $res['status']); + self::assertSame(1, $db->countWrites('UPDATE customer_order SET status')); + // 2 unites consommees (qn 1 * quantite 2) -> re-credit +2 sur l'ingredient 5. + $inc = $db->firstWrite('UPDATE ingredient SET stock_quantity'); + self::assertSame(2, $inc['u']); + self::assertSame(5, $inc['id']); + // Type 'cancellation' code en dur dans le SQL (cf. pay() qui code 'sale'). + self::assertStringContainsString("'cancellation'", $db->firstWriteSql('INSERT INTO stock_movement')); + $move = $db->firstWrite('INSERT INTO stock_movement'); + self::assertSame(2, $move['delta']); // delta POSITIF (re-credit) + self::assertSame(100, $move['oid']); + self::assertSame(9, $move['uid']); // acteur resolu par PIN + // Audit ecrit avec le montant re-credite (pre-status paid). + self::assertSame(1, $db->countWrites('INSERT INTO audit_log')); + $audit = $db->firstWrite('INSERT INTO audit_log'); + self::assertSame('order.cancel', $audit['code']); + self::assertStringContainsString('paid', (string) $audit['summary']); + self::assertStringContainsString('890c', (string) $audit['summary']); + } + + public function testCancelRecreditsWhenSaleMovementsExistEvenIfPreStatusPending(): void + { + // Anti-course pending_payment -> paid -> cancel : la commande lue en pending a + // en realite ete payee (mouvements 'sale' poses par un pay() concurrent). Le + // re-credit se decide sur l'existence des mouvements 'sale', pas sur le + // pre-status -> il a bien lieu (pas de derive de stock silencieuse). + $db = new FakeOrderDatabase(); + $db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'pending_payment']; + $db->saleMovementsExist = true; + $db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]]; + $db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]]; + + $this->repo($db)->cancel('K100', 9, 4); + + self::assertSame(2, $db->firstWrite('UPDATE ingredient SET stock_quantity')['u']); + self::assertStringContainsString("'cancellation'", $db->firstWriteSql('INSERT INTO stock_movement')); + } + + public function testCancelRejectsUnknownOrder(): void + { + $db = new FakeOrderDatabase(); + $db->orderByNumber = null; + + $this->expectException(OrderValidationException::class); + $this->expectExceptionMessage('ORDER_NOT_FOUND'); + $this->repo($db)->cancel('K404', 9, 4); + } + + public function testCancelRejectsTerminalStatus(): void + { + $db = new FakeOrderDatabase(); + $db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'delivered']; + + $this->expectException(OrderValidationException::class); + $this->expectExceptionMessage('CANNOT_CANCEL_IN_STATE'); + $this->repo($db)->cancel('K100', 9, 4); + } + + public function testCancelAlreadyCancelledRejected(): void + { + $db = new FakeOrderDatabase(); + $db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'cancelled']; + + $this->expectException(OrderValidationException::class); + $this->expectExceptionMessage('CANNOT_CANCEL_IN_STATE'); + $this->repo($db)->cancel('K100', 9, 4); + } + + public function testCancelConcurrentRaceThrowsInvalidTransition(): void + { + // La garde RG-T07 (status IN (...)) n'affecte 0 ligne : un autre appel a deja + // transite vers un statut terminal -> INVALID_TRANSITION, aucun re-credit. + $db = new FakeOrderDatabase(); + $db->orderByNumber = ['id' => 100, 'order_number' => 'K100', 'total_ttc_cents' => 890, 'status' => 'paid']; + $db->payUpdateAffected = 0; + $db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 2]]; + $db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]]; + + try { + $this->repo($db)->cancel('K100', 9, 4); + self::fail('expected OrderValidationException'); + } catch (OrderValidationException $exception) { + self::assertSame('INVALID_TRANSITION', $exception->getMessage()); + } + + self::assertSame(0, $db->countWrites('UPDATE ingredient SET stock_quantity')); + self::assertSame(0, $db->countWrites('INSERT INTO stock_movement')); + self::assertSame(0, $db->countWrites('INSERT INTO audit_log')); + } }