diff --git a/plugin/src/Guide.php b/plugin/src/Guide.php index 9a95f14..8f24c62 100644 --- a/plugin/src/Guide.php +++ b/plugin/src/Guide.php @@ -107,6 +107,13 @@ public static function text(): string To see a linked guest's contact details, use the CRM's own tools with that `contact_id` (they require the CRM capability). + + ## Reports + + - `restaurant_reports` — a revenue summary from **paid** orders: `today` and + `last_7_days` (each `{revenue, orders}`), `active_orders` (not yet closed), + and `top_items` (the week's best sellers). Revenue is the settled amount, not + a live recomputation. MD; } } diff --git a/plugin/src/Reports.php b/plugin/src/Reports.php new file mode 100644 index 0000000..16d8dff --- /dev/null +++ b/plugin/src/Reports.php @@ -0,0 +1,96 @@ +storage()->selectOne( + 'SELECT COALESCE(SUM(amount_paid), 0) AS revenue, COUNT(*) AS orders + FROM ' . Schema::ORDER . ' WHERE paid = 1 AND paid_at >= :from AND paid_at < :to', + ['from' => $from, 'to' => $to], + ); + return [ + 'revenue' => number_format((float) ($row['revenue'] ?? 0), 2, '.', ''), + 'orders' => (int) ($row['orders'] ?? 0), + ]; + } + + /** + * Revenue per calendar day across `[from, to)`, oldest first. + * + * @return list + */ + public function revenueByDay(string $from, string $to): array + { + $rows = $this->storage()->select( + 'SELECT DATE(paid_at) AS day, COALESCE(SUM(amount_paid), 0) AS revenue, COUNT(*) AS orders + FROM ' . Schema::ORDER . ' WHERE paid = 1 AND paid_at >= :from AND paid_at < :to + GROUP BY DATE(paid_at) ORDER BY day', + ['from' => $from, 'to' => $to], + ); + return array_map(static fn (array $r): array => [ + 'day' => (string) $r['day'], + 'revenue' => number_format((float) $r['revenue'], 2, '.', ''), + 'orders' => (int) $r['orders'], + ], $rows); + } + + /** + * Best-selling items by quantity, over paid orders settled in `[from, to)`. + * + * @return list + */ + public function topItems(string $from, string $to, int $limit = 5): array + { + $limit = max(1, min($limit, 100)); + $rows = $this->storage()->select( + 'SELECT i.name, SUM(i.qty) AS qty, SUM(i.unit_price * i.qty) AS revenue + FROM ' . Schema::ORDER_ITEM . ' i JOIN ' . Schema::ORDER . ' o ON o.id = i.order_id + WHERE o.paid = 1 AND o.paid_at >= :from AND o.paid_at < :to + GROUP BY i.name ORDER BY qty DESC, revenue DESC LIMIT ' . $limit, + ['from' => $from, 'to' => $to], + ); + return array_map(static fn (array $r): array => [ + 'name' => (string) $r['name'], + 'qty' => (int) $r['qty'], + 'revenue' => number_format((float) $r['revenue'], 2, '.', ''), + ], $rows); + } + + /** Orders not yet closed (still on the floor / in the kitchen / awaiting payment). */ + public function activeOrders(): int + { + $row = $this->storage()->selectOne( + 'SELECT COUNT(*) AS c FROM ' . Schema::ORDER . " WHERE status <> 'closed'", + ); + return (int) ($row['c'] ?? 0); + } + + private function storage(): PluginStorage + { + return ($this->storage)(); + } +} diff --git a/plugin/src/ReportsAdmin.php b/plugin/src/ReportsAdmin.php new file mode 100644 index 0000000..fa88396 --- /dev/null +++ b/plugin/src/ReportsAdmin.php @@ -0,0 +1,109 @@ +reports->revenueBetween($todayStart, $tomorrow); + $week = $this->reports->revenueBetween($weekStart, $tomorrow); + $byDay = $this->reports->revenueByDay($weekStart, $tomorrow); + $top = $this->reports->topItems($weekStart, $tomorrow, 5); + $active = $this->reports->activeOrders(); + + return $this->styles($nonce) + . '

Reports

' + . '

How service is going — revenue and what is selling. Figures are from settled (paid) orders.

' + . '
' + . $this->card('Revenue today', $today['revenue'], $today['orders'] . ' paid') + . $this->card('Last 7 days', $week['revenue'], $week['orders'] . ' paid') + . $this->card('Active orders', (string) $active, 'open on the floor') + . '
' + . $this->byDay($byDay) + . $this->topItems($top); + } + + private function card(string $label, string $big, string $sub): string + { + return '
' . self::e($label) . '
' + . '
' . self::e($big) . '
' + . '
' . self::e($sub) . '
'; + } + + /** @param list $byDay */ + private function byDay(array $byDay): string + { + if ($byDay === []) { + return '

Revenue by day

No paid orders in the last 7 days.

'; + } + $rows = ''; + foreach ($byDay as $d) { + $rows .= '' . self::e($d['day']) . '' + . '' . self::e((string) $d['orders']) . '' + . '' . self::e($d['revenue']) . ''; + } + return '

Revenue by day

' . $rows . '
DayOrdersRevenue
'; + } + + /** @param list $top */ + private function topItems(array $top): string + { + if ($top === []) { + return '

Top items (7 days)

Nothing sold yet.

'; + } + $rows = ''; + foreach ($top as $t) { + $rows .= '' . self::e($t['name']) . '' + . '' . self::e((string) $t['qty']) . '' + . '' . self::e($t['revenue']) . ''; + } + return '

Top items (7 days)

' . $rows . '
ItemSoldRevenue
'; + } + + private function styles(string $nonce): string + { + return ''; + } + + private static function e(string $v): string + { + return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/plugin/src/RestaurantPlugin.php b/plugin/src/RestaurantPlugin.php index bb08017..85f6eda 100644 --- a/plugin/src/RestaurantPlugin.php +++ b/plugin/src/RestaurantPlugin.php @@ -23,7 +23,7 @@ * payment & turn. Slice 5: staff roles — the terminals are gated on fine-grained * actions (ADR 0030): floor staff reach tables/orders/payment, cooks the kitchen, * managers everything. Slice 6: reservations, which link to CRM guests without the - * restaurant ever reading CRM data. Reports follow. + * restaurant ever reading CRM data. Slice 7: the manager reports dashboard. */ final class RestaurantPlugin implements Plugin { @@ -52,9 +52,10 @@ public function register(PluginContext $context): void $menu = new Menu(static fn () => $context->content()); $orders = new Orders($storage, $tables, static fn (int $menuItemId): ?array => $menu->snapshot($menuItemId)); $reservations = new Reservations($storage, $tables); + $reports = new Reports($storage); // The agent surface — every tool gates on danmat.restaurant:read|write (ADR 0016). - $context->mcp()->register(new RestaurantToolset($tables, $orders, $menu, $reservations)); + $context->mcp()->register(new RestaurantToolset($tables, $orders, $menu, $reservations, $reports)); // The floor board. A staff terminal is a capability-gated ADMIN PAGE, never a // public plugin route (routes carry no auth/CSRF). Gated on :write; the handler @@ -282,6 +283,15 @@ public function register(PluginContext $context): void return Response::redirect('/admin/restaurant-reservations?ok=deleted'); }); + // Reports — the manager dashboard. Read-only, gated on the manage action. + $context->adminPages()->register( + 'restaurant-reports', + 'Reports', + '📈', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new ReportsAdmin($reports))->render($csrf, null, $nonce), + self::ID . ':manage', + ); + // Teach an MCP agent how to drive the restaurant (ADR 0013). $context->skills()->register('Restaurant', Guide::text()); } diff --git a/plugin/src/RestaurantToolset.php b/plugin/src/RestaurantToolset.php index 913bec5..193a707 100644 --- a/plugin/src/RestaurantToolset.php +++ b/plugin/src/RestaurantToolset.php @@ -30,6 +30,7 @@ public function __construct( private Orders $orders, private MenuSource $menu, private Reservations $reservations, + private Reports $reports, ) { } @@ -203,6 +204,30 @@ protected function tools(): array 'required' => ['id'], 'properties' => ['id' => ['type' => 'integer', 'description' => 'The reservation id.']], ], $this->reservationDelete(...)), + + new PluginTool('reports', 'read', 'A revenue summary: today and the last 7 days (from paid orders), active orders, and the week\'s best-selling items.', [ + 'type' => 'object', + 'properties' => new \stdClass(), + ], $this->reports(...)), + ]; + } + + /** + * @param array $a + * @return array + */ + private function reports(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $ref = time(); + $todayStart = date('Y-m-d 00:00:00', $ref); + $tomorrow = date('Y-m-d 00:00:00', $ref + 86400); + $weekStart = date('Y-m-d 00:00:00', $ref - 6 * 86400); + + return [ + 'today' => $this->reports->revenueBetween($todayStart, $tomorrow), + 'last_7_days' => $this->reports->revenueBetween($weekStart, $tomorrow), + 'active_orders' => $this->reports->activeOrders(), + 'top_items' => $this->reports->topItems($weekStart, $tomorrow, 5), ]; } diff --git a/plugin/tests/ReportsAdminTest.php b/plugin/tests/ReportsAdminTest.php new file mode 100644 index 0000000..55cc8cd --- /dev/null +++ b/plugin/tests/ReportsAdminTest.php @@ -0,0 +1,75 @@ + getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach ([...Schema::tables(), ...Schema::orders()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TABLE); + $db->execute('TRUNCATE ' . Schema::ORDER); + $db->execute('TRUNCATE ' . Schema::ORDER_ITEM); + + $storage = new PluginStorage($db); + $this->tables = new Tables(static fn (): PluginStorage => $storage); + $this->orders = new Orders(static fn (): PluginStorage => $storage, $this->tables, static fn (int $id): ?array => null); + $this->admin = new ReportsAdmin(new Reports(static fn (): PluginStorage => $storage)); + } + + private function paidOrder(string $label, string $item, string $price, int $qty, string $paidAt): void + { + $t = $this->tables->save(null, ['label' => $label], $paidAt); + $id = $this->orders->open($t, $paidAt); + $this->orders->addItem($id, null, $item, $price, $qty, $paidAt); + $this->orders->pay($id, 'card', $paidAt); + } + + public function test_it_shows_todays_revenue_and_escapes_item_names(): void + { + $this->paidOrder('1', 'Special', '10', 2, '2026-06-01 12:00:00'); + + $html = $this->admin->render('', null, 'n', '2026-06-01 20:00:00'); + + self::assertStringContainsString('Revenue today', $html); + self::assertStringContainsString('20.00', $html, 'the settled revenue shows'); + self::assertStringNotContainsString('Special', $html, 'a hostile item name is escaped'); + self::assertStringContainsString('<b>Special</b>', $html); + } + + public function test_an_empty_period_reads_cleanly(): void + { + $html = $this->admin->render('', null, 'n', '2026-06-01 20:00:00'); + self::assertStringContainsString('0.00', $html); + self::assertStringContainsString('No paid orders in the last 7 days.', $html); + } +} diff --git a/plugin/tests/ReportsTest.php b/plugin/tests/ReportsTest.php new file mode 100644 index 0000000..4617696 --- /dev/null +++ b/plugin/tests/ReportsTest.php @@ -0,0 +1,110 @@ + getenv('TEST_DB_HOST') ?: 'db', + 'port' => (int) (getenv('TEST_DB_PORT') ?: 3306), + 'name' => getenv('TEST_DB_NAME') ?: 'nimbus_test', + 'user' => getenv('TEST_DB_USER') ?: 'root', + 'pass' => ($p = getenv('TEST_DB_PASS')) !== false ? $p : 'root', + ]); + foreach ([...Schema::tables(), ...Schema::orders()] as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::TABLE); + $db->execute('TRUNCATE ' . Schema::ORDER); + $db->execute('TRUNCATE ' . Schema::ORDER_ITEM); + + $storage = new PluginStorage($db); + $this->tables = new Tables(static fn (): PluginStorage => $storage); + $this->orders = new Orders(static fn (): PluginStorage => $storage, $this->tables, static fn (int $id): ?array => null); + $this->reports = new Reports(static fn (): PluginStorage => $storage); + } + + /** Open a fresh order (own table), add one manual line, and pay it at $paidAt. */ + private function paidOrder(string $label, string $item, string $price, int $qty, string $paidAt): void + { + $t = $this->tables->save(null, ['label' => $label], $paidAt); + $id = $this->orders->open($t, $paidAt); + $this->orders->addItem($id, null, $item, $price, $qty, $paidAt); + $this->orders->pay($id, 'card', $paidAt); + } + + public function test_revenue_between_sums_paid_orders_in_the_window(): void + { + $this->paidOrder('1', 'Steak', '20', 2, '2026-06-01 12:00:00'); // 40 today + $this->paidOrder('2', 'Soup', '5', 1, '2026-06-01 19:00:00'); // 5 today + $this->paidOrder('3', 'Wine', '8', 1, '2026-05-31 20:00:00'); // 8 yesterday + + $today = $this->reports->revenueBetween('2026-06-01 00:00:00', '2026-06-02 00:00:00'); + self::assertSame('45.00', $today['revenue']); + self::assertSame(2, $today['orders']); + + $week = $this->reports->revenueBetween('2026-05-26 00:00:00', '2026-06-02 00:00:00'); + self::assertSame('53.00', $week['revenue'], 'includes yesterday'); + self::assertSame(3, $week['orders']); + } + + public function test_an_unpaid_order_is_not_revenue_but_is_active(): void + { + $t = $this->tables->save(null, ['label' => '9'], '2026-06-01 12:00:00'); + $this->orders->open($t, '2026-06-01 12:00:00'); // open, unpaid + + self::assertSame('0.00', $this->reports->revenueBetween('2026-06-01 00:00:00', '2026-06-02 00:00:00')['revenue']); + self::assertSame(1, $this->reports->activeOrders(), 'the open order is active'); + + $this->paidOrder('10', 'X', '5', 1, '2026-06-01 13:00:00'); + self::assertSame(1, $this->reports->activeOrders(), 'a paid (closed) order is not active'); + } + + public function test_revenue_by_day_groups_and_orders(): void + { + $this->paidOrder('1', 'A', '10', 1, '2026-06-01 12:00:00'); + $this->paidOrder('2', 'B', '10', 1, '2026-06-02 12:00:00'); + $this->paidOrder('3', 'C', '5', 1, '2026-06-02 18:00:00'); + + $byDay = $this->reports->revenueByDay('2026-06-01 00:00:00', '2026-06-03 00:00:00'); + self::assertSame('2026-06-01', $byDay[0]['day']); + self::assertSame('10.00', $byDay[0]['revenue']); + self::assertSame('2026-06-02', $byDay[1]['day']); + self::assertSame('15.00', $byDay[1]['revenue']); + self::assertSame(2, $byDay[1]['orders']); + } + + public function test_top_items_ranks_by_quantity(): void + { + $this->paidOrder('1', 'Fries', '4', 5, '2026-06-01 12:00:00'); + $this->paidOrder('2', 'Steak', '20', 2, '2026-06-01 13:00:00'); + $this->paidOrder('3', 'Fries', '4', 1, '2026-06-01 14:00:00'); + + $top = $this->reports->topItems('2026-06-01 00:00:00', '2026-06-02 00:00:00', 5); + self::assertSame('Fries', $top[0]['name']); + self::assertSame(6, $top[0]['qty']); + self::assertSame('24.00', $top[0]['revenue']); + self::assertSame('Steak', $top[1]['name']); + } +} diff --git a/plugin/tests/RestaurantPluginTest.php b/plugin/tests/RestaurantPluginTest.php index 6af9a47..6b6b7e1 100644 --- a/plugin/tests/RestaurantPluginTest.php +++ b/plugin/tests/RestaurantPluginTest.php @@ -32,6 +32,7 @@ public function test_it_declares_the_fine_grained_staff_actions_as_grants(): voi self::assertArrayHasKey('danmat.restaurant:floor', $grantable); self::assertArrayHasKey('danmat.restaurant:kitchen', $grantable); self::assertArrayHasKey('danmat.restaurant:manage', $grantable); + self::assertSame('Restaurant: manage', $grantable['danmat.restaurant:manage']); // read/write remain for the MCP/agent surface. self::assertArrayHasKey('danmat.restaurant:read', $grantable); self::assertArrayHasKey('danmat.restaurant:write', $grantable); @@ -49,5 +50,6 @@ public function test_each_terminal_is_gated_on_the_right_action(): void self::assertSame('danmat.restaurant:floor', $gate['restaurant-orders'], 'orders + payment are floor-staff'); self::assertSame('danmat.restaurant:kitchen', $gate['restaurant-kitchen'], 'the kitchen is cooks only'); self::assertSame('danmat.restaurant:floor', $gate['restaurant-reservations'], 'the book is floor-staff'); + self::assertSame('danmat.restaurant:manage', $gate['restaurant-reports'], 'reports are manager-only'); } } diff --git a/plugin/tests/RestaurantToolsetTest.php b/plugin/tests/RestaurantToolsetTest.php index ef92d0b..87050f9 100644 --- a/plugin/tests/RestaurantToolsetTest.php +++ b/plugin/tests/RestaurantToolsetTest.php @@ -6,6 +6,7 @@ use DanMat\Restaurant\Menu; use DanMat\Restaurant\Orders; +use DanMat\Restaurant\Reports; use DanMat\Restaurant\Reservations; use DanMat\Restaurant\RestaurantToolset; use DanMat\Restaurant\Schema; @@ -50,11 +51,12 @@ protected function setUp(): void $tables = new Tables(static fn (): PluginStorage => $storage); $orders = new Orders(static fn (): PluginStorage => $storage, $tables, static fn (int $id): ?array => null); $reservations = new Reservations(static fn (): PluginStorage => $storage, $tables); + $reports = new Reports(static fn (): PluginStorage => $storage); // The menu reader is never exercised here (order lines are manual), so a // reader that would need core content is fine left unbuilt. $menu = new Menu(static fn () => throw new \RuntimeException('no content reader in this test')); - $this->toolset = new RestaurantToolset($tables, $orders, $menu, $reservations); + $this->toolset = new RestaurantToolset($tables, $orders, $menu, $reservations, $reports); $this->toolset->bindTo('danmat.restaurant'); $this->ctx = new EntryOpContext('127.0.0.1', '/api/v1/mcp'); @@ -80,6 +82,7 @@ public function test_the_tools_are_namespaced_and_split_read_from_write(): void 'restaurant_order_add_item', 'restaurant_order_set_item_qty', 'restaurant_order_remove_item', 'restaurant_order_pay', 'restaurant_order_delete', 'restaurant_kitchen', 'restaurant_reservations', 'restaurant_reservation_get', 'restaurant_reservation_set', 'restaurant_reservation_status', 'restaurant_reservation_delete', + 'restaurant_reports', ], $names); } @@ -88,7 +91,7 @@ public function test_a_read_only_token_sees_only_the_read_tools(): void $names = array_column($this->toolset->definitions($this->principal('danmat.restaurant:read')), 'name'); self::assertSame([ 'restaurant_tables', 'restaurant_table_get', 'restaurant_menu', 'restaurant_orders', 'restaurant_order_get', - 'restaurant_kitchen', 'restaurant_reservations', 'restaurant_reservation_get', + 'restaurant_kitchen', 'restaurant_reservations', 'restaurant_reservation_get', 'restaurant_reports', ], $names); }