From 52acdd914e264e6152f9b4a744124d313dd45e98 Mon Sep 17 00:00:00 2001
From: DanMat
Date: Sun, 30 Aug 2026 14:14:39 -0400
Subject: [PATCH] =?UTF-8?q?feat:=20Workbench=20Phase=201=20=E2=80=94=20gat?=
=?UTF-8?q?e=20the=20admin=20page,=20add=20count/transfer,=20datalists,=20?=
=?UTF-8?q?filter,=20honest=20errors?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Now that core can gate a plugin admin page on the plugin's own capability
(ADR 0020), the Inventory page and all four form actions require
`nimbuscms.inventory:write` — parity with the MCP tools, so a content-only
editor can no longer move stock from the UI.
- Count + Transfer forms (Ledger::count/transfer already existed; only the UI
and the H3 actions were missing).
- SKU + location suggestions sourced from this plugin's own tables
(a typo-guard; a genuinely new SKU can still be typed — Inventory owns no
catalog).
- A bound SKU substring filter (?q=) on the stock table — no string-built SQL,
the term echoed back escaped (no reflected XSS).
- Honest notices: InvalidArgumentException -> "badqty", InsufficientStock ->
"short", same-location transfer -> "samelocation", instead of collapsing
every failure into one generic message.
Tests: InventoryAdminTest (render — datalists, filter narrowing, term
escaping/SQLi-safety, notice mapping) and InventoryAdminActionsTest (the H3
actions through the real loader — proving the page gates on the plugin cap and
each action maps failures honestly). 43 tests green; PHPStan + php-cs-fixer clean.
Co-Authored-By: Claude Opus 4.8
---
src/InventoryAdmin.php | 134 +++++++++++++++++++++------
src/InventoryPlugin.php | 57 +++++++++++-
tests/InventoryAdminActionsTest.php | 138 ++++++++++++++++++++++++++++
tests/InventoryAdminTest.php | 97 +++++++++++++++++++
4 files changed, 393 insertions(+), 33 deletions(-)
create mode 100644 tests/InventoryAdminActionsTest.php
create mode 100644 tests/InventoryAdminTest.php
diff --git a/src/InventoryAdmin.php b/src/InventoryAdmin.php
index d2d8244..1d386cd 100644
--- a/src/InventoryAdmin.php
+++ b/src/InventoryAdmin.php
@@ -7,21 +7,27 @@
use Nimbus\Plugin\PluginStorage;
/**
- * The Inventory admin page — a read-only overview of stock (on-hand, reserved,
- * available per SKU and location) and the most recent movements. Registered as a
- * GET-only plugin admin page; changes are made through the MCP tools (or an agent),
- * so this is a window, not an editor.
+ * The Inventory admin page — an overview of stock (on-hand, reserved, available
+ * per SKU and location) and the most recent movements, plus the four ledger
+ * actions as forms (receive, adjust, count, transfer). The same operations an
+ * agent drives over MCP; this is the human hand on the same ledger.
*
- * SKU codes and locations can originate from tool callers, so every value is
- * escaped before it reaches the page.
+ * SKU codes, locations and the filter term can originate from callers, so every
+ * value is escaped before it reaches the page. The SKU/location inputs offer a
+ * `` of what already exists (a typo-guard, not a hard gate — receiving a
+ * genuinely new SKU is still allowed by typing it).
*/
final class InventoryAdmin
{
private const NOTICES = [
- 'received' => ['ok', 'Stock received.'],
- 'adjusted' => ['ok', 'Stock adjusted.'],
- 'invalid' => ['err', 'Check the SKU and quantity and try again.'],
- 'short' => ['err', 'Not enough available for that adjustment.'],
+ 'received' => ['ok', 'Stock received.'],
+ 'adjusted' => ['ok', 'Stock adjusted.'],
+ 'counted' => ['ok', 'Stock count recorded.'],
+ 'transferred' => ['ok', 'Stock transferred.'],
+ 'short' => ['err', 'Not enough available for that movement.'],
+ 'badqty' => ['err', 'Enter a valid quantity — a number with up to 4 decimal places (adjustments may be negative).'],
+ 'samelocation' => ['err', 'Choose two different locations to transfer between.'],
+ 'invalid' => ['err', 'Check the SKU and quantity and try again.'],
];
/** @param \Closure():PluginStorage $storage */
@@ -32,10 +38,12 @@ public function __construct(private \Closure $storage)
/**
* @param string $csrf the CSRF token for the forms (passed by core to the page handler)
* @param ?string $notice a fixed notice code (from the ?ok=/?err= redirect), mapped to a message
+ * @param ?string $q a SKU filter substring (from ?q=), applied to the stock table
*/
- public function render(string $csrf = '', ?string $notice = null): string
+ public function render(string $csrf = '', ?string $notice = null, ?string $q = null): string
{
$s = ($this->storage)();
+ $q = $q !== null ? trim($q) : '';
$banner = '';
if ($notice !== null && isset(self::NOTICES[$notice])) {
@@ -44,16 +52,26 @@ public function render(string $csrf = '', ?string $notice = null): string
}
$locations = [];
- foreach ($s->select('SELECT id, code FROM ' . Schema::LOCATION) as $l) {
+ foreach ($s->select('SELECT id, code FROM ' . Schema::LOCATION . ' ORDER BY code') as $l) {
$locations[(int) $l['id']] = (string) $l['code'];
}
+ /** @var list $skus distinct SKUs already stocked — the datalist suggestions */
+ $skus = array_map(
+ static fn (array $r): string => (string) $r['sku_code'],
+ $s->select('SELECT DISTINCT sku_code FROM ' . Schema::STOCK . ' ORDER BY sku_code'),
+ );
- $stock = $s->select(
+ // Stock, optionally filtered to SKUs containing the term (bound LIKE — no
+ // string-built SQL). available = on_hand − reserved.
+ $where = $q === '' ? '' : ' WHERE s.sku_code LIKE :q';
+ $params = $q === '' ? [] : ['q' => '%' . $q . '%'];
+ $stock = $s->select(
'SELECT s.sku_code, s.location_id, s.on_hand, s.uom,
COALESCE((SELECT SUM(qty) FROM ' . Schema::RESERVATION . ' r
WHERE r.sku_code = s.sku_code AND r.location_id = s.location_id), 0) AS reserved
- FROM ' . Schema::STOCK . ' s
+ FROM ' . Schema::STOCK . ' s' . $where . '
ORDER BY s.sku_code, s.location_id',
+ $params,
);
$movements = $s->select(
@@ -63,11 +81,15 @@ public function render(string $csrf = '', ?string $notice = null): string
$html = '
Inventory
' . $banner
. 'Stock as an append-only ledger — on-hand, reserved and available per location. '
- . 'Receive or adjust below, or drive it over MCP (an agent can also count and transfer).
'
+ . 'Receive, adjust, count or transfer below, or drive it over MCP.'
+ . $this->datalists($skus, array_values($locations))
. $this->forms($csrf);
+ $html .= $this->filterForm($q);
if ($stock === []) {
- $html .= 'No stock yet. Receive some with the inventory_receive tool.
';
+ $html .= $q === ''
+ ? 'No stock yet. Receive some above, or with the inventory_receive tool.
'
+ : 'No stock matches “' . $this->e($q) . '”.
';
} else {
$html .= ''
. 'SKU Location On hand '
@@ -107,36 +129,88 @@ public function render(string $csrf = '', ?string $notice = null): string
return $html;
}
- /** The receive + adjust forms. Each posts to its plugin admin action with the CSRF token. */
+ /**
+ * The known-SKU and known-location suggestion lists, referenced by the form
+ * inputs. Suggestions only — a new SKU/location can still be typed.
+ *
+ * @param list $skus
+ * @param list $locations
+ */
+ private function datalists(array $skus, array $locations): string
+ {
+ $opts = static function (array $values, callable $e): string {
+ $out = '';
+ foreach ($values as $v) {
+ $out .= ' ';
+ }
+ return $out;
+ };
+
+ return '' . $opts($skus, [$this, 'e']) . ' '
+ . '' . $opts($locations, [$this, 'e']) . ' ';
+ }
+
+ /** The four ledger forms (receive, adjust, count, transfer). Each posts to its action with the CSRF token. */
private function forms(string $csrf): string
{
$t = ' ';
return ''
- . '
'
- . '
'
+
+ . '
'
+
+ . '
'
. '
';
}
- private function field(string $label, string $name, string $type, string $placeholder): string
+ /** A SKU substring filter for the stock table (GET, no JS). */
+ private function filterForm(string $q): string
{
+ return '';
+ }
+
+ private function field(string $label, string $name, string $placeholder, ?string $list = null): string
+ {
+ $listAttr = $list === null ? '' : ' list="' . $this->e($list) . '"';
+
return '' . $this->e($label) . ' '
- . '
';
+ . ' ';
}
- private function e(string $v): string
+ public function e(string $v): string
{
return htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
}
diff --git a/src/InventoryPlugin.php b/src/InventoryPlugin.php
index 041db5a..f1157ab 100644
--- a/src/InventoryPlugin.php
+++ b/src/InventoryPlugin.php
@@ -52,13 +52,17 @@ public function register(PluginContext $context): void
// stock synchronously without touching Inventory's tables (ADR 0019).
$context->services()->provide(ReservationPort::class, new ReservationAdapter($ledger, $reservations));
- // Admin page: an overview plus receive/adjust forms (H3). The page handler
- // gets a CSRF token (3rd arg) for the forms and shows the redirect notice.
+ // Admin page: an overview plus the receive/adjust/count/transfer forms (H3).
+ // Gated on this plugin's own wildcard-immune capability (ADR 0020) — moving
+ // stock in the UI needs `nimbuscms.inventory:write`, exactly like the MCP
+ // tools, so a content-only editor can't. The handler gets a CSRF token (3rd
+ // arg) for the forms and shows the redirect notice.
$context->adminPages()->register(
'inventory',
'Inventory',
'📦',
- static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new InventoryAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err')),
+ static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new InventoryAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('q')),
+ self::ID . ':write',
);
$context->adminPages()->action('inventory', 'receive', static function (Request $r) use ($ledger): Response {
$sku = trim((string) ($r->input('sku') ?? ''));
@@ -72,6 +76,8 @@ public function register(PluginContext $context): void
$now = date('Y-m-d H:i:s');
$ledger->receive($sku, $ledger->ensureLocation($loc, $loc, $now), $qty, $uom, 'admin-ui', $now);
return Response::redirect('/admin/inventory?ok=received');
+ } catch (\InvalidArgumentException) {
+ return Response::redirect('/admin/inventory?err=badqty');
} catch (\Throwable) {
return Response::redirect('/admin/inventory?err=invalid');
}
@@ -91,6 +97,51 @@ public function register(PluginContext $context): void
return Response::redirect('/admin/inventory?ok=adjusted');
} catch (InsufficientStock) {
return Response::redirect('/admin/inventory?err=short');
+ } catch (\InvalidArgumentException) {
+ return Response::redirect('/admin/inventory?err=badqty');
+ } catch (\Throwable) {
+ return Response::redirect('/admin/inventory?err=invalid');
+ }
+ });
+ $context->adminPages()->action('inventory', 'count', static function (Request $r) use ($ledger): Response {
+ $sku = trim((string) ($r->input('sku') ?? ''));
+ $counted = trim((string) ($r->input('qty') ?? ''));
+ if ($sku === '' || $counted === '') {
+ return Response::redirect('/admin/inventory?err=invalid');
+ }
+ $loc = trim((string) ($r->input('location') ?? '')) ?: 'main';
+ try {
+ $now = date('Y-m-d H:i:s');
+ $locId = $ledger->ensureLocation($loc, $loc, $now);
+ $ledger->count($sku, $locId, $counted, $ledger->uomFor($sku, $locId) ?? 'each', 'admin-ui', $now);
+ return Response::redirect('/admin/inventory?ok=counted');
+ } catch (\InvalidArgumentException) {
+ return Response::redirect('/admin/inventory?err=badqty');
+ } catch (\Throwable) {
+ return Response::redirect('/admin/inventory?err=invalid');
+ }
+ });
+ $context->adminPages()->action('inventory', 'transfer', static function (Request $r) use ($ledger): Response {
+ $sku = trim((string) ($r->input('sku') ?? ''));
+ $qty = trim((string) ($r->input('qty') ?? ''));
+ $from = trim((string) ($r->input('from') ?? ''));
+ $to = trim((string) ($r->input('to') ?? ''));
+ if ($sku === '' || $qty === '' || $from === '' || $to === '') {
+ return Response::redirect('/admin/inventory?err=invalid');
+ }
+ if ($from === $to) {
+ return Response::redirect('/admin/inventory?err=samelocation');
+ }
+ try {
+ $now = date('Y-m-d H:i:s');
+ $fromId = $ledger->ensureLocation($from, $from, $now);
+ $toId = $ledger->ensureLocation($to, $to, $now);
+ $ledger->transfer($sku, $fromId, $toId, $qty, $ledger->uomFor($sku, $fromId) ?? 'each', 'admin-ui', $now);
+ return Response::redirect('/admin/inventory?ok=transferred');
+ } catch (InsufficientStock) {
+ return Response::redirect('/admin/inventory?err=short');
+ } catch (\InvalidArgumentException) {
+ return Response::redirect('/admin/inventory?err=badqty');
} catch (\Throwable) {
return Response::redirect('/admin/inventory?err=invalid');
}
diff --git a/tests/InventoryAdminActionsTest.php b/tests/InventoryAdminActionsTest.php
new file mode 100644
index 0000000..7eb43db
--- /dev/null
+++ b/tests/InventoryAdminActionsTest.php
@@ -0,0 +1,138 @@
+ */
+ private array $actions;
+
+ protected function setUp(): void
+ {
+ $this->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::all(), ...Schema::reservations()] as $sql) {
+ $this->db->execute($sql);
+ }
+ foreach ([Schema::MOVEMENT, Schema::STOCK, Schema::LOCATION, Schema::RESERVATION] as $t) {
+ $this->db->execute('TRUNCATE ' . $t);
+ }
+
+ // Load the package the way Nimbus does, capturing the admin actions.
+ $manifest = json_decode((string) file_get_contents(__DIR__ . '/../composer.json'), true);
+ $this->installedJson = (string) tempnam(sys_get_temp_dir(), 'nb-installed-');
+ file_put_contents($this->installedJson, json_encode([
+ 'packages' => [['name' => $manifest['name'], 'type' => $manifest['type'], 'extra' => $manifest['extra']]],
+ ], JSON_THROW_ON_ERROR));
+
+ $adminPages = new AdminPageRegistry();
+ $diagnostics = (new PluginLoader($this->installedJson))->load(new PluginCapabilities(
+ adminPages: $adminPages,
+ db: $this->db,
+ ));
+ self::assertSame([], $diagnostics, 'the plugin (page gated on nimbuscms.inventory:write) loads cleanly on this core');
+
+ $this->actions = [];
+ foreach ($adminPages->actions() as $a) {
+ $this->actions[$a['action']] = $a['handler'];
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ @unlink($this->installedJson);
+ }
+
+ /** @param array $input */
+ private function post(string $action, array $input): Response
+ {
+ return ($this->actions[$action])(new Request('POST', '/admin/inventory/' . $action, [], $input, [], []));
+ }
+
+ private function onHand(string $sku, string $location): string
+ {
+ $ledger = new Ledger(fn (): \Nimbus\Plugin\PluginStorage => new \Nimbus\Plugin\PluginStorage($this->db));
+ $loc = $ledger->ensureLocation($location, $location, '2026-01-01 00:00:00');
+ return $ledger->onHand($sku, $loc);
+ }
+
+ public function test_receive_lands_stock_and_redirects_ok(): void
+ {
+ $r = $this->post('receive', ['sku' => 'house-blend', 'location' => 'main', 'qty' => '12', 'uom' => 'each']);
+
+ self::assertSame(302, $r->status);
+ self::assertSame('/admin/inventory?ok=received', $r->header('Location'));
+ self::assertSame('12.0000', $this->onHand('house-blend', 'main'));
+ }
+
+ public function test_receive_a_bad_quantity_is_an_honest_badqty_notice(): void
+ {
+ $r = $this->post('receive', ['sku' => 'house-blend', 'location' => 'main', 'qty' => 'not-a-number']);
+
+ self::assertSame('/admin/inventory?err=badqty', $r->header('Location'));
+ }
+
+ public function test_a_missing_field_is_invalid(): void
+ {
+ $r = $this->post('receive', ['sku' => '', 'qty' => '5']);
+ self::assertSame('/admin/inventory?err=invalid', $r->header('Location'));
+ }
+
+ public function test_adjust_below_zero_is_short(): void
+ {
+ $this->post('receive', ['sku' => 'oat-milk', 'location' => 'main', 'qty' => '3']);
+ $r = $this->post('adjust', ['sku' => 'oat-milk', 'location' => 'main', 'qty' => '-9', 'reason' => 'waste']);
+
+ self::assertSame('/admin/inventory?err=short', $r->header('Location'));
+ }
+
+ public function test_count_sets_on_hand_and_redirects_ok(): void
+ {
+ $this->post('receive', ['sku' => 'cups', 'location' => 'main', 'qty' => '10']);
+ $r = $this->post('count', ['sku' => 'cups', 'location' => 'main', 'qty' => '7']);
+
+ self::assertSame('/admin/inventory?ok=counted', $r->header('Location'));
+ self::assertSame('7.0000', $this->onHand('cups', 'main'));
+ }
+
+ public function test_transfer_moves_stock_between_locations(): void
+ {
+ $this->post('receive', ['sku' => 'beans', 'location' => 'main', 'qty' => '10']);
+ $r = $this->post('transfer', ['sku' => 'beans', 'from' => 'main', 'to' => 'store', 'qty' => '4']);
+
+ self::assertSame('/admin/inventory?ok=transferred', $r->header('Location'));
+ self::assertSame('6.0000', $this->onHand('beans', 'main'));
+ self::assertSame('4.0000', $this->onHand('beans', 'store'));
+ }
+
+ public function test_transfer_to_the_same_location_is_refused(): void
+ {
+ $r = $this->post('transfer', ['sku' => 'beans', 'from' => 'main', 'to' => 'main', 'qty' => '1']);
+ self::assertSame('/admin/inventory?err=samelocation', $r->header('Location'));
+ }
+}
diff --git a/tests/InventoryAdminTest.php b/tests/InventoryAdminTest.php
new file mode 100644
index 0000000..f217a6a
--- /dev/null
+++ b/tests/InventoryAdminTest.php
@@ -0,0 +1,97 @@
+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::all(), ...Schema::reservations()] as $sql) {
+ $this->db->execute($sql);
+ }
+ foreach ([Schema::MOVEMENT, Schema::STOCK, Schema::LOCATION, Schema::RESERVATION] as $t) {
+ $this->db->execute('TRUNCATE ' . $t);
+ }
+
+ $storage = new PluginStorage($this->db);
+ $ledger = new Ledger(static fn (): PluginStorage => $storage);
+ $ledger->receive('house-blend', $ledger->ensureLocation('main', 'Main', self::T), '12', 'each', 'seed', self::T);
+ $ledger->receive('oat-milk', $ledger->ensureLocation('store', 'Store', self::T), '4', 'each', 'seed', self::T);
+
+ $this->admin = new InventoryAdmin(static fn (): PluginStorage => $storage);
+ }
+
+ public function test_the_datalists_list_the_known_skus_and_locations(): void
+ {
+ $html = $this->admin->render('tok');
+
+ self::assertStringContainsString('', $html);
+ self::assertStringContainsString('', $html);
+ self::assertStringContainsString(' ', $html);
+ self::assertStringContainsString('', $html);
+ self::assertStringContainsString('', $html);
+ self::assertStringContainsString(' ', $html);
+ }
+
+ public function test_the_four_ledger_forms_are_present(): void
+ {
+ $html = $this->admin->render('tok');
+
+ self::assertStringContainsString('action="/admin/inventory/receive"', $html);
+ self::assertStringContainsString('action="/admin/inventory/adjust"', $html);
+ self::assertStringContainsString('action="/admin/inventory/count"', $html);
+ self::assertStringContainsString('action="/admin/inventory/transfer"', $html);
+ }
+
+ public function test_the_filter_narrows_the_stock_table_to_matching_skus(): void
+ {
+ $html = $this->admin->render('tok', null, 'house');
+
+ // The Available cell (a ) is unique to the stock table (the datalist
+ // and the unfiltered movements table both still mention oat-milk).
+ self::assertStringContainsString('12.0000 ', $html, 'house-blend stock row shown');
+ self::assertStringNotContainsString('4.0000 ', $html, 'oat-milk stock row filtered out');
+ }
+
+ public function test_the_filter_term_is_escaped_and_never_injects(): void
+ {
+ // Reflected-XSS + SQLi guard: a hostile term is bound (no error, no rows)
+ // and echoed back escaped — never as live markup.
+ $html = $this->admin->render('tok', null, '">');
+
+ self::assertStringNotContainsString('', $html);
+ self::assertStringContainsString('<script>', $html);
+ }
+
+ public function test_a_notice_code_maps_to_a_message_and_unknown_shows_nothing(): void
+ {
+ self::assertStringContainsString('Stock transferred.', $this->admin->render('tok', 'transferred'));
+ self::assertStringContainsString('Choose two different locations', $this->admin->render('tok', 'samelocation'));
+ self::assertStringNotContainsString('nb-notice', $this->admin->render('tok', 'nonsense-code'));
+ }
+}