feat(orders): saisie commande comptoir/drive - fondation produits (mlt 4.1) (#85)
All checks were successful
CI / secret-scan (push) Successful in 10s
CI / php-lint (push) Successful in 20s
CI / static-tests (push) Successful in 49s
CI / js-tests (push) Successful in 27s

This commit is contained in:
Corentin JOGUET 2026-06-22 12:09:11 +02:00
parent ea1654c21a
commit 5cc879c3ea
9 changed files with 863 additions and 12 deletions

View file

@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth\Csrf;
use App\Auth\GuardResult;
use App\Catalogue\MenuRepository;
use App\Catalogue\ProductRepository;
use App\Core\DatabaseInterface;
use App\Core\Response;
use App\Order\OrderQueryRepository;
use App\Order\OrderRepository;
use App\Order\OrderValidationException;
/**
* Saisie de commande comptoir / drive en back-office (CREATE_COUNTER_ORDER, mlt 4.1).
* UN seul controleur sert les DEUX canaux : la `source` est derivee du CHEMIN de la
* requete (un chemin commencant par '/drive' -> 'drive', sinon 'counter'). Ce choix
* evite un controleur par canal alors que la logique est identique ; seules la source
* auto-tagguee, le titre et les liens d'action changent. Le decoupage par chemin (et
* non par parametre de route) garantit que counter et drive restent etanches : un
* equipier drive ne peut pas creer une commande comptoir en falsifiant un champ.
*
* Version PRODUITS uniquement (sous-lot 3a) : les menus composes (slots) viendront
* dans un sous-lot ulterieur. La commande est creee directement `paid` (encaissement
* immediat, RG-5/POST-1) sans PIN : la permission order.create suffit.
*
* Non `final` : les tests sous-classent pour injecter des doubles (db/orderQuery/orders).
*/
class CounterOrderController extends AdminController
{
/**
* Liste des commandes recentes du canal courant + lien "Nouvelle commande".
* Corrige le 404 des landings /counter/orders et /drive/orders (role.default_route).
*
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
$guard = $this->guard('order.create');
if ($guard instanceof Response) {
return $guard;
}
$source = $this->source();
// RG-1 (5.1, source filter) : ne lister que les commandes du canal. recent()
// ramene les plus recentes tous canaux ; on filtre sur la source derivee du
// chemin pour que le comptoir ne voie pas le drive et inversement.
$orders = array_values(array_filter(
$this->orderQuery()->recent(50),
static fn (array $o): bool => (string) ($o['source'] ?? '') === $source,
));
return $this->channelView('admin/counter/index', $source, [
'title' => $this->channelTitle($source) . ' - Wakdo Admin',
'orders' => $orders,
], $guard);
}
/**
* Composeur de commande (GET .../new) : produits commandables + select service_mode.
*
* @param array<string, string> $params
*/
public function create(array $params = []): Response
{
$guard = $this->guard('order.create');
if ($guard instanceof Response) {
return $guard;
}
$source = $this->source();
return $this->renderForm($guard, $source, [], null);
}
/**
* Soumission de la commande (POST). Construit le panier depuis les quantites
* saisies, encaisse via createStaffOrder (source derivee du chemin, acteur =
* equipier authentifie). Panier vide / RG-T09 / indisponibilite -> flash + re-rendu.
*
* @param array<string, string> $params
*/
public function store(array $params = []): Response
{
$guard = $this->guard('order.create');
if ($guard instanceof Response) {
return $guard;
}
$form = $this->request->formBody();
if (!Csrf::validate($this->sessionManager(), $form['_csrf'] ?? null)) {
return $this->invalidCsrf();
}
$source = $this->source();
$serviceMode = (string) ($form['service_mode'] ?? '');
// Panier = une ligne produit par quantite >= 1. Le champ s'appelle qty_<id>
// (un champ nombre par produit listable) ; on ne retient que les positifs.
$items = [];
foreach ($form as $key => $value) {
if (!str_starts_with($key, 'qty_')) {
continue;
}
$productId = (int) substr($key, 4);
$quantity = ctype_digit(trim($value)) ? (int) $value : 0;
if ($productId > 0 && $quantity >= 1) {
$items[] = ['type' => 'product', 'product_id' => $productId, 'quantity' => $quantity];
}
}
if ($items === []) {
return $this->renderForm($guard, $source, $form, 'Ajoutez au moins un produit (quantite >= 1).', 422);
}
try {
$order = $this->orders()->createStaffOrder(
['service_mode' => $serviceMode, 'items' => $items],
$guard->userId ?? 0,
$source,
);
} catch (OrderValidationException $exception) {
return $this->renderForm($guard, $source, $form, $this->messageFor($exception->getMessage()), 422);
}
$this->setFlash('Commande ' . $order['order_number'] . ' enregistree et encaissee.');
return $this->redirect($this->landing($source));
}
protected function orderQuery(): OrderQueryRepository
{
return new OrderQueryRepository($this->db());
}
protected function orders(): OrderRepository
{
$db = $this->db();
return new OrderRepository($db, new ProductRepository($db), new MenuRepository($db));
}
protected function productRepository(): ProductRepository
{
return new ProductRepository($this->db());
}
/**
* Canal derive du chemin de la requete : tout chemin sous /drive est le canal
* drive, le reste (/counter...) est le comptoir. Source unique de la verite pour
* la source auto-tagguee, les titres et les liens.
*/
private function source(): string
{
return str_starts_with($this->request->path(), '/drive') ? 'drive' : 'counter';
}
private function landing(string $source): string
{
return $source === 'drive' ? '/drive/orders' : '/counter/orders';
}
private function newPath(string $source): string
{
return $source === 'drive' ? '/drive/orders/new' : '/counter/orders/new';
}
private function channelTitle(string $source): string
{
return $source === 'drive' ? 'Commandes drive' : 'Commandes comptoir';
}
/**
* Rend le composeur produits (vue partagee par les deux canaux).
*
* @param array<string, mixed> $values valeurs du formulaire a reafficher (re-rendu d'erreur)
*/
private function renderForm(GuardResult $guard, string $source, array $values, ?string $error, int $status = 200): Response
{
return $this->channelView('admin/counter/new', $source, [
'title' => 'Nouvelle commande ' . ($source === 'drive' ? 'drive' : 'comptoir') . ' - Wakdo Admin',
'products' => $this->productRepository()->availableForCatalogue(),
'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')),
'error' => $error,
], $guard, $status);
}
/**
* Vue de canal : injecte les liens et le titre derives de la source pour que les
* vues partagees (comptoir/drive) s'adaptent sans connaitre le decoupage par chemin.
*
* @param array<string, mixed> $data
*/
private function channelView(string $name, string $source, array $data, GuardResult $guard, int $status = 200): Response
{
return $this->adminView($name, $data + [
'activeNav' => $source === 'drive' ? 'drive' : 'counter',
'source' => $source,
'channelTitle' => $this->channelTitle($source),
'landing' => $this->landing($source),
'newPath' => $this->newPath($source),
], $guard, $status);
}
/**
* Message lisible pour un code d'erreur metier (re-rendu de formulaire).
*/
private function messageFor(string $code): string
{
return match ($code) {
'EMPTY_ORDER' => 'La commande est vide : ajoutez au moins un produit.',
'INVALID_SERVICE_MODE' => 'Mode de service invalide (le drive impose le mode drive).',
'PRODUCT_UNAVAILABLE' => 'Un produit selectionne est indisponible.',
default => 'Commande invalide, verifiez votre saisie.',
};
}
private function redirect(string $location): Response
{
return Response::make('', 302, ['Location' => $location]);
}
private function invalidCsrf(): Response
{
return Response::make('Requete invalide.', 403, ['Content-Type' => 'text/plain; charset=utf-8']);
}
}

View file

@ -33,7 +33,7 @@ class OrderQueryRepository
$limit = max(1, min(200, $limit)); $limit = max(1, min(200, $limit));
return $this->db->fetchAll( return $this->db->fetchAll(
'SELECT order_number, service_mode, service_tag, status, total_ttc_cents, created_at, paid_at ' 'SELECT order_number, source, service_mode, service_tag, status, total_ttc_cents, created_at, paid_at '
. 'FROM customer_order ORDER BY created_at DESC, id DESC LIMIT ' . $limit, . 'FROM customer_order ORDER BY created_at DESC, id DESC LIMIT ' . $limit,
); );
} }

View file

@ -106,6 +106,70 @@ class OrderRepository
return $existing; return $existing;
} }
// Borne anonyme : source 'kiosk', prefixe 'K', aucun acteur (acting_user_id NULL).
// Aucune contrainte croisee de service_mode (mlt RG-6 : kiosk n'implique rien).
return $this->persist($req, 'kiosk', 'K', null);
}
/**
* Cree une commande comptoir/drive (CREATE_COUNTER_ORDER, mlt 4.1). Meme logique
* de creation que le kiosk (resolution + INSERT pending_payment), MAIS la commande
* est immediatement encaissee (paid + decrement de stock, RG-T20) : POST-1 exige
* status='paid', paid_at et acting_user_id poses des la creation. Aucun PIN : la
* permission order.create suffit (la creation n'est pas dans l'ensemble sensible).
*
* - source auto-tagguee depuis le role de l'equipier (counter / drive, RG-1) ;
* - service_mode choisi par l'equipier (dine_in / takeaway / drive) ;
* - RG-T09 : source 'drive' impose service_mode='drive' (verifie avant l'INSERT) ;
* - acting_user_id + stock_movement.user_id = id de l'equipier authentifie (RG-5).
*
* Numero : prefixe 'C' (counter) / 'D' (drive) + id, coherent avec le 'K'+id du
* kiosk (decision projet, diverge du C-AAAA-MM-JJ-NNN de la spec RG-3 : plus
* simple, pas de compteur sequentiel par jour ni de service_day a tenir).
*
* @param array<string, mixed> $req
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}
* @throws OrderValidationException source invalide, RG-T09, reference indisponible.
*/
public function createStaffOrder(array $req, int $actingUserId, string $source): array
{
if (!in_array($source, ['counter', 'drive'], true)) {
throw new OrderValidationException('INVALID_SOURCE');
}
// RG-T09 / RG-2 (4.1) : la contrainte croisee drive est verifiee AVANT l'INSERT.
// service_mode est valide par persist() (in [dine_in, takeaway, drive]) ; on
// n'ajoute ici que le resserrement specifique au canal drive.
if ($source === 'drive' && (string) ($req['service_mode'] ?? '') !== 'drive') {
throw new OrderValidationException('INVALID_SERVICE_MODE');
}
$prefix = $source === 'drive' ? 'D' : 'C';
$created = $this->persist($req, $source, $prefix, $actingUserId);
// POST-1 : encaissement immediat (paid + paid_at + decrement stock RG-T20 avec
// user_id=equipier). pay() est idempotent et porte l'acteur dans acting_user_id
// (COALESCE) et stock_movement.user_id. Le numero (prefixe canal + id) sert de
// cle de transition.
return $this->pay($created['order_number'], $actingUserId);
}
/**
* Corps partage de la creation (resolution des lignes + transaction d'INSERT
* customer_order / order_item / selections / modifiers) en pending_payment. La
* source, le prefixe de numero et l'acteur sont des PARAMETRES : c'est la seule
* difference structurelle entre le kiosk (kiosk / 'K' / NULL) et le comptoir-drive
* (counter|drive / 'C'|'D' / id equipier). Le calcul de prix (RG-T16) et les
* snapshots (RG-T05) sont identiques quelle que soit l'origine.
*
* @param array<string, mixed> $req
* @return array{id:int, order_number:string, total_ttc_cents:int, status:string}
* @throws OrderValidationException si une reference est invalide / indisponible.
*/
private function persist(array $req, string $source, string $prefix, ?int $actingUserId): array
{
$key = trim((string) ($req['idempotency_key'] ?? ''));
$serviceMode = (string) ($req['service_mode'] ?? ''); $serviceMode = (string) ($req['service_mode'] ?? '');
if (!in_array($serviceMode, ['dine_in', 'takeaway', 'drive'], true)) { if (!in_array($serviceMode, ['dine_in', 'takeaway', 'drive'], true)) {
throw new OrderValidationException('INVALID_SERVICE_MODE'); throw new OrderValidationException('INVALID_SERVICE_MODE');
@ -136,23 +200,25 @@ class OrderRepository
$result = ['id' => 0, 'order_number' => '', 'total_ttc_cents' => $totalTtc, 'status' => 'pending_payment']; $result = ['id' => 0, 'order_number' => '', 'total_ttc_cents' => $totalTtc, 'status' => 'pending_payment'];
$this->db->transaction(function (DatabaseInterface $db) use ($key, $serviceMode, $serviceTag, $lines, $totalTtc, $totalHt, $totalVat, &$result): void { $this->db->transaction(function (DatabaseInterface $db) use ($key, $source, $prefix, $actingUserId, $serviceMode, $serviceTag, $lines, $totalTtc, $totalHt, $totalVat, &$result): void {
$db->execute( $db->execute(
'INSERT INTO customer_order ' 'INSERT INTO customer_order '
. '(order_number, idempotency_key, source, service_mode, service_tag, status, ' . '(order_number, idempotency_key, source, service_mode, service_tag, status, '
. ' total_ht_cents, total_vat_cents, total_ttc_cents) ' . ' acting_user_id, total_ht_cents, total_vat_cents, total_ttc_cents) '
. "VALUES ('', :idem, 'kiosk', :mode, :tag, 'pending_payment', :ht, :vat, :ttc)", . "VALUES ('', :idem, :source, :mode, :tag, 'pending_payment', :acting, :ht, :vat, :ttc)",
[ [
'idem' => $key !== '' ? $key : null, 'idem' => $key !== '' ? $key : null,
'source' => $source,
'mode' => $serviceMode, 'mode' => $serviceMode,
'tag' => $serviceTag !== '' ? $serviceTag : null, 'tag' => $serviceTag !== '' ? $serviceTag : null,
'acting' => $actingUserId,
'ht' => $totalHt, 'ht' => $totalHt,
'vat' => $totalVat, 'vat' => $totalVat,
'ttc' => $totalTtc, 'ttc' => $totalTtc,
], ],
); );
$orderId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0); $orderId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
$orderNumber = 'K' . $orderId; $orderNumber = $prefix . $orderId;
$db->execute( $db->execute(
'UPDATE customer_order SET order_number = :num WHERE id = :id', 'UPDATE customer_order SET order_number = :num WHERE id = :id',
['num' => $orderNumber, 'id' => $orderId], ['num' => $orderNumber, 'id' => $orderId],

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
/**
* Liste des commandes du canal (comptoir ou drive), injectee dans admin/layout.php.
* Lecture seule : numero, mode, statut, total, date + bouton "Nouvelle commande".
* Partagee par les deux canaux ; le titre, le lien de creation et la source viennent
* du controleur (CounterOrderController::channelView). Toute valeur est echappee (RG-T15).
*
* @var list<array<string, mixed>> $orders
* @var string $channelTitle
* @var string $newPath
*/
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR';
$modeLabel = static fn (string $m): string => match ($m) {
'dine_in' => 'Sur place',
'takeaway' => 'A emporter',
'drive' => 'Drive',
default => $m,
};
$statusLabel = static fn (string $s): string => match ($s) {
'pending_payment' => 'En attente',
'paid' => 'Payee',
'delivered' => 'Livree',
'cancelled' => 'Annulee',
default => $s,
};
$statusPill = static fn (string $s): string => match ($s) {
'paid', 'delivered' => 'pill-success',
'cancelled' => 'pill-danger',
default => 'pill-warning',
};
/** @var list<array<string, mixed>> $rows */
$rows = isset($orders) && is_array($orders) ? $orders : [];
$heading = isset($channelTitle) && is_string($channelTitle) ? $channelTitle : 'Commandes';
$createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orders/new';
?>
<section class="admin-section" aria-labelledby="counter-heading">
<div class="page-header">
<h1 id="counter-heading" class="admin-section__title"><?= $esc($heading) ?></h1>
<a class="btn btn-primary" href="<?= $esc($createPath) ?>">Nouvelle commande</a>
</div>
<p class="admin-section__sub"><?= count($rows) ?> commande(s) recente(s)</p>
<?php if ($rows === []): ?>
<p class="admin-empty">Aucune commande pour ce canal.</p>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<th>Numero</th>
<th>Mode</th>
<th>Statut</th>
<th>Total</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $o): ?>
<?php $status = (string) ($o['status'] ?? ''); ?>
<tr>
<td><strong><?= $esc($o['order_number'] ?? '') ?></strong></td>
<td><?= $esc($modeLabel((string) ($o['service_mode'] ?? ''))) ?></td>
<td><span class="pill <?= $esc($statusPill($status)) ?>"><?= $esc($statusLabel($status)) ?></span></td>
<td><?= $esc($euros($o['total_ttc_cents'] ?? 0)) ?></td>
<td><?= $esc($o['created_at'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/**
* Composeur de commande comptoir/drive (version PRODUITS, sous-lot 3a), injecte dans
* admin/layout.php. Une quantite par produit commandable (champ qty_<id>) + un select
* service_mode. Partage par les deux canaux ; la source/landing viennent du controleur.
* Au canal drive, service_mode est verrouille a 'drive' (RG-T09). Echappement RG-T15.
*
* @var list<array<string, mixed>> $products
* @var string $source 'counter' | 'drive'
* @var string $serviceMode valeur preselectionnee / reaffichee
* @var string $landing retour a la liste du canal
* @var string|null $error
* @var string $csrfToken
*/
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR';
$csrf = $esc($csrfToken ?? '');
$chan = isset($source) && $source === 'drive' ? 'drive' : 'counter';
$action = $chan === 'drive' ? '/drive/orders' : '/counter/orders';
$backTo = isset($landing) && is_string($landing) ? $landing : '/counter/orders';
$mode = isset($serviceMode) && is_string($serviceMode) ? $serviceMode : ($chan === 'drive' ? 'drive' : 'dine_in');
$errorMessage = isset($error) && is_string($error) ? $error : null;
/** @var list<array<string, mixed>> $rows */
$rows = isset($products) && is_array($products) ? $products : [];
// RG-T09 : au drive, le seul mode possible est 'drive'. Le comptoir choisit librement.
$modeOptions = $chan === 'drive'
? ['drive' => 'Drive']
: ['dine_in' => 'Sur place', 'takeaway' => 'A emporter'];
?>
<div class="page-header">
<h1 class="page-title">Nouvelle commande <?= $chan === 'drive' ? 'drive' : 'comptoir' ?></h1>
</div>
<?php if ($errorMessage !== null): ?>
<p class="form-error" role="alert"><?= $esc($errorMessage) ?></p>
<?php endif; ?>
<form method="post" action="<?= $esc($action) ?>" class="form-card">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group">
<label class="form-label" for="service_mode">Mode de service</label>
<select class="form-input" id="service_mode" name="service_mode"<?= $chan === 'drive' ? ' readonly' : '' ?>>
<?php foreach ($modeOptions as $value => $label): ?>
<option value="<?= $esc($value) ?>"<?= $mode === $value ? ' selected' : '' ?>><?= $esc($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($rows === []): ?>
<p class="admin-empty">Aucun produit commandable pour le moment.</p>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<th>Produit</th>
<th>Prix</th>
<th>Quantite</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $p): ?>
<?php $pid = (int) ($p['id'] ?? 0); ?>
<tr>
<td><?= $esc($p['name'] ?? '') ?></td>
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td>
<td>
<input class="form-input" type="number" min="0" value="0"
id="qty_<?= $pid ?>" name="qty_<?= $pid ?>"
aria-label="Quantite <?= $esc($p['name'] ?? '') ?>">
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Encaisser la commande</button>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
</div>
</form>

View file

@ -115,9 +115,14 @@ $navClass = static function (string $code, string $current): string {
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($can('stats.read') || $can('order.read')): ?> <?php if ($can('stats.read') || $can('order.read') || $can('order.create')): ?>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-label">Pilotage</div> <div class="sidebar-section-label">Pilotage</div>
<?php if ($can('order.create')): ?>
<?php /* Lien generique vers le comptoir ; le canal effectif (counter/drive)
est derive du chemin par CounterOrderController (mlt 4.1). */ ?>
<a href="/counter/orders" class="<?= $navClass('counter', $active) ?>">Saisie commande</a>
<?php endif; ?>
<?php if ($can('stats.read')): ?> <?php if ($can('stats.read')): ?>
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a> <a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>
<?php endif; ?> <?php endif; ?>

View file

@ -14,6 +14,7 @@ use App\Auth\SessionManager;
use App\Controllers\AuthController; use App\Controllers\AuthController;
use App\Controllers\CatalogueController; use App\Controllers\CatalogueController;
use App\Controllers\CategoryController; use App\Controllers\CategoryController;
use App\Controllers\CounterOrderController;
use App\Controllers\DashboardController; use App\Controllers\DashboardController;
use App\Controllers\HealthController; use App\Controllers\HealthController;
use App\Controllers\HomeController; use App\Controllers\HomeController;
@ -126,6 +127,19 @@ try {
// kitchen (seed role.default_route = /kitchen/display) ; corrige le 404 d'apres-login. // kitchen (seed role.default_route = /kitchen/display) ; corrige le 404 d'apres-login.
$router->add('GET', '/kitchen/display', [KitchenController::class, 'display']); $router->add('GET', '/kitchen/display', [KitchenController::class, 'display']);
// Saisie de commande comptoir / drive (CREATE_COUNTER_ORDER, mlt 4.1, order.create).
// UN controleur, deux canaux : la source est derivee du chemin (/drive -> drive,
// sinon counter). Landings des roles counter/drive (seed role.default_route =
// /counter/orders + /drive/orders) ; corrige le 404 d'apres-login. Sans PIN
// (la permission order.create suffit) ; la commande est encaissee directement.
// {new}/{POST liste} = segments distincts, pas de collision avec /kitchen ni /admin.
$router->add('GET', '/counter/orders', [CounterOrderController::class, 'index']);
$router->add('GET', '/counter/orders/new', [CounterOrderController::class, 'create']);
$router->add('POST', '/counter/orders', [CounterOrderController::class, 'store']);
$router->add('GET', '/drive/orders', [CounterOrderController::class, 'index']);
$router->add('GET', '/drive/orders/new', [CounterOrderController::class, 'create']);
$router->add('POST', '/drive/orders', [CounterOrderController::class, 'store']);
// 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
// seul segment (pas de collision avec /edit, /deactivate, /reset-pin, /erase). // seul segment (pas de collision avec /edit, /deactivate, /reset-pin, /erase).

View file

@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use App\Auth\Csrf;
use App\Auth\SessionManager;
use App\Controllers\CounterOrderController;
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 : liste canned multi-source (rendu de la liste teste sans
* base). recent() ramene tous canaux ; le controleur filtre par source derivee du chemin.
*/
final class StubChannelOrders extends OrderQueryRepository
{
public function recent(int $limit = 50): array
{
return [
['order_number' => 'C100', 'source' => 'counter', 'service_mode' => 'dine_in', 'service_tag' => null, 'status' => 'paid', 'total_ttc_cents' => 890, 'created_at' => '2026-06-22 10:00:00', 'paid_at' => '2026-06-22 10:00:01'],
['order_number' => 'D200', 'source' => 'drive', 'service_mode' => 'drive', 'service_tag' => null, 'status' => 'paid', 'total_ttc_cents' => 990, 'created_at' => '2026-06-22 10:05:00', 'paid_at' => '2026-06-22 10:05:01'],
['order_number' => 'K9', 'source' => 'kiosk', 'service_mode' => 'takeaway', 'service_tag' => null, 'status' => 'paid', 'total_ttc_cents' => 500, 'created_at' => '2026-06-22 10:06:00', 'paid_at' => '2026-06-22 10:06:01'],
];
}
}
final class TestCounterOrderController extends CounterOrderController
{
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 StubChannelOrders($this->fakeDb);
}
}
final class CounterOrderControllerTest extends TestCase
{
/** @var list<string> */
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->session = new SessionManager(new Config(), true);
$now = time();
$this->session->set('user_id', 7);
$this->session->set('role_id', 4);
$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
{
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' => 'Sam', 'last_name' => 'C', 'role_label' => 'Comptoir'];
$db->canResult = true;
$db->permissionCodes = ['order.read', 'order.create'];
return $db;
}
private function get(string $path): Request
{
return new Request('GET', $path, [], [], '', '203.0.113.5');
}
/**
* @param array<string, string> $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 controller(Request $request, FakeDatabase $db): TestCounterOrderController
{
return new TestCounterOrderController($request, new Config(), new Database(new Config()), $this->session, $db);
}
public function testIndexRequiresOrderCreate(): void
{
$db = $this->permittedDb();
$db->canResult = false;
$db->permissionCodes = [];
self::assertSame(403, $this->controller($this->get('/counter/orders'), $db)->index()->status());
}
public function testCounterIndexListsOnlyCounterOrders(): void
{
$response = $this->controller($this->get('/counter/orders'), $this->permittedDb())->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('Commandes comptoir', $body);
self::assertStringContainsString('C100', $body); // canal counter present
self::assertStringNotContainsString('D200', $body); // canal drive exclu
self::assertStringNotContainsString('K9', $body); // kiosk exclu
self::assertStringContainsString('Nouvelle commande', $body);
}
public function testDriveIndexListsOnlyDriveOrders(): void
{
$response = $this->controller($this->get('/drive/orders'), $this->permittedDb())->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('Commandes drive', $body);
self::assertStringContainsString('D200', $body);
self::assertStringNotContainsString('C100', $body);
}
public function testCreateRendersProductComposer(): void
{
$db = $this->permittedDb();
$db->productsRows = [
['id' => 12, 'category_id' => 1, 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1],
];
$response = $this->controller($this->get('/counter/orders/new'), $db)->create();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('Cheeseburger', $body);
self::assertStringContainsString('qty_12', $body); // champ quantite par produit
self::assertStringContainsString('service_mode', $body);
}
public function testStoreRejectsInvalidCsrf(): void
{
$db = $this->permittedDb();
$request = $this->post(['service_mode' => 'dine_in', 'qty_12' => '1'], '/counter/orders');
self::assertSame(403, $this->controller($request, $db)->store()->status());
}
public function testStoreEmptyCartReRendersWith422(): void
{
$db = $this->permittedDb();
$db->productsRows = [
['id' => 12, 'category_id' => 1, 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1],
];
$request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'dine_in', 'qty_12' => '0'], '/counter/orders');
$response = $this->controller($request, $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO customer_order'));
}
public function testStoreCreatesCounterOrderAndRedirects(): void
{
$db = $this->permittedDb();
$db->productRow = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
$db->lastInsertId = 100;
// pay() (encaissement immediat) relit la commande persistee par numero.
$db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'dine_in', 'qty_12' => '2'], '/counter/orders');
$response = $this->controller($request, $db)->store();
self::assertSame(302, $response->status());
self::assertSame('/counter/orders', $response->header('Location'));
self::assertTrue($db->wrote('INSERT INTO customer_order'));
// Source auto-tagguee counter, acteur = equipier de session (id 7).
$insert = $this->writeParams($db, 'INSERT INTO customer_order');
self::assertSame('counter', $insert['source']);
self::assertSame(7, $insert['acting']);
// Encaissement immediat : transition paid emise.
self::assertTrue($db->wrote('UPDATE customer_order SET status'));
}
public function testStoreDriveRejectsNonDriveServiceMode(): void
{
// RG-T09 : au drive, service_mode doit etre drive ; sinon re-rendu 422, pas d'INSERT.
$db = $this->permittedDb();
$db->productRow = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
$request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'takeaway', 'qty_12' => '1'], '/drive/orders');
$response = $this->controller($request, $db)->store();
self::assertSame(422, $response->status());
self::assertFalse($db->wrote('INSERT INTO customer_order'));
}
/**
* Parametres lies de la premiere ecriture dont le SQL contient $needle.
*
* @return array<string|int, mixed>
*/
private function writeParams(FakeDatabase $db, string $needle): array
{
foreach ($db->writes as $write) {
if (str_contains($write['sql'], $needle)) {
return $write['params'];
}
}
return [];
}
}

View file

@ -177,6 +177,124 @@ final class OrderRepositoryTest extends TestCase
]); ]);
} }
// --- createStaffOrder() : comptoir/drive, source tagguee + encaissement immediat (mlt 4.1) ---
public function testStaffOrderCounterTagsSourcePrefixesCAndPaysImmediately(): void
{
$db = new FakeOrderDatabase();
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
$db->compositions[12] = [['ingredient_id' => 5, 'quantity_normal' => 1, 'quantity_maxi' => 1]];
// persist() insere en pending puis pay() relit la commande par numero + ses
// lignes : on simule la ligne persistee (id 100 -> 'C100', pending) et son
// order_item, pour que le decrement de stock de pay() s'execute (RG-T20).
$db->orderByNumber = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$db->orderItems = [['id' => 1, 'item_type' => 'product', 'product_id' => 12, 'menu_id' => null, 'format' => 'normal', 'quantity' => 1]];
$res = $this->repo($db)->createStaffOrder([
'service_mode' => 'dine_in',
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]],
], 7, 'counter');
// POST-1 : source counter, prefixe 'C' + id, acting_user_id pose, status paid.
$order = $db->firstWrite('INSERT INTO customer_order');
self::assertSame('counter', $order['source']);
self::assertSame(7, $order['acting']);
$renumber = $db->firstWrite('UPDATE customer_order SET order_number');
self::assertSame('C100', $renumber['num']);
self::assertSame('paid', $res['status']);
self::assertSame('C100', $res['order_number']);
// POST-3 : stock decremente avec user_id = equipier (RG-4/RG-T20).
$move = $db->firstWrite('INSERT INTO stock_movement');
self::assertSame(-1, $move['delta']);
self::assertSame(7, $move['uid']);
// L'acteur est aussi pose a la transition paid (acting_user_id, COALESCE).
$transition = $db->firstWrite('UPDATE customer_order SET status');
self::assertSame(7, $transition['uid']);
}
public function testStaffOrderDrivePrefixesD(): void
{
$db = new FakeOrderDatabase();
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
$db->orderByNumber = ['id' => 100, 'order_number' => 'D100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$res = $this->repo($db)->createStaffOrder([
'service_mode' => 'drive', // RG-T09 : drive impose drive
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]],
], 7, 'drive');
$order = $db->firstWrite('INSERT INTO customer_order');
self::assertSame('drive', $order['source']);
self::assertSame('D100', $db->firstWrite('UPDATE customer_order SET order_number')['num']);
self::assertSame('paid', $res['status']);
}
public function testStaffOrderDriveRejectsNonDriveServiceMode(): void
{
// RG-T09 / ERR-2 : source drive mais service_mode != drive -> INVALID_SERVICE_MODE.
$db = new FakeOrderDatabase();
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
try {
$this->repo($db)->createStaffOrder([
'service_mode' => 'takeaway',
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]],
], 7, 'drive');
self::fail('expected OrderValidationException');
} catch (OrderValidationException $exception) {
self::assertSame('INVALID_SERVICE_MODE', $exception->getMessage());
}
// Verifie AVANT l'INSERT : aucune commande n'est creee.
self::assertSame(0, $db->countWrites('INSERT INTO customer_order'));
}
public function testStaffOrderRejectsUnknownSource(): void
{
$db = new FakeOrderDatabase();
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('INVALID_SOURCE');
$this->repo($db)->createStaffOrder([
'service_mode' => 'dine_in',
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]],
], 7, 'kiosk');
}
public function testStaffOrderRejectsEmptyCart(): void
{
$db = new FakeOrderDatabase();
$this->expectException(OrderValidationException::class);
$this->expectExceptionMessage('EMPTY_ORDER');
$this->repo($db)->createStaffOrder([
'service_mode' => 'dine_in',
'items' => [],
], 7, 'counter');
}
public function testKioskCreatePendingStaysUnchanged(): void
{
// Garde-fou de non-regression : le flux kiosk reste source 'kiosk', prefixe 'K',
// acting_user_id NULL, status pending_payment (createStaffOrder ne l'a pas altere).
$db = new FakeOrderDatabase();
$db->products[12] = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'is_available' => 1];
$res = $this->repo($db)->createPending([
'service_mode' => 'takeaway',
'items' => [['type' => 'product', 'product_id' => 12, 'quantity' => 1]],
]);
$order = $db->firstWrite('INSERT INTO customer_order');
self::assertSame('kiosk', $order['source']);
self::assertNull($order['acting']);
self::assertSame('K100', $res['order_number']);
self::assertSame('pending_payment', $res['status']);
// Pas d'encaissement automatique pour le kiosk : aucune transition paid.
self::assertSame(0, $db->countWrites('UPDATE customer_order SET status'));
}
// --- pay() : transition + decrement de stock (RG-5 etapes 5-6, RG-T20) --- // --- pay() : transition + decrement de stock (RG-5 etapes 5-6, RG-T20) ---
public function testPayTransitionsToPaidAndDecrementsProductRecipe(): void public function testPayTransitionsToPaidAndDecrementsProductRecipe(): void