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 .= '
' . '' @@ -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 '
' - . '
' + . '' . '

Receive stock

' . $t - . $this->field('SKU', 'sku', 'text', 'e.g. house-blend') - . $this->field('Location', 'location', 'text', 'main') - . $this->field('Quantity', 'qty', 'text', 'e.g. 12') - . $this->field('Unit', 'uom', 'text', 'each') + . $this->field('SKU', 'sku', 'e.g. house-blend', 'inv-skus') + . $this->field('Location', 'location', 'main', 'inv-locs') + . $this->field('Quantity', 'qty', 'e.g. 12') + . $this->field('Unit', 'uom', 'each') . '' - . '
' + + . '' . '

Adjust stock

' . $t - . $this->field('SKU', 'sku', 'text', 'e.g. house-blend') - . $this->field('Location', 'location', 'text', 'main') - . $this->field('Change (+/−)', 'qty', 'text', 'e.g. -3') - . $this->field('Reason', 'reason', 'text', 'waste') + . $this->field('SKU', 'sku', 'e.g. house-blend', 'inv-skus') + . $this->field('Location', 'location', 'main', 'inv-locs') + . $this->field('Change (+/−)', 'qty', 'e.g. -3') + . $this->field('Reason', 'reason', 'waste') . '' + + . '
' + . '

Count stock

' . $t + . '

Set on-hand to a counted figure; the correction is recorded as a movement.

' + . $this->field('SKU', 'sku', 'e.g. house-blend', 'inv-skus') + . $this->field('Location', 'location', 'main', 'inv-locs') + . $this->field('Counted', 'qty', 'e.g. 40') + . '' + + . '
' + . '

Transfer stock

' . $t + . $this->field('SKU', 'sku', 'e.g. house-blend', 'inv-skus') + . $this->field('From', 'from', 'main', 'inv-locs') + . $this->field('To', 'to', 'store', 'inv-locs') + . $this->field('Quantity', 'qty', 'e.g. 6') + . '' . '
'; } - 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 '
' + . '
' + . '
' + . '' + . ($q === '' ? '' : ' Clear') + . ''; + } + + private function field(string $label, string $name, string $placeholder, ?string $list = null): string + { + $listAttr = $list === null ? '' : ' list="' . $this->e($list) . '"'; + return '
' - . '
'; + . ''; } - 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('
SKULocationOn hand