feat(back-office): KDS cuisine - detail des commandes + bande SLA #108

Merged
Corentin merged 1 commit from fix/kds-content-sla into dev 2026-06-25 10:28:38 +02:00
7 changed files with 572 additions and 8 deletions
Showing only changes of commit d754dbe3a0 - Show all commits

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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