diff --git a/src/app/Controllers/OrderAdminController.php b/src/app/Controllers/OrderAdminController.php new file mode 100644 index 0000000..699172b --- /dev/null +++ b/src/app/Controllers/OrderAdminController.php @@ -0,0 +1,40 @@ + $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('order.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/orders/index', [ + 'title' => 'Commandes - Wakdo Admin', + 'activeNav' => 'orders', + 'orders' => $this->orderQuery()->recent(50), + ], $guard); + } + + protected function orderQuery(): OrderQueryRepository + { + return new OrderQueryRepository($this->db()); + } +} diff --git a/src/app/Controllers/StatsController.php b/src/app/Controllers/StatsController.php index a3c3f0f..706bca1 100644 --- a/src/app/Controllers/StatsController.php +++ b/src/app/Controllers/StatsController.php @@ -6,6 +6,7 @@ namespace App\Controllers; use App\Catalogue\StatsRepository; use App\Core\Response; +use App\Order\OrderQueryRepository; /** * Tableau de bord statistiques (mlt domaine 11). GET /admin/stats, permission @@ -33,6 +34,7 @@ class StatsController extends AdminController 'activeNav' => 'stats', 'counts' => $this->statsRepository()->counts(), 'stock' => $this->statsRepository()->stockHealth(), + 'sales' => $this->orderQuery()->salesKpis(), ], $guard); } @@ -40,4 +42,9 @@ class StatsController extends AdminController { return new StatsRepository($this->db()); } + + protected function orderQuery(): OrderQueryRepository + { + return new OrderQueryRepository($this->db()); + } } diff --git a/src/app/Order/OrderQueryRepository.php b/src/app/Order/OrderQueryRepository.php new file mode 100644 index 0000000..405e882 --- /dev/null +++ b/src/app/Order/OrderQueryRepository.php @@ -0,0 +1,79 @@ + double sans base). + */ +class OrderQueryRepository +{ + 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, service_mode, service_tag, status, total_ttc_cents, created_at, paid_at ' + . 'FROM customer_order ORDER BY created_at DESC, id DESC LIMIT ' . $limit, + ); + } + + /** + * 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, + ]; + } +} diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index b52d52b..8a5e77c 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -114,10 +114,15 @@ $navClass = static function (string $code, string $current): string { - + @@ -135,8 +140,7 @@ $navClass = static function (string $code, string $current): string { diff --git a/src/app/Views/admin/orders/index.php b/src/app/Views/admin/orders/index.php new file mode 100644 index 0000000..7e8a06a --- /dev/null +++ b/src/app/Views/admin/orders/index.php @@ -0,0 +1,73 @@ +> $orders + */ + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR'; + +$modeLabel = static fn (string $m): string => match ($m) { + 'dine_in' => 'Sur place', + 'takeaway' => 'A emporter', + 'drive' => 'Drive', + default => $m, +}; + +$statusLabel = static fn (string $s): string => match ($s) { + 'pending_payment' => 'En attente', + 'paid' => 'Payee', + 'delivered' => 'Livree', + 'cancelled' => 'Annulee', + default => $s, +}; + +$statusPill = static fn (string $s): string => match ($s) { + 'paid', 'delivered' => 'pill-success', + 'cancelled' => 'pill-danger', + default => 'pill-warning', +}; + +/** @var list> $rows */ +$rows = isset($orders) && is_array($orders) ? $orders : []; +?> + +
+

Commandes

+

commande(s) recente(s)

+ + +

Aucune commande pour le moment.

+ + + + + + + + + + + + + + + + + + + + + + + + +
NumeroModeChevaletStatutTotalDate
+ +
diff --git a/src/app/Views/admin/stats/index.php b/src/app/Views/admin/stats/index.php index 1deede7..4d2b42c 100644 --- a/src/app/Views/admin/stats/index.php +++ b/src/app/Views/admin/stats/index.php @@ -42,7 +42,49 @@ $cards = [ + + $salesData */ +$salesData = isset($sales) && is_array($sales) ? $sales : []; +$byStatus = is_array($salesData['by_status'] ?? null) ? $salesData['by_status'] : []; +$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR'; +?> + + +
+
+
+
CA encaisse
+
aujourd'hui
+
+
+
+
Commandes payees
+
aujourd'hui
+
+
+
+
Panier moyen
+
par commande payee
+
+
+
+
Commandes totales
+
en attente
+
+
+ + diff --git a/src/public/admin/index.php b/src/public/admin/index.php index b3d3745..9eabba1 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -20,6 +20,7 @@ use App\Controllers\HomeController; use App\Controllers\IngredientController; use App\Controllers\MeController; use App\Controllers\MenuController; +use App\Controllers\OrderAdminController; use App\Controllers\OrderController; use App\Controllers\PasswordResetController; use App\Controllers\ProductController; @@ -105,6 +106,9 @@ try { // catalogue + sante stock (RG-T21) ; KPIs de vente avec les commandes (P4). $router->add('GET', '/admin/stats', [StatsController::class, 'index']); + // Commandes (P4, order.read) : liste lecture seule du domaine commande. + $router->add('GET', '/admin/orders', [OrderAdminController::class, 'index']); + // Gestion des comptes (mlt domaine 10). user.read (liste) ; user.create/update/ // deactivate. TOUTES les mutations = PIN equipier + audit (RG-T13/14). {id} = un // seul segment (pas de collision avec /edit, /deactivate, /reset-pin, /erase). diff --git a/tests/Integration/OrderQueryRepositoryDbTest.php b/tests/Integration/OrderQueryRepositoryDbTest.php new file mode 100644 index 0000000..e6a1b65 --- /dev/null +++ b/tests/Integration/OrderQueryRepositoryDbTest.php @@ -0,0 +1,103 @@ +) et verifie les KPIs de vente (delta vs baseline) + la liste + * recente. Nettoyage par prefixe en tearDown. + */ +final class OrderQueryRepositoryDbTest extends TestCase +{ + private Database $db; + private string $suffix = ''; + + protected function setUp(): void + { + if (getenv('WAKDO_DB_TESTS') !== '1') { + self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).'); + } + + $this->db = new Database(new Config()); + + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + + $this->suffix = bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + if ($this->suffix === '') { + return; + } + $this->db->execute( + 'DELETE FROM customer_order WHERE order_number LIKE :p', + ['p' => 'IT-' . $this->suffix . '%'], + ); + } + + private function insertOrder(string $number, string $status, int $ttc): void + { + $ht = (int) round($ttc / 1.1); + $this->db->execute( + "INSERT INTO customer_order (order_number, idempotency_key, source, service_mode, status, " + . 'total_ht_cents, total_vat_cents, total_ttc_cents) ' + . "VALUES (:num, :key, 'kiosk', 'takeaway', :st, :ht, :vat, :ttc)", + ['num' => $number, 'key' => $number . '-k', 'st' => $status, 'ht' => $ht, 'vat' => $ttc - $ht, 'ttc' => $ttc], + ); + } + + public function testSalesKpisCountsPaidRevenueOnly(): void + { + $repo = new OrderQueryRepository($this->db); + $before = $repo->salesKpis(); + + $this->insertOrder('IT-' . $this->suffix . '-P', 'paid', 1000); + $this->insertOrder('IT-' . $this->suffix . '-N', 'pending_payment', 500); + + $after = $repo->salesKpis(); + // Le CA ne compte QUE le paid (pas le pending_payment). + self::assertSame($before['revenue_cents'] + 1000, $after['revenue_cents']); + self::assertSame($before['paid_count'] + 1, $after['paid_count']); + self::assertSame($before['total_orders'] + 2, $after['total_orders']); + self::assertGreaterThanOrEqual(1, $after['by_status']['paid'] ?? 0); + self::assertGreaterThanOrEqual(1, $after['by_status']['pending_payment'] ?? 0); + self::assertSame(intdiv($after['revenue_cents'], max(1, $after['paid_count'])), $after['avg_basket_cents']); + } + + public function testRecentListsInsertedOrdersWithExpectedColumns(): void + { + $repo = new OrderQueryRepository($this->db); + $num = 'IT-' . $this->suffix . '-P'; + $this->insertOrder($num, 'paid', 1000); + + $recent = $repo->recent(200); + $numbers = array_column($recent, 'order_number'); + self::assertContains($num, $numbers, 'la commande inseree doit apparaitre dans recent()'); + + $row = $recent[(int) array_search($num, $numbers, true)]; + self::assertSame('paid', (string) $row['status']); + self::assertSame(1000, (int) $row['total_ttc_cents']); + self::assertSame('takeaway', (string) $row['service_mode']); + self::assertArrayHasKey('created_at', $row); + } + + public function testRecentRespectsLimit(): void + { + $repo = new OrderQueryRepository($this->db); + self::assertLessThanOrEqual(3, count($repo->recent(3))); + } +} diff --git a/tests/Unit/Admin/OrderAdminControllerTest.php b/tests/Unit/Admin/OrderAdminControllerTest.php new file mode 100644 index 0000000..3bc7007 --- /dev/null +++ b/tests/Unit/Admin/OrderAdminControllerTest.php @@ -0,0 +1,132 @@ + 'K42', 'service_mode' => 'dine_in', 'service_tag' => '261', 'status' => 'paid', 'total_ttc_cents' => 1990, 'created_at' => '2026-06-19 12:00:00', 'paid_at' => '2026-06-19 12:01:00'], + ['order_number' => 'K43', 'service_mode' => 'takeaway', 'service_tag' => null, 'status' => 'pending_payment', 'total_ttc_cents' => 800, 'created_at' => '2026-06-19 12:05:00', 'paid_at' => null], + ]; + } +} + +final class TestOrderAdminController extends OrderAdminController +{ + public function __construct( + Request $request, + Config $config, + Database $database, + private readonly SessionManager $testSession, + private readonly FakeDatabase $fakeDb, + ) { + parent::__construct($request, $config, $database); + } + + protected function sessionManager(): SessionManager + { + return $this->testSession; + } + + protected function db(): DatabaseInterface + { + return $this->fakeDb; + } + + protected function orderQuery(): OrderQueryRepository + { + return new StubRecentOrders($this->fakeDb); + } +} + +final class OrderAdminControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + private SessionManager $session; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 2); + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Manon', 'last_name' => 'G', 'role_label' => 'Manager']; + $db->canResult = true; + $db->permissionCodes = ['order.read']; + + return $db; + } + + private function controller(FakeDatabase $db): TestOrderAdminController + { + $request = new Request('GET', '/admin/orders', [], [], '', '203.0.113.5'); + + return new TestOrderAdminController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + public function testRequiresOrderRead(): void + { + $db = $this->permittedDb(); + $db->canResult = false; + + self::assertSame(403, $this->controller($db)->index()->status()); + } + + public function testRendersOrdersList(): void + { + $response = $this->controller($this->permittedDb())->index(); + + self::assertSame(200, $response->status()); + $body = $response->body(); + self::assertStringContainsString('Commandes', $body); + self::assertStringContainsString('K42', $body); + self::assertStringContainsString('Sur place', $body); // dine_in -> libelle + self::assertStringContainsString('261', $body); // chevalet + self::assertStringContainsString('19,90 EUR', $body); // total 1990c formate + self::assertStringContainsString('Payee', $body); // statut paid + self::assertStringContainsString('A emporter', $body); // takeaway -> libelle + } +} diff --git a/tests/Unit/Admin/StatsControllerTest.php b/tests/Unit/Admin/StatsControllerTest.php index 8c89af3..90a8f5e 100644 --- a/tests/Unit/Admin/StatsControllerTest.php +++ b/tests/Unit/Admin/StatsControllerTest.php @@ -12,6 +12,7 @@ use App\Core\Config; use App\Core\Database; use App\Core\DatabaseInterface; use App\Core\Request; +use App\Order\OrderQueryRepository; use App\Tests\Support\FakeDatabase; /** @@ -43,6 +44,27 @@ final class StubStatsRepository extends StatsRepository } } +/** + * Stub d'OrderQueryRepository : KPIs de vente canned (rendu du bloc Ventes teste + * sans base ; les agregats sont couverts par OrderQueryRepositoryDbTest). + */ +final class StubOrderQueryRepository extends OrderQueryRepository +{ + public function salesKpis(): array + { + return [ + 'revenue_cents' => 20800, 'paid_count' => 8, 'avg_basket_cents' => 2600, + 'revenue_today_cents' => 5200, 'paid_count_today' => 2, 'total_orders' => 11, + 'by_status' => ['paid' => 8, 'pending_payment' => 2, 'cancelled' => 1], + ]; + } + + public function recent(int $limit = 50): array + { + return []; + } +} + final class TestStatsController extends StatsController { public function __construct( @@ -69,6 +91,11 @@ final class TestStatsController extends StatsController { return new StubStatsRepository($this->fakeDb); } + + protected function orderQuery(): OrderQueryRepository + { + return new StubOrderQueryRepository($this->fakeDb); + } } final class StatsControllerTest extends TestCase @@ -140,5 +167,8 @@ final class StatsControllerTest extends TestCase self::assertStringContainsString('53', $body); // compteur produits self::assertStringContainsString('Cheddar', $body); // alerte stock critique self::assertStringContainsString('critical', $body); // bande + self::assertStringContainsString('Ventes', $body); // section KPIs vente + self::assertStringContainsString('CA encaisse', $body); + self::assertStringContainsString('208,00 EUR', $body); // revenue_cents 20800 formate } }