release: dev -> main v0.2.0 #93
9 changed files with 863 additions and 12 deletions
231
src/app/Controllers/CounterOrderController.php
Normal file
231
src/app/Controllers/CounterOrderController.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ class OrderQueryRepository
|
|||
$limit = max(1, min(200, $limit));
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,70 @@ class OrderRepository
|
|||
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'] ?? '');
|
||||
if (!in_array($serviceMode, ['dine_in', 'takeaway', 'drive'], true)) {
|
||||
throw new OrderValidationException('INVALID_SERVICE_MODE');
|
||||
|
|
@ -136,23 +200,25 @@ class OrderRepository
|
|||
|
||||
$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(
|
||||
'INSERT INTO customer_order '
|
||||
. '(order_number, idempotency_key, source, service_mode, service_tag, status, '
|
||||
. ' total_ht_cents, total_vat_cents, total_ttc_cents) '
|
||||
. "VALUES ('', :idem, 'kiosk', :mode, :tag, 'pending_payment', :ht, :vat, :ttc)",
|
||||
. ' acting_user_id, total_ht_cents, total_vat_cents, total_ttc_cents) '
|
||||
. "VALUES ('', :idem, :source, :mode, :tag, 'pending_payment', :acting, :ht, :vat, :ttc)",
|
||||
[
|
||||
'idem' => $key !== '' ? $key : null,
|
||||
'mode' => $serviceMode,
|
||||
'tag' => $serviceTag !== '' ? $serviceTag : null,
|
||||
'ht' => $totalHt,
|
||||
'vat' => $totalVat,
|
||||
'ttc' => $totalTtc,
|
||||
'idem' => $key !== '' ? $key : null,
|
||||
'source' => $source,
|
||||
'mode' => $serviceMode,
|
||||
'tag' => $serviceTag !== '' ? $serviceTag : null,
|
||||
'acting' => $actingUserId,
|
||||
'ht' => $totalHt,
|
||||
'vat' => $totalVat,
|
||||
'ttc' => $totalTtc,
|
||||
],
|
||||
);
|
||||
$orderId = (int) ($db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
|
||||
$orderNumber = 'K' . $orderId;
|
||||
$orderNumber = $prefix . $orderId;
|
||||
$db->execute(
|
||||
'UPDATE customer_order SET order_number = :num WHERE id = :id',
|
||||
['num' => $orderNumber, 'id' => $orderId],
|
||||
|
|
|
|||
80
src/app/Views/admin/counter/index.php
Normal file
80
src/app/Views/admin/counter/index.php
Normal 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>
|
||||
89
src/app/Views/admin/counter/new.php
Normal file
89
src/app/Views/admin/counter/new.php
Normal 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>
|
||||
|
|
@ -115,9 +115,14 @@ $navClass = static function (string $code, string $current): string {
|
|||
</div>
|
||||
<?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-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')): ?>
|
||||
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>
|
||||
<?php endif; ?>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use App\Auth\SessionManager;
|
|||
use App\Controllers\AuthController;
|
||||
use App\Controllers\CatalogueController;
|
||||
use App\Controllers\CategoryController;
|
||||
use App\Controllers\CounterOrderController;
|
||||
use App\Controllers\DashboardController;
|
||||
use App\Controllers\HealthController;
|
||||
use App\Controllers\HomeController;
|
||||
|
|
@ -126,6 +127,19 @@ try {
|
|||
// kitchen (seed role.default_route = /kitchen/display) ; corrige le 404 d'apres-login.
|
||||
$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/
|
||||
// deactivate. TOUTES les mutations = PIN equipier + audit (RG-T13/14). {id} = un
|
||||
// seul segment (pas de collision avec /edit, /deactivate, /reset-pin, /erase).
|
||||
|
|
|
|||
248
tests/Unit/Admin/CounterOrderControllerTest.php
Normal file
248
tests/Unit/Admin/CounterOrderControllerTest.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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) ---
|
||||
|
||||
public function testPayTransitionsToPaidAndDecrementsProductRecipe(): void
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue