From 89488b20b23666da2c27de4e79db063dc1b118b8 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 25 Jun 2026 10:28:36 +0200 Subject: [PATCH] feat(back-office): KDS cuisine - detail des commandes + bande SLA (#108) --- src/app/Controllers/KitchenController.php | 5 +- src/app/Order/OrderQueryRepository.php | 175 ++++++++++++++++++ src/app/Views/admin/kitchen/display.php | 75 +++++++- src/public/admin/assets/css/admin.css | 42 +++++ .../OrderQueryRepositoryDbTest.php | 62 +++++++ tests/Unit/Admin/KitchenControllerTest.php | 58 +++++- tests/Unit/Order/OrderQueryRepositoryTest.php | 163 ++++++++++++++++ 7 files changed, 572 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/Order/OrderQueryRepositoryTest.php diff --git a/src/app/Controllers/KitchenController.php b/src/app/Controllers/KitchenController.php index df605b7..3615d17 100644 --- a/src/app/Controllers/KitchenController.php +++ b/src/app/Controllers/KitchenController.php @@ -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); } diff --git a/src/app/Order/OrderQueryRepository.php b/src/app/Order/OrderQueryRepository.php index e6bf285..79de4db 100644 --- a/src/app/Order/OrderQueryRepository.php +++ b/src/app/Order/OrderQueryRepository.php @@ -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 $sources + * @param int|null $now epoch de reference pour la bande SLA ; null => time() + * @return list> + */ + 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 $orderIds + * @return array>> 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> $rows + * @return array>> + */ + 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 diff --git a/src/app/Views/admin/kitchen/display.php b/src/app/Views/admin/kitchen/display.php index 0b1363c..257f056 100644 --- a/src/app/Views/admin/kitchen/display.php +++ b/src/app/Views/admin/kitchen/display.php @@ -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> $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 : "x