Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions plugin/src/Guide.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
96 changes: 96 additions & 0 deletions plugin/src/Reports.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace DanMat\Restaurant;

use Nimbus\Plugin\PluginStorage;

/**
* Reports — read-only aggregation over orders, for the manager dashboard. Revenue
* is summed from each order's recorded `amount_paid` (the authoritative settled
* amount), never recomputed or client-supplied. Every method takes explicit date
* bounds (a half-open `[from, to)` range) so the caller owns "today"/"this week" and
* the queries stay deterministic and testable. Bound SQL throughout.
*/
final class Reports
{
/** @param \Closure():PluginStorage $storage resolved lazily, so construction runs no query */
public function __construct(private \Closure $storage)
{
}

/**
* Revenue and paid-order count for paid orders settled in `[from, to)`.
*
* @return array{revenue:string,orders:int}
*/
public function revenueBetween(string $from, string $to): array
{
$row = $this->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<array{day:string,revenue:string,orders:int}>
*/
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<array{name:string,qty:int,revenue:string}>
*/
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)();
}
}
109 changes: 109 additions & 0 deletions plugin/src/ReportsAdmin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

declare(strict_types=1);

namespace DanMat\Restaurant;

/**
* The manager dashboard — today's and this week's revenue, active orders, a 7-day
* revenue breakdown, and the week's best sellers. Read-only (no forms, no actions),
* gated on `danmat.restaurant:manage`. It computes the live date windows and asks
* {@see Reports} for the figures. Author values (item names) are escaped on output.
*/
final class ReportsAdmin
{
public function __construct(private Reports $reports)
{
}

public function render(string $csrf = '', ?string $notice = null, string $nonce = '', ?string $now = null): string
{
$ref = $now !== null ? strtotime($now) : time();
$ref = $ref === false ? time() : $ref;
$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);

$today = $this->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)
. '<div class="nb-page-head"><h1>Reports</h1></div>'
. '<p class="nb-muted rz-intro">How service is going — revenue and what is selling. Figures are from settled (paid) orders.</p>'
. '<div class="rz-cards">'
. $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')
. '</div>'
. $this->byDay($byDay)
. $this->topItems($top);
}

private function card(string $label, string $big, string $sub): string
{
return '<div class="rz-card"><div class="rz-card-label">' . self::e($label) . '</div>'
. '<div class="rz-card-big">' . self::e($big) . '</div>'
. '<div class="rz-card-sub">' . self::e($sub) . '</div></div>';
}

/** @param list<array{day:string,revenue:string,orders:int}> $byDay */
private function byDay(array $byDay): string
{
if ($byDay === []) {
return '<h2>Revenue by day</h2><p class="nb-muted">No paid orders in the last 7 days.</p>';
}
$rows = '';
foreach ($byDay as $d) {
$rows .= '<tr><td data-label="Day">' . self::e($d['day']) . '</td>'
. '<td data-label="Orders">' . self::e((string) $d['orders']) . '</td>'
. '<td data-label="Revenue">' . self::e($d['revenue']) . '</td></tr>';
}
return '<h2>Revenue by day</h2><table class="rz-table"><thead><tr><th>Day</th><th>Orders</th><th>Revenue</th></tr></thead><tbody>' . $rows . '</tbody></table>';
}

/** @param list<array{name:string,qty:int,revenue:string}> $top */
private function topItems(array $top): string
{
if ($top === []) {
return '<h2>Top items (7 days)</h2><p class="nb-muted">Nothing sold yet.</p>';
}
$rows = '';
foreach ($top as $t) {
$rows .= '<tr><td data-label="Item">' . self::e($t['name']) . '</td>'
. '<td data-label="Sold">' . self::e((string) $t['qty']) . '</td>'
. '<td data-label="Revenue">' . self::e($t['revenue']) . '</td></tr>';
}
return '<h2>Top items (7 days)</h2><table class="rz-table"><thead><tr><th>Item</th><th>Sold</th><th>Revenue</th></tr></thead><tbody>' . $rows . '</tbody></table>';
}

private function styles(string $nonce): string
{
return '<style nonce="' . self::e($nonce) . '">'
. '.rz-intro{max-width:60ch}'
. '.rz-cards{display:flex;gap:1rem;flex-wrap:wrap;margin:0 0 1.5rem}'
. '.rz-card{flex:1 1 12rem;border:1px solid rgba(128,128,128,.2);border-radius:10px;padding:.9rem 1rem}'
. '.rz-card-label{font-size:.8rem;font-weight:700;opacity:.7;text-transform:uppercase;letter-spacing:.03em}'
. '.rz-card-big{font-size:1.8rem;font-weight:800;font-variant-numeric:tabular-nums;margin:.2rem 0}'
. '.rz-card-sub{font-size:.8rem;opacity:.7}'
. '.rz-table{width:100%;border-collapse:collapse;margin:.5rem 0 1.5rem;max-width:36rem}'
. '.rz-table th,.rz-table td{text-align:left;padding:.5rem .5rem;border-bottom:1px solid rgba(128,128,128,.2)}'
. '.rz-table td:last-child,.rz-table th:last-child{text-align:right;font-variant-numeric:tabular-nums}'
. '@media (max-width:36rem){'
. '.rz-table,.rz-table tbody,.rz-table tr,.rz-table td{display:block}'
. '.rz-table thead{display:none}'
. '.rz-table tr{border:1px solid rgba(128,128,128,.25);border-radius:8px;margin:0 0 .5rem;padding:.3rem .6rem}'
. '.rz-table td{border:0;padding:.25rem 0;display:flex;justify-content:space-between}'
. '.rz-table td:last-child{text-align:right}'
. '.rz-table td[data-label]:before{content:attr(data-label);font-weight:700;font-size:.8rem;opacity:.7}'
. '}'
. '</style>';
}

private static function e(string $v): string
{
return htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
}
}
14 changes: 12 additions & 2 deletions plugin/src/RestaurantPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
Expand Down
25 changes: 25 additions & 0 deletions plugin/src/RestaurantToolset.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public function __construct(
private Orders $orders,
private MenuSource $menu,
private Reservations $reservations,
private Reports $reports,
) {
}

Expand Down Expand Up @@ -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<string,mixed> $a
* @return array<string,mixed>
*/
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),
];
}

Expand Down
75 changes: 75 additions & 0 deletions plugin/tests/ReportsAdminTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace DanMat\Restaurant\Tests;

use DanMat\Restaurant\Orders;
use DanMat\Restaurant\Reports;
use DanMat\Restaurant\ReportsAdmin;
use DanMat\Restaurant\Schema;
use DanMat\Restaurant\Tables;
use Nimbus\Database\Connection;
use Nimbus\Plugin\PluginStorage;
use PHPUnit\Framework\TestCase;

/**
* The manager dashboard renders aggregated figures (read-only) and item names
* (author input), which must be escaped. A fixed reference time makes the windows
* deterministic.
*/
final class ReportsAdminTest extends TestCase
{
private Orders $orders;
private Tables $tables;
private ReportsAdmin $admin;

protected function setUp(): void
{
$db = new Connection([
'host' => 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', '<b>Special</b>', '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('<b>Special</b>', $html, 'a hostile item name is escaped');
self::assertStringContainsString('&lt;b&gt;Special&lt;/b&gt;', $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);
}
}
Loading
Loading