feat(back-office): KDS cuisine - detail des commandes + bande SLA (#108)
This commit is contained in:
parent
1e5f930185
commit
89488b20b2
7 changed files with 572 additions and 8 deletions
|
|
@ -32,10 +32,13 @@ class KitchenController extends AdminController
|
|||
|
||||
$sources = $this->orderQuery()->visibleSources($guard->roleId ?? 0);
|
||||
|
||||
// paidQueueWithDetail : memes commandes que paidQueue, enrichies du detail des
|
||||
// articles (selections + modificateurs) et d'une bande SLA derivee de paid_at,
|
||||
// pour que le KDS soit exploitable pour PREPARER (et pas seulement lister).
|
||||
return $this->adminView('admin/kitchen/display', [
|
||||
'title' => 'Cuisine - Wakdo Admin',
|
||||
'activeNav' => 'kitchen',
|
||||
'orders' => $this->orderQuery()->paidQueue($sources),
|
||||
'orders' => $this->orderQuery()->paidQueueWithDetail($sources),
|
||||
'canDeliver' => $this->may($guard, 'order.deliver'),
|
||||
], $guard);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ use App\Core\DatabaseInterface;
|
|||
*/
|
||||
class OrderQueryRepository
|
||||
{
|
||||
/**
|
||||
* Seuils de la bande SLA du KDS (RG-4 de 5.1 ; seuil cible ~10 min, Note 6).
|
||||
* Constantes plutot qu'env : un seul reglage, simple a relire et a tester ;
|
||||
* a externaliser en configuration si le besoin de variation par site apparait.
|
||||
*/
|
||||
private const SLA_WARN_SECONDS = 300; // 5 min : passage vert -> ambre.
|
||||
private const SLA_LATE_SECONDS = 600; // 10 min (seuil cible) : ambre -> rouge.
|
||||
|
||||
public function __construct(private readonly DatabaseInterface $db)
|
||||
{
|
||||
}
|
||||
|
|
@ -90,6 +98,173 @@ class OrderQueryRepository
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* File de preparation enrichie pour le KDS (LIST_ORDERS_DISPLAY, mlt 5.1) :
|
||||
* memes commandes `paid` que paidQueue (meme filtre de sources, meme tri
|
||||
* paid_at croissant), mais chaque commande porte en plus :
|
||||
* - `items` : ses lignes order_item (label_snapshot, quantity, format),
|
||||
* chacune avec ses `selections` (choix de slot, label_snapshot)
|
||||
* et ses `modifiers` (ingredient + action remove/add) ;
|
||||
* - `sla_band`: la bande SLA derivee de (now - paid_at) -- fresh / warn / late.
|
||||
*
|
||||
* RG-3 (5.1) : l'affichage s'appuie sur les SNAPSHOTS persistes ; aucune
|
||||
* re-jointure sur product/menu n'est faite. RG-4 : la couleur est calculee au
|
||||
* rendu, sans etat stocke (Note 6 du dictionnaire).
|
||||
*
|
||||
* Anti N+1 : 4 requetes au total quel que soit le nombre de commandes (la file
|
||||
* + un fetch groupe pour items / selections / modifiers via IN (...)), plutot
|
||||
* qu'un fetch par commande. L'horloge est injectable ($now) pour des bandes SLA
|
||||
* deterministes en test (meme couture ?int $now que SessionGuard / PinThrottle).
|
||||
*
|
||||
* @param list<string> $sources
|
||||
* @param int|null $now epoch de reference pour la bande SLA ; null => time()
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function paidQueueWithDetail(array $sources, ?int $now = null): array
|
||||
{
|
||||
$now ??= time();
|
||||
|
||||
if ($sources === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = [];
|
||||
$params = [];
|
||||
foreach (array_values($sources) as $i => $source) {
|
||||
$key = 's' . $i;
|
||||
$placeholders[] = ':' . $key;
|
||||
$params[$key] = $source;
|
||||
}
|
||||
|
||||
// `id` est selectionne ici (a la difference de paidQueue) : il sert de cle de
|
||||
// jointure pour le fetch groupe des lignes, sans etre expose tel quel a la vue.
|
||||
$orders = $this->db->fetchAll(
|
||||
'SELECT id, 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,
|
||||
);
|
||||
if ($orders === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$orderIds = array_map(static fn (array $o): int => (int) $o['id'], $orders);
|
||||
$items = $this->itemsForOrders($orderIds);
|
||||
|
||||
$out = [];
|
||||
foreach ($orders as $order) {
|
||||
$order['items'] = $items[(int) $order['id']] ?? [];
|
||||
$order['sla_band'] = $this->slaBand((string) ($order['paid_at'] ?? ''), $now);
|
||||
unset($order['id']); // l'id technique ne sert qu'a la jointure, pas a la vue.
|
||||
$out[] = $order;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bande SLA d'une commande a partir de l'ecart (now - paid_at), Note 6 / RG-4 (5.1).
|
||||
* Bandes : `fresh` si < 5 min, `warn` si 5-10 min, `late` au-dela (seuil cible
|
||||
* 10 min). Un paid_at vide ou non parsable retombe sur `fresh` (pas d'alerte sur
|
||||
* une donnee absente). Calcul pur (pas d'I/O) : la vue ne fait que mapper la bande
|
||||
* vers une classe CSS ; cf. kitchen/display.php.
|
||||
*/
|
||||
public function slaBand(string $paidAt, ?int $now = null): string
|
||||
{
|
||||
$now ??= time();
|
||||
$paid = $paidAt !== '' ? strtotime($paidAt) : false;
|
||||
if ($paid === false) {
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
$elapsed = $now - $paid;
|
||||
if ($elapsed >= self::SLA_LATE_SECONDS) {
|
||||
return 'late';
|
||||
}
|
||||
if ($elapsed >= self::SLA_WARN_SECONDS) {
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge en lot les lignes des commandes donnees (order_item + selections +
|
||||
* modifiers), regroupees par order_id puis structurees par ligne. Trois requetes
|
||||
* groupees (IN (...)) au lieu d'un fetch par commande : borne le cout a O(1)
|
||||
* aller-retours quel que soit le volume de la file. Les ids viennent de la liste
|
||||
* interne (entiers surs), interpoles comme entiers : LIMIT/IN ne lient pas avec
|
||||
* ATTR_EMULATE_PREPARES=false, et un cast (int) ferme l'injection.
|
||||
*
|
||||
* @param list<int> $orderIds
|
||||
* @return array<int, list<array<string, mixed>>> items par order_id
|
||||
*/
|
||||
private function itemsForOrders(array $orderIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $orderIds)));
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
$inOrders = implode(', ', $ids);
|
||||
|
||||
$itemRows = $this->db->fetchAll(
|
||||
'SELECT id, order_id, item_type, format, label_snapshot, quantity '
|
||||
. 'FROM order_item WHERE order_id IN (' . $inOrders . ') ORDER BY id ASC',
|
||||
);
|
||||
if ($itemRows === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$itemIds = array_map(static fn (array $r): int => (int) $r['id'], $itemRows);
|
||||
$inItems = implode(', ', array_values(array_unique($itemIds)));
|
||||
|
||||
$selectionsByItem = $this->groupByItem(
|
||||
$this->db->fetchAll(
|
||||
'SELECT order_item_id, label_snapshot FROM order_item_selection '
|
||||
. 'WHERE order_item_id IN (' . $inItems . ') ORDER BY id ASC',
|
||||
),
|
||||
);
|
||||
// order_item_modifier ne stocke PAS de libelle (uniquement ingredient_id) :
|
||||
// a la difference des selections (label_snapshot present), le nom lisible vient
|
||||
// d'une jointure sur `ingredient`. Seule re-jointure necessaire (RG-3 ne
|
||||
// l'exclut que pour product/menu). Le nom d'ingredient est relativement stable ;
|
||||
// a defaut de snapshot c'est la source disponible.
|
||||
$modifiersByItem = $this->groupByItem(
|
||||
$this->db->fetchAll(
|
||||
'SELECT oim.order_item_id, oim.action, i.name AS ingredient_name '
|
||||
. 'FROM order_item_modifier oim JOIN ingredient i ON i.id = oim.ingredient_id '
|
||||
. 'WHERE oim.order_item_id IN (' . $inItems . ') ORDER BY oim.id ASC',
|
||||
),
|
||||
);
|
||||
|
||||
$itemsByOrder = [];
|
||||
foreach ($itemRows as $row) {
|
||||
$itemId = (int) $row['id'];
|
||||
$row['selections'] = $selectionsByItem[$itemId] ?? [];
|
||||
$row['modifiers'] = $modifiersByItem[$itemId] ?? [];
|
||||
$itemsByOrder[(int) $row['order_id']][] = $row;
|
||||
}
|
||||
|
||||
return $itemsByOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regroupe des lignes filles par leur order_item_id (cle de jointure commune
|
||||
* aux selections et aux modificateurs).
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
* @return array<int, list<array<string, mixed>>>
|
||||
*/
|
||||
private function groupByItem(array $rows): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$grouped[(int) ($row['order_item_id'] ?? 0)][] = $row;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* KPIs de vente : CA encaisse (statuts paid + delivered), nombre de commandes
|
||||
* encaissees, panier moyen, CA et nombre du JOUR, total de commandes, et la
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ declare(strict_types=1);
|
|||
* 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).
|
||||
*
|
||||
* Chaque commande porte son detail (items -> selections + modifiers) et une bande SLA
|
||||
* (sla_band : fresh / warn / late) calculee cote serveur depuis (now - paid_at),
|
||||
* mappee vers une classe CSS sur la carte (kds-order--fresh / --warn / --late). Le KDS
|
||||
* est rendu exploitable pour PREPARER : la liste lisible des articles est affichee.
|
||||
*
|
||||
* @var list<array<string, mixed>> $orders
|
||||
* @var bool $canDeliver
|
||||
* @var string $csrfToken
|
||||
|
|
@ -20,12 +25,65 @@ $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');
|
||||
|
||||
// Bande SLA (serveur) -> classe CSS de la carte. Defaut prudent sur valeur inconnue.
|
||||
$slaClass = static fn (string $band): string => [
|
||||
'fresh' => 'kds-order--fresh',
|
||||
'warn' => 'kds-order--warn',
|
||||
'late' => 'kds-order--late',
|
||||
][$band] ?? 'kds-order--fresh';
|
||||
|
||||
/**
|
||||
* Libelle lisible d'un article : "<qty>x <label> (Maxi) - <selections> - <modifs>".
|
||||
* S'appuie sur les snapshots (label_snapshot, format) ; les modificateurs sont rendus
|
||||
* "sans <ingredient>" (remove) / "+<ingredient>" (add). Tout est echappe a la sortie.
|
||||
*
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
$itemLabel = static function (array $item) use ($esc): string {
|
||||
$qty = max(1, (int) ($item['quantity'] ?? 1));
|
||||
$name = (string) ($item['label_snapshot'] ?? '');
|
||||
$main = $esc($qty) . 'x ' . $esc($name);
|
||||
if ((string) ($item['format'] ?? 'normal') === 'maxi') {
|
||||
$main .= ' (Maxi)';
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
|
||||
$selections = isset($item['selections']) && is_array($item['selections']) ? $item['selections'] : [];
|
||||
$selLabels = [];
|
||||
foreach ($selections as $sel) {
|
||||
$label = trim((string) ($sel['label_snapshot'] ?? ''));
|
||||
if ($label !== '') {
|
||||
$selLabels[] = $esc($label);
|
||||
}
|
||||
}
|
||||
if ($selLabels !== []) {
|
||||
$parts[] = implode(', ', $selLabels);
|
||||
}
|
||||
|
||||
$modifiers = isset($item['modifiers']) && is_array($item['modifiers']) ? $item['modifiers'] : [];
|
||||
$modLabels = [];
|
||||
foreach ($modifiers as $mod) {
|
||||
$ing = trim((string) ($mod['ingredient_name'] ?? ''));
|
||||
if ($ing === '') {
|
||||
continue;
|
||||
}
|
||||
$modLabels[] = ((string) ($mod['action'] ?? '') === 'add' ? '+' : 'sans ') . $esc($ing);
|
||||
}
|
||||
if ($modLabels !== []) {
|
||||
$parts[] = implode(', ', $modLabels);
|
||||
}
|
||||
|
||||
return $parts === [] ? $main : $main . ' - ' . implode(' - ', $parts);
|
||||
};
|
||||
?>
|
||||
<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>
|
||||
<span class="kitchen-clock" id="kitchenTime" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<?php if ($rows === []): ?>
|
||||
|
|
@ -33,9 +91,13 @@ $modeLabel = static fn (string $m): string => $m === 'dine_in' ? 'Sur place' : (
|
|||
<?php else: ?>
|
||||
<section class="kitchen-grid" aria-label="File des commandes payees">
|
||||
<?php foreach ($rows as $o): ?>
|
||||
<article class="kitchen-card">
|
||||
<?php
|
||||
$items = isset($o['items']) && is_array($o['items']) ? $o['items'] : [];
|
||||
$band = (string) ($o['sla_band'] ?? 'fresh');
|
||||
?>
|
||||
<article class="kitchen-card <?= $esc($slaClass($band)) ?>">
|
||||
<div class="kitchen-card-header">
|
||||
<span class="kitchen-card-number"><?= $esc($o['order_number'] ?? '') ?></span>
|
||||
<span class="kitchen-order-num"><?= $esc($o['order_number'] ?? '') ?></span>
|
||||
<span class="kitchen-card-source"><?= $esc($sourceLabel((string) ($o['source'] ?? ''))) ?></span>
|
||||
</div>
|
||||
<div class="kitchen-card-body">
|
||||
|
|
@ -44,6 +106,15 @@ $modeLabel = static fn (string $m): string => $m === 'dine_in' ? 'Sur place' : (
|
|||
<p class="kitchen-line">Table : <?= $esc($o['service_tag']) ?></p>
|
||||
<?php endif; ?>
|
||||
<p class="kitchen-line">Payee a : <?= $esc($o['paid_at'] ?? '') ?></p>
|
||||
<?php if ($items === []): ?>
|
||||
<p class="kitchen-line">Aucun article.</p>
|
||||
<?php else: ?>
|
||||
<ul class="kds-items">
|
||||
<?php foreach ($items as $item): ?>
|
||||
<li class="kds-item"><?= $itemLabel($item) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($can): ?>
|
||||
<div class="kitchen-card-footer">
|
||||
|
|
|
|||
|
|
@ -1095,6 +1095,48 @@ tbody td.mono {
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- KDS : detail des articles --- */
|
||||
.kds-items {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.kds-item {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.kds-item:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* --- KDS : bande SLA (now - paid_at), calculee cote serveur --- */
|
||||
/* Bande couleur a gauche de la carte : vert < 5 min, ambre 5-10 min, rouge > 10 min. */
|
||||
.kds-order--fresh {
|
||||
border-left: 4px solid var(--color-success);
|
||||
}
|
||||
|
||||
.kds-order--warn {
|
||||
border-left: 4px solid var(--color-warning);
|
||||
background: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
.kds-order--late {
|
||||
border-left: 4px solid var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
}
|
||||
|
||||
.kitchen-clock {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* --- Login page --- */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
|
|
|
|||
|
|
@ -100,4 +100,66 @@ final class OrderQueryRepositoryDbTest extends TestCase
|
|||
$repo = new OrderQueryRepository($this->db);
|
||||
self::assertLessThanOrEqual(3, count($repo->recent(3)));
|
||||
}
|
||||
|
||||
/**
|
||||
* paidQueueWithDetail (LIST_ORDERS_DISPLAY) contre le schema reel : insere une
|
||||
* commande `paid` avec une ligne produit + un modificateur, et verifie que la file
|
||||
* porte l'article (label_snapshot, format) et le modificateur (ingredient_name via
|
||||
* la jointure ingredient + action). Les FK sont resolues par nom (convention des
|
||||
* seeds : produit 'Le 280', ingredient 'Oignon'). Auto-skip si seeds absents.
|
||||
*/
|
||||
public function testPaidQueueWithDetailReturnsItemsAndModifiers(): void
|
||||
{
|
||||
$product = $this->db->fetch("SELECT id FROM product WHERE name = 'Le 280'");
|
||||
$ingredient = $this->db->fetch("SELECT id FROM ingredient WHERE name = 'Oignon'");
|
||||
if ($product === null || $ingredient === null) {
|
||||
self::markTestSkipped('Seeds catalogue/ingredients absents (produit/ingredient introuvable).');
|
||||
}
|
||||
|
||||
$num = 'IT-' . $this->suffix . '-KDS';
|
||||
$this->insertOrder($num, 'paid', 1090);
|
||||
// paid_at explicite : la file trie sur paid_at, et la bande SLA en derive.
|
||||
$orderId = $this->orderIdByNumber($num);
|
||||
$this->db->execute('UPDATE customer_order SET paid_at = NOW() WHERE id = :id', ['id' => $orderId]);
|
||||
|
||||
$this->db->execute(
|
||||
'INSERT INTO order_item (order_id, item_type, product_id, format, label_snapshot, '
|
||||
. 'unit_price_cents_snapshot, vat_rate_snapshot, quantity) '
|
||||
. "VALUES (:oid, 'product', :pid, 'normal', 'Le 280', 1090, 100, 1)",
|
||||
['oid' => $orderId, 'pid' => (int) $product['id']],
|
||||
);
|
||||
$itemId = (int) ($this->db->fetch('SELECT LAST_INSERT_ID() AS id')['id'] ?? 0);
|
||||
$this->db->execute(
|
||||
'INSERT INTO order_item_modifier (order_item_id, ingredient_id, action, extra_price_cents) '
|
||||
. "VALUES (:iid, :ing, 'remove', 0)",
|
||||
['iid' => $itemId, 'ing' => (int) $ingredient['id']],
|
||||
);
|
||||
|
||||
$queue = (new OrderQueryRepository($this->db))->paidQueueWithDetail(['kiosk', 'counter', 'drive']);
|
||||
$mine = array_values(array_filter(
|
||||
$queue,
|
||||
static fn (array $o): bool => ($o['order_number'] ?? '') === $num,
|
||||
));
|
||||
self::assertCount(1, $mine, 'la commande inseree doit apparaitre dans la file KDS');
|
||||
|
||||
$order = $mine[0];
|
||||
self::assertArrayNotHasKey('id', $order, 'l\'id technique ne doit pas etre expose');
|
||||
self::assertContains($order['sla_band'], ['fresh', 'warn', 'late']);
|
||||
|
||||
self::assertCount(1, $order['items']);
|
||||
$item = $order['items'][0];
|
||||
self::assertSame('Le 280', (string) $item['label_snapshot']);
|
||||
self::assertSame('normal', (string) $item['format']);
|
||||
self::assertCount(1, $item['modifiers']);
|
||||
self::assertSame('remove', (string) $item['modifiers'][0]['action']);
|
||||
self::assertSame('Oignon', (string) $item['modifiers'][0]['ingredient_name']);
|
||||
}
|
||||
|
||||
private function orderIdByNumber(string $number): int
|
||||
{
|
||||
return (int) ($this->db->fetch(
|
||||
'SELECT id FROM customer_order WHERE order_number = :n',
|
||||
['n' => $number],
|
||||
)['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ 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.
|
||||
* Stub OrderQueryRepository : sources visibles + file enrichie canned, pour tester le
|
||||
* rendu du KDS sans base. Le SQL reel de visibleSources/paidQueueWithDetail est couvert
|
||||
* par OrderQueryRepositoryDbTest (integration) ; ici on isole le rendu de la vue :
|
||||
* detail des articles (selections + modificateurs) et bande SLA -> classe CSS.
|
||||
*/
|
||||
final class StubKitchenQuery extends OrderQueryRepository
|
||||
{
|
||||
|
|
@ -26,10 +27,34 @@ final class StubKitchenQuery extends OrderQueryRepository
|
|||
return ['kiosk', 'counter', 'drive'];
|
||||
}
|
||||
|
||||
public function paidQueue(array $sources): array
|
||||
public function paidQueueWithDetail(array $sources, ?int $now = null): 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'],
|
||||
[
|
||||
'order_number' => 'K42',
|
||||
'source' => 'kiosk',
|
||||
'service_mode' => 'dine_in',
|
||||
'service_tag' => '12',
|
||||
'total_ttc_cents' => 990,
|
||||
'paid_at' => '2026-06-19 12:01:00',
|
||||
'sla_band' => 'warn',
|
||||
'items' => [
|
||||
[
|
||||
'item_type' => 'menu',
|
||||
'format' => 'maxi',
|
||||
'label_snapshot' => 'Menu Le 280',
|
||||
'quantity' => 1,
|
||||
'selections' => [
|
||||
['label_snapshot' => 'Coca 50cl'],
|
||||
['label_snapshot' => 'Grande Frite'],
|
||||
],
|
||||
'modifiers' => [
|
||||
['ingredient_name' => 'oignon', 'action' => 'remove'],
|
||||
['ingredient_name' => 'bacon', 'action' => 'add'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -132,4 +157,27 @@ final class KitchenControllerTest extends TestCase
|
|||
// order.deliver accorde (canResult=true) -> bouton de remise present.
|
||||
self::assertStringContainsString('Remettre', $body);
|
||||
}
|
||||
|
||||
public function testRendersItemDetailAndModifiers(): void
|
||||
{
|
||||
// Le KDS doit etre exploitable pour PREPARER : libelle, format, selections de
|
||||
// slot et modificateurs lisibles (et non plus seulement paid_at brut).
|
||||
$body = $this->controller($this->permittedDb())->display()->body();
|
||||
|
||||
self::assertStringContainsString('1x Menu Le 280', $body);
|
||||
self::assertStringContainsString('(Maxi)', $body);
|
||||
self::assertStringContainsString('Coca 50cl', $body);
|
||||
self::assertStringContainsString('Grande Frite', $body);
|
||||
self::assertStringContainsString('sans oignon', $body);
|
||||
self::assertStringContainsString('+bacon', $body);
|
||||
}
|
||||
|
||||
public function testAppliesSlaBandCssClass(): void
|
||||
{
|
||||
// La bande SLA (calculee serveur) est rendue en classe CSS sur la carte :
|
||||
// la file canned porte sla_band='warn'.
|
||||
$body = $this->controller($this->permittedDb())->display()->body();
|
||||
|
||||
self::assertStringContainsString('kds-order--warn', $body);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
163
tests/Unit/Order/OrderQueryRepositoryTest.php
Normal file
163
tests/Unit/Order/OrderQueryRepositoryTest.php
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Order;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use App\Core\DatabaseInterface;
|
||||
use App\Order\OrderQueryRepository;
|
||||
|
||||
/**
|
||||
* Double de lecture minimal pour OrderQueryRepository::paidQueueWithDetail : route
|
||||
* les quatre SELECT (file paid -> order_item -> selections -> modifiers) sur des
|
||||
* jeux de lignes scriptes. Les requetes detail utilisent IN (...) sans parametre lie
|
||||
* (ids casts en entier dans le repo) : on desambiguise donc uniquement sur le texte
|
||||
* SQL. Aucune ecriture (l'operation est lecture seule, RG-5 de 5.1).
|
||||
*/
|
||||
final class FakeKdsDatabase implements DatabaseInterface
|
||||
{
|
||||
/** @var list<array<string, mixed>> commandes paid renvoyees par la file. */
|
||||
public array $orders = [];
|
||||
/** @var list<array<string, mixed>> lignes order_item (tous order_id confondus). */
|
||||
public array $items = [];
|
||||
/** @var list<array<string, mixed>> lignes order_item_selection. */
|
||||
public array $selections = [];
|
||||
/** @var list<array<string, mixed>> lignes order_item_modifier (avec ingredient_name). */
|
||||
public array $modifiers = [];
|
||||
|
||||
public function fetch(string $sql, array $params = []): ?array
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function fetchAll(string $sql, array $params = []): array
|
||||
{
|
||||
if (str_contains($sql, "WHERE status = 'paid'")) {
|
||||
return $this->orders;
|
||||
}
|
||||
if (str_contains($sql, 'FROM order_item WHERE order_id IN')) {
|
||||
return $this->items;
|
||||
}
|
||||
if (str_contains($sql, 'FROM order_item_selection')) {
|
||||
return $this->selections;
|
||||
}
|
||||
if (str_contains($sql, 'FROM order_item_modifier')) {
|
||||
return $this->modifiers;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function execute(string $sql, array $params = []): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function transaction(callable $fn): void
|
||||
{
|
||||
$fn($this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Couvre la derivation de la bande SLA (slaBand, RG-4 de 5.1 / Note 6 : vert < 5 min,
|
||||
* ambre 5-10 min, rouge > 10 min, calcul depuis now - paid_at avec horloge injectee)
|
||||
* et l'assemblage du payload enrichi (paidQueueWithDetail : items imbriquant
|
||||
* selections + modifiers, tri preserve, id technique non expose).
|
||||
*/
|
||||
final class OrderQueryRepositoryTest extends TestCase
|
||||
{
|
||||
private const NOW = 1_700_000_000; // epoch de reference fixe (deterministe).
|
||||
|
||||
/** paid_at calibre a $secondsAgo secondes avant NOW. */
|
||||
private function paidAtSecondsAgo(int $secondsAgo): string
|
||||
{
|
||||
return date('Y-m-d H:i:s', self::NOW - $secondsAgo);
|
||||
}
|
||||
|
||||
public function testSlaBandFreshBelowFiveMinutes(): void
|
||||
{
|
||||
$repo = new OrderQueryRepository(new FakeKdsDatabase());
|
||||
// 4 min ecoulees -> sous le seuil ambre (300 s) -> vert.
|
||||
self::assertSame('fresh', $repo->slaBand($this->paidAtSecondsAgo(240), self::NOW));
|
||||
}
|
||||
|
||||
public function testSlaBandWarnBetweenFiveAndTenMinutes(): void
|
||||
{
|
||||
$repo = new OrderQueryRepository(new FakeKdsDatabase());
|
||||
// 7 min ecoulees -> >= 300 s et < 600 s -> ambre.
|
||||
self::assertSame('warn', $repo->slaBand($this->paidAtSecondsAgo(420), self::NOW));
|
||||
}
|
||||
|
||||
public function testSlaBandLateBeyondTenMinutes(): void
|
||||
{
|
||||
$repo = new OrderQueryRepository(new FakeKdsDatabase());
|
||||
// 12 min ecoulees -> >= 600 s (seuil cible) -> rouge.
|
||||
self::assertSame('late', $repo->slaBand($this->paidAtSecondsAgo(720), self::NOW));
|
||||
}
|
||||
|
||||
public function testSlaBandFallsBackToFreshOnEmptyPaidAt(): void
|
||||
{
|
||||
$repo = new OrderQueryRepository(new FakeKdsDatabase());
|
||||
// Donnee absente : pas d'alerte sur une valeur manquante.
|
||||
self::assertSame('fresh', $repo->slaBand('', self::NOW));
|
||||
}
|
||||
|
||||
public function testPaidQueueWithDetailNestsItemsSelectionsAndModifiers(): void
|
||||
{
|
||||
$db = new FakeKdsDatabase();
|
||||
$db->orders = [
|
||||
['id' => 7, 'order_number' => 'K7', 'source' => 'kiosk', 'service_mode' => 'dine_in', 'service_tag' => '3', 'total_ttc_cents' => 990, 'paid_at' => $this->paidAtSecondsAgo(120)],
|
||||
];
|
||||
$db->items = [
|
||||
['id' => 50, 'order_id' => 7, 'item_type' => 'menu', 'format' => 'maxi', 'label_snapshot' => 'Menu Le 280', 'quantity' => 1],
|
||||
];
|
||||
$db->selections = [
|
||||
['order_item_id' => 50, 'label_snapshot' => 'Coca 50cl'],
|
||||
['order_item_id' => 50, 'label_snapshot' => 'Grande Frite'],
|
||||
];
|
||||
$db->modifiers = [
|
||||
['order_item_id' => 50, 'action' => 'remove', 'ingredient_name' => 'oignon'],
|
||||
['order_item_id' => 50, 'action' => 'add', 'ingredient_name' => 'bacon'],
|
||||
];
|
||||
|
||||
$queue = (new OrderQueryRepository($db))->paidQueueWithDetail(['kiosk', 'counter', 'drive'], self::NOW);
|
||||
|
||||
self::assertCount(1, $queue);
|
||||
$order = $queue[0];
|
||||
// L'id technique (cle de jointure) n'est pas expose a la vue.
|
||||
self::assertArrayNotHasKey('id', $order);
|
||||
self::assertSame('K7', $order['order_number']);
|
||||
self::assertSame('fresh', $order['sla_band']); // 2 min ecoulees.
|
||||
|
||||
self::assertCount(1, $order['items']);
|
||||
$item = $order['items'][0];
|
||||
self::assertSame('Menu Le 280', $item['label_snapshot']);
|
||||
self::assertSame('maxi', $item['format']);
|
||||
self::assertSame(['Coca 50cl', 'Grande Frite'], array_column($item['selections'], 'label_snapshot'));
|
||||
self::assertSame(['oignon', 'bacon'], array_column($item['modifiers'], 'ingredient_name'));
|
||||
self::assertSame(['remove', 'add'], array_column($item['modifiers'], 'action'));
|
||||
}
|
||||
|
||||
public function testPaidQueueWithDetailEmptyWhenNoVisibleSource(): void
|
||||
{
|
||||
// Aucune source visible -> file vide (coherent avec paidQueue).
|
||||
$queue = (new OrderQueryRepository(new FakeKdsDatabase()))->paidQueueWithDetail([], self::NOW);
|
||||
self::assertSame([], $queue);
|
||||
}
|
||||
|
||||
public function testPaidQueueWithDetailHandlesOrderWithoutItems(): void
|
||||
{
|
||||
// Commande sans ligne (cas degrade) : items vide, pas d'erreur.
|
||||
$db = new FakeKdsDatabase();
|
||||
$db->orders = [
|
||||
['id' => 9, 'order_number' => 'K9', 'source' => 'kiosk', 'service_mode' => 'takeaway', 'service_tag' => null, 'total_ttc_cents' => 500, 'paid_at' => $this->paidAtSecondsAgo(60)],
|
||||
];
|
||||
|
||||
$queue = (new OrderQueryRepository($db))->paidQueueWithDetail(['kiosk'], self::NOW);
|
||||
|
||||
self::assertCount(1, $queue);
|
||||
self::assertSame([], $queue[0]['items']);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue