feat(orders): KDS cuisine + transition paid vers delivered (P3 operationnel) (#81)
All checks were successful
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 21s
CI / static-tests (push) Successful in 43s
CI / js-tests (push) Successful in 33s

This commit is contained in:
Corentin JOGUET 2026-06-22 09:28:39 +02:00
parent 9e3346181d
commit 80919f62c1
10 changed files with 508 additions and 7 deletions

View 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);
}
}

View file

@ -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
{ {
@ -27,14 +34,63 @@ class OrderAdminController extends AdminController
} }
return $this->adminView('admin/orders/index', [ return $this->adminView('admin/orders/index', [
'title' => 'Commandes - Wakdo Admin', 'title' => 'Commandes - Wakdo Admin',
'activeNav' => 'orders', 'activeNav' => 'orders',
'orders' => $this->orderQuery()->recent(50), 'orders' => $this->orderQuery()->recent(50),
], $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']);
}
} }

View file

@ -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

View file

@ -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).

View 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; ?>

View file

@ -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; ?>

View file

@ -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

View 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);
}
}

View file

@ -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());
}
} }

View file

@ -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');
}
} }