feat(orders): KDS cuisine + transition paid vers delivered (P3 operationnel) (#81)
This commit is contained in:
parent
9e3346181d
commit
80919f62c1
10 changed files with 508 additions and 7 deletions
52
src/app/Controllers/KitchenController.php
Normal file
52
src/app/Controllers/KitchenController.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Auth\GuardResult;
|
||||||
|
use App\Core\Response;
|
||||||
|
use App\Order\OrderQueryRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affichage cuisine (KDS, Kitchen Display System). GET /kitchen/display, permission
|
||||||
|
* order.read. Landing par defaut du role kitchen (seed role.default_route). Lecture
|
||||||
|
* SEULE : la file des commandes `paid` triee par paid_at croissant (la plus ancienne
|
||||||
|
* d'abord, RG-T12), filtree par les sources visibles du role (role_visible_source :
|
||||||
|
* kitchen voit tout ; counter voit kiosk+counter ; drive voit drive). Aucune
|
||||||
|
* transition de statut ici (la remise se fait via OrderAdminController::deliver).
|
||||||
|
*
|
||||||
|
* Non `final` : les tests sous-classent (seam db()/orderQuery()).
|
||||||
|
*/
|
||||||
|
class KitchenController extends AdminController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,15 +4,22 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Auth\Csrf;
|
||||||
|
use App\Catalogue\MenuRepository;
|
||||||
|
use App\Catalogue\ProductRepository;
|
||||||
use App\Core\Response;
|
use App\Core\Response;
|
||||||
use App\Order\OrderQueryRepository;
|
use App\Order\OrderQueryRepository;
|
||||||
|
use App\Order\OrderRepository;
|
||||||
|
use App\Order\OrderValidationException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste des commandes back-office (P4, domaine 7). GET /admin/orders, permission
|
* Domaine commande back-office (P4 + P3 operationnel). GET /admin/orders : liste
|
||||||
* order.read. Lecture seule : pas de transition de statut ici (deliver/cancel =
|
* recente (order.read). POST /admin/orders/{number}/deliver : transition paid ->
|
||||||
* ecrans operationnels kitchen/counter, hors back-office MVP).
|
* 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
|
class OrderAdminController extends AdminController
|
||||||
{
|
{
|
||||||
|
|
@ -33,8 +40,57 @@ class OrderAdminController extends AdminController
|
||||||
], $guard);
|
], $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<string, string> $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
|
protected function orderQuery(): OrderQueryRepository
|
||||||
{
|
{
|
||||||
return new OrderQueryRepository($this->db());
|
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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<string>
|
||||||
|
*/
|
||||||
|
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<string> $sources
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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
|
* KPIs de vente : CA encaisse (statuts paid + delivered), nombre de commandes
|
||||||
* encaissees, panier moyen, CA et nombre du JOUR, total de commandes, et la
|
* encaissees, panier moyen, CA et nombre du JOUR, total de commandes, et la
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,57 @@ class OrderRepository
|
||||||
return $result;
|
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
|
* Unites de stock a decrementer, AGREGEES par ingredient_id sur toute la
|
||||||
* commande (lecture des lignes persistees + recettes des produits supports).
|
* commande (lecture des lignes persistees + recettes des produits supports).
|
||||||
|
|
|
||||||
59
src/app/Views/admin/kitchen/display.php
Normal file
59
src/app/Views/admin/kitchen/display.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KDS cuisine : file des commandes payees (lecture seule ; remise si order.deliver).
|
||||||
|
* Injecte dans admin/layout.php. Utilise la grille .kitchen-* (admin.css). Le bouton
|
||||||
|
* de remise n'apparait que pour les roles dotes de order.deliver (kitchen ne l'a pas :
|
||||||
|
* il voit la file en lecture seule ; counter/drive/admin remettent).
|
||||||
|
*
|
||||||
|
* @var list<array<string, mixed>> $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');
|
||||||
|
?>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Cuisine</h1>
|
||||||
|
<p class="page-subtitle">File des commandes payees, de la plus ancienne a la plus recente.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($rows === []): ?>
|
||||||
|
<p>Aucune commande en attente de preparation.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<section class="kitchen-grid" aria-label="File des commandes payees">
|
||||||
|
<?php foreach ($rows as $o): ?>
|
||||||
|
<article class="kitchen-card">
|
||||||
|
<div class="kitchen-card-header">
|
||||||
|
<span class="kitchen-card-number"><?= $esc($o['order_number'] ?? '') ?></span>
|
||||||
|
<span class="kitchen-card-source"><?= $esc($sourceLabel((string) ($o['source'] ?? ''))) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="kitchen-card-body">
|
||||||
|
<p class="kitchen-line">Mode : <?= $esc($modeLabel((string) ($o['service_mode'] ?? ''))) ?></p>
|
||||||
|
<?php if (($o['service_tag'] ?? '') !== ''): ?>
|
||||||
|
<p class="kitchen-line">Table : <?= $esc($o['service_tag']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p class="kitchen-line">Payee a : <?= $esc($o['paid_at'] ?? '') ?></p>
|
||||||
|
</div>
|
||||||
|
<?php if ($can): ?>
|
||||||
|
<div class="kitchen-card-footer">
|
||||||
|
<form method="post" action="/admin/orders/<?= rawurlencode((string) ($o['order_number'] ?? '')) ?>/deliver">
|
||||||
|
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
||||||
|
<button class="btn btn-primary" type="submit">Remettre (livree)</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
@ -123,6 +123,7 @@ $navClass = static function (string $code, string $current): string {
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($can('order.read')): ?>
|
<?php if ($can('order.read')): ?>
|
||||||
<a href="/admin/orders" class="<?= $navClass('orders', $active) ?>">Commandes</a>
|
<a href="/admin/orders" class="<?= $navClass('orders', $active) ?>">Commandes</a>
|
||||||
|
<a href="/kitchen/display" class="<?= $navClass('kitchen', $active) ?>">Cuisine (KDS)</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use App\Controllers\DashboardController;
|
||||||
use App\Controllers\HealthController;
|
use App\Controllers\HealthController;
|
||||||
use App\Controllers\HomeController;
|
use App\Controllers\HomeController;
|
||||||
use App\Controllers\IngredientController;
|
use App\Controllers\IngredientController;
|
||||||
|
use App\Controllers\KitchenController;
|
||||||
use App\Controllers\MeController;
|
use App\Controllers\MeController;
|
||||||
use App\Controllers\MenuController;
|
use App\Controllers\MenuController;
|
||||||
use App\Controllers\OrderAdminController;
|
use App\Controllers\OrderAdminController;
|
||||||
|
|
@ -114,6 +115,11 @@ try {
|
||||||
|
|
||||||
// Commandes (P4, order.read) : liste lecture seule du domaine commande.
|
// Commandes (P4, order.read) : liste lecture seule du domaine commande.
|
||||||
$router->add('GET', '/admin/orders', [OrderAdminController::class, 'index']);
|
$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/
|
// Gestion des comptes (mlt domaine 10). user.read (liste) ; user.create/update/
|
||||||
// deactivate. TOUTES les mutations = PIN equipier + audit (RG-T13/14). {id} = un
|
// deactivate. TOUTES les mutations = PIN equipier + audit (RG-T13/14). {id} = un
|
||||||
|
|
|
||||||
135
tests/Unit/Admin/KitchenControllerTest.php
Normal file
135
tests/Unit/Admin/KitchenControllerTest.php
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Admin;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Controllers\KitchenController;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\DatabaseInterface;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Order\OrderQueryRepository;
|
||||||
|
use App\Tests\Support\FakeDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub OrderQueryRepository : sources visibles + file canned, pour tester le rendu
|
||||||
|
* du KDS sans base. Le SQL reel de visibleSources/paidQueue n'est pas encore couvert
|
||||||
|
* par un test d'integration (a ajouter) ; ici on isole le rendu de la vue.
|
||||||
|
*/
|
||||||
|
final class StubKitchenQuery extends OrderQueryRepository
|
||||||
|
{
|
||||||
|
public function visibleSources(int $roleId): array
|
||||||
|
{
|
||||||
|
return ['kiosk', 'counter', 'drive'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paidQueue(array $sources): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['order_number' => '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<string> */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -129,4 +129,21 @@ final class OrderAdminControllerTest extends TestCase
|
||||||
self::assertStringContainsString('Payee', $body); // statut paid
|
self::assertStringContainsString('Payee', $body); // statut paid
|
||||||
self::assertStringContainsString('A emporter', $body); // takeaway -> libelle
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -285,4 +285,76 @@ final class OrderRepositoryTest extends TestCase
|
||||||
$move = $db->firstWrite('INSERT INTO stock_movement');
|
$move = $db->firstWrite('INSERT INTO stock_movement');
|
||||||
self::assertSame(7, $move['uid']);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue