double sans base). */ 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) { } /** * Commandes les plus recentes (tous statuts confondus), pour la liste admin. * Triees de la plus recente a la plus ancienne. $limit borne [1, 200] et * interpole comme ENTIER (pas de bind : LIMIT n'accepte pas de parametre lie * avec ATTR_EMULATE_PREPARES=false). * * @return list> */ public function recent(int $limit = 50): array { $limit = max(1, min(200, $limit)); return $this->db->fetchAll( '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, ); } /** * Sources de commande visibles par un role (role_visible_source, dictionary 3.16). * Liste vide en base = vue globale (admin / manager voient tout) : on renvoie alors * les trois sources. Sert a filtrer la file de preparation par canal. * * @return list */ public function visibleSources(int $roleId): array { $rows = $this->db->fetchAll( 'SELECT source FROM role_visible_source WHERE role_id = :r', ['r' => $roleId], ); $sources = array_values(array_filter(array_map( static fn (array $r): string => (string) ($r['source'] ?? ''), $rows, ))); return $sources === [] ? ['kiosk', 'counter', 'drive'] : $sources; } /** * File de preparation (KDS) : commandes au statut `paid`, triees par paid_at * CROISSANT (la plus ancienne d'abord, RG-T12), filtrees par les sources visibles. * Les sources viennent d'une allowlist (role_visible_source) et sont liees comme * parametres. Liste de sources vide -> file vide (pas de canal visible). * * @param list $sources * @return list> */ public function paidQueue(array $sources): array { if ($sources === []) { return []; } $placeholders = []; $params = []; foreach (array_values($sources) as $i => $source) { $key = 's' . $i; $placeholders[] = ':' . $key; $params[$key] = $source; } return $this->db->fetchAll( 'SELECT 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, ); } /** * 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 * repartition par statut. Le CA exclut les commandes pending_payment (non * encaissees) et cancelled. * * @return array{revenue_cents:int, paid_count:int, avg_basket_cents:int, revenue_today_cents:int, paid_count_today:int, total_orders:int, by_status:array} */ public function salesKpis(): array { $t = $this->db->fetch( "SELECT COALESCE(SUM(CASE WHEN status IN ('paid','delivered') THEN total_ttc_cents ELSE 0 END), 0) AS revenue, COALESCE(SUM(status IN ('paid','delivered')), 0) AS paid_count, COALESCE(SUM(CASE WHEN status IN ('paid','delivered') AND created_at >= CURDATE() THEN total_ttc_cents ELSE 0 END), 0) AS revenue_today, COALESCE(SUM(status IN ('paid','delivered') AND created_at >= CURDATE()), 0) AS paid_count_today, COUNT(*) AS total_orders FROM customer_order", ) ?? []; $revenue = (int) ($t['revenue'] ?? 0); $paid = (int) ($t['paid_count'] ?? 0); $byStatus = []; foreach ($this->db->fetchAll('SELECT status, COUNT(*) AS n FROM customer_order GROUP BY status') as $r) { $byStatus[(string) ($r['status'] ?? '')] = (int) ($r['n'] ?? 0); } return [ 'revenue_cents' => $revenue, 'paid_count' => $paid, 'avg_basket_cents' => $paid > 0 ? intdiv($revenue, $paid) : 0, 'revenue_today_cents' => (int) ($t['revenue_today'] ?? 0), 'paid_count_today' => (int) ($t['paid_count_today'] ?? 0), 'total_orders' => (int) ($t['total_orders'] ?? 0), 'by_status' => $byStatus, ]; } }