corentin_wakdo/src/app/Order/OrderQueryRepository.php
Corentin JOGUET 89488b20b2
All checks were successful
CI / secret-scan (push) Successful in 16s
CI / php-lint (push) Successful in 30s
CI / static-tests (push) Successful in 1m15s
CI / js-tests (push) Successful in 37s
feat(back-office): KDS cuisine - detail des commandes + bande SLA (#108)
2026-06-25 10:28:36 +02:00

306 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Order;
use App\Core\DatabaseInterface;
/**
* Lecture du domaine commande pour le back-office (P4) : liste des commandes
* recentes (order.read) + KPIs de vente du tableau de bord (stats.read).
*
* Separe de OrderRepository (ecriture : createPending / pay) pour garder le
* read-side leger -- il ne depend que de DatabaseInterface, pas des repos
* catalogue. Non `final` : seam de test (sous-classe -> 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<array<string, mixed>>
*/
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<string>
*/
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<string> $sources
* @return list<array<string, mixed>>
*/
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<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
* 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<string,int>}
*/
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,
];
}
}