From f2f9590cffcbc28333aece56129b5631d3b74f7d Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 30 Aug 2026 19:19:07 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20item=20master=20=E2=80=94=20a=20SKU's?= =?UTF-8?q?=20sellable=20item=20+=20category=20taxonomy=20(ADR=200022)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For retail (a grocery sells its inventory as-is), a SKU can now optionally carry a sellable *item* record — name, price, unit, description, image, active/featured flags — alongside its ledger stock, plus a two-level category taxonomy. Additive: a SKU can have stock with no item and vice versa, so a pure-ledger user is unaffected. Keyed by the opaque sku_code with no FK into core (image is a soft media-id ref resolved at render). - Schema: inventory_item + inventory_category (migration 003_items), superseding the "catalog lives in content" docblocks (ADR 0022). - Catalog service: item/category CRUD with the pinned controls — store raw / escape on render, non-negative decimal price, media-id soft ref, field allow-list (no over-posting), category parent-must-exist + two- level depth (cycles impossible), delete blocked while referenced. - MCP: inventory_item_set/get + category_set/get + categories, gated inventory:write/read (deletes stay admin-only — a recorded deferral). - Admin: a Catalog page (item + category management), inventory:write + CSRF, one nonce'd '; + } + + private function notice(?string $notice): string + { + if ($notice === null || !isset(self::NOTICES[$notice])) { + return ''; + } + [$kind, $msg] = self::NOTICES[$notice]; + return '
' . $this->e($msg) . '
'; + } + + public function e(string $v): string + { + return htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/src/CategoryInUse.php b/src/CategoryInUse.php new file mode 100644 index 0000000..17bdd19 --- /dev/null +++ b/src/CategoryInUse.php @@ -0,0 +1,18 @@ +migrations()->register('001_ledger', Schema::all()); $context->migrations()->register('002_reservations', Schema::reservations()); + $context->migrations()->register('003_items', Schema::items()); // Grantable, wildcard-immune: nimbuscms.inventory:read / :write. Moving // stock is moving money — a content *:write token can never reach it. @@ -45,8 +49,9 @@ public function register(PluginContext $context): void }; $ledger = new Ledger($storage, $emit); $reservations = new Reservations($storage, $ledger); + $catalog = new Catalog($storage); - $context->mcp()->register(new InventoryToolset($ledger, $reservations)); + $context->mcp()->register(new InventoryToolset($ledger, $reservations, $catalog)); // Publish the reservation contract so Commerce (or any plugin) can reserve // stock synchronously without touching Inventory's tables (ADR 0019). @@ -147,6 +152,79 @@ public function register(PluginContext $context): void } }); + // Catalog admin (ADR 0022): manage the sellable item behind each SKU and the + // category taxonomy. Its own page so merchandising stays separate from the + // stock workbench; same `nimbuscms.inventory:write` gate + CSRF as the ledger. + $context->adminPages()->register( + 'catalog', + 'Catalog', + '📇', + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new CatalogAdmin($catalog))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('edit'), $r->query('editcat'), $nonce), + self::ID . ':write', + ); + $context->adminPages()->action('catalog', 'item-save', static function (Request $r) use ($catalog): Response { + $sku = trim((string) ($r->input('sku') ?? '')); + if ($sku === '') { + return Response::redirect('/admin/catalog?err=invalid'); + } + // Build the field set from a known allow-list only — never the raw + // request — so no unexpected key (a forged flag/timestamp) is assigned. + $fields = [ + 'name' => (string) ($r->input('name') ?? ''), + 'price' => (string) ($r->input('price') ?? ''), + 'unit' => (string) ($r->input('unit') ?? ''), + 'description' => (string) ($r->input('description') ?? ''), + 'image_media_id' => (string) ($r->input('image_media_id') ?? ''), + 'category_id' => (string) ($r->input('category_id') ?? ''), + 'active' => $r->input('active') !== null, + 'featured' => $r->input('featured') !== null, + ]; + try { + $catalog->saveItem($sku, $fields, date('Y-m-d H:i:s')); + return Response::redirect('/admin/catalog?ok=item-saved'); + } catch (\InvalidArgumentException $e) { + return Response::redirect('/admin/catalog?err=' . (str_contains($e->getMessage(), 'price') ? 'badprice' : 'invalid')); + } catch (\Throwable) { + return Response::redirect('/admin/catalog?err=invalid'); + } + }); + $context->adminPages()->action('catalog', 'item-delete', static function (Request $r) use ($catalog): Response { + $sku = trim((string) ($r->input('sku') ?? '')); + if ($sku !== '') { + $catalog->deleteItem($sku); + } + return Response::redirect('/admin/catalog?ok=item-deleted'); + }); + $context->adminPages()->action('catalog', 'category-save', static function (Request $r) use ($catalog): Response { + $name = trim((string) ($r->input('name') ?? '')); + $idIn = trim((string) ($r->input('id') ?? '')); + $parIn = trim((string) ($r->input('parent_id') ?? '')); + $id = ($idIn !== '' && ctype_digit($idIn)) ? (int) $idIn : null; + $parent = ($parIn !== '' && ctype_digit($parIn)) ? (int) $parIn : null; + try { + $catalog->saveCategory($id, $name, $parent, date('Y-m-d H:i:s')); + return Response::redirect('/admin/catalog?ok=cat-saved'); + } catch (\InvalidArgumentException) { + return Response::redirect('/admin/catalog?err=badcat'); + } catch (\Throwable) { + return Response::redirect('/admin/catalog?err=invalid'); + } + }); + $context->adminPages()->action('catalog', 'category-delete', static function (Request $r) use ($catalog): Response { + $idIn = trim((string) ($r->input('id') ?? '')); + if ($idIn === '' || !ctype_digit($idIn)) { + return Response::redirect('/admin/catalog?err=invalid'); + } + try { + $catalog->deleteCategory((int) $idIn); + return Response::redirect('/admin/catalog?ok=cat-deleted'); + } catch (CategoryInUse) { + return Response::redirect('/admin/catalog?err=cat-inuse'); + } catch (\Throwable) { + return Response::redirect('/admin/catalog?err=invalid'); + } + }); + // Teach any MCP agent how to drive the ledger (ADR 0013). $context->skills()->register('Inventory', Guide::text()); } diff --git a/src/InventoryToolset.php b/src/InventoryToolset.php index 6f8a5b7..b20b7fa 100644 --- a/src/InventoryToolset.php +++ b/src/InventoryToolset.php @@ -12,11 +12,15 @@ /** * Inventory over MCP — the agent-driven onboarding the initiative is built around. * - * Six tools under the `inventory` namespace: four writes (receive, adjust, count, - * transfer) and two reads (stock, movements). The {@see PluginToolset} base gates + * Tools under the `inventory` namespace: the ledger writes (receive, adjust, + * count, transfer) and reservation writes (reserve, release, issue); the reads + * (stock, movements); and the **item master** (ADR 0022) — `item_set`/`item_get` + * and `category_set`/`category_get`/`categories`, so an agent can manage the + * sellable catalog, not just the numbers. The {@see PluginToolset} base gates * every one on the plugin's own `nimbuscms.inventory` capability (ADR 0015/0016) — * a write tool needs `:write`, unreachable by a content token — so this class - * writes no authorization code. + * writes no authorization code. (Deletes stay admin-only in Slice 1 — a recorded + * deferral of an MCP delete tool.) * * Two things the handlers guarantee, per the security review: * - **`actor` and `occurred_at` are server-set** — the token's name and the @@ -26,7 +30,7 @@ */ final class InventoryToolset extends PluginToolset { - public function __construct(private Ledger $ledger, private Reservations $reservations) + public function __construct(private Ledger $ledger, private Reservations $reservations, private Catalog $catalog) { } @@ -97,6 +101,49 @@ protected function tools(): array 'required' => ['sku'], 'properties' => ['sku' => $sku, 'limit' => ['type' => 'integer', 'description' => 'Max rows (1–500, default 50).']], ], $this->movements(...)), + + new PluginTool('item_set', 'write', 'Create or update a SKU\'s sellable item record (name, price, category, …). Only the fields you send change; the rest keep their stored value.', [ + 'type' => 'object', + 'required' => ['sku', 'name'], + 'properties' => [ + 'sku' => $sku, + 'name' => ['type' => 'string', 'description' => 'Display name of the item.'], + 'price' => ['type' => 'string', 'description' => 'A non-negative amount, up to 2 decimals (e.g. "3.49"). Defaults to 0.'], + 'unit' => ['type' => 'string', 'description' => 'Selling unit (e.g. "each", "kg", "500g pack"). Optional.'], + 'description' => ['type' => 'string', 'description' => 'Plain-text description (no HTML). Optional.'], + 'image_media_id' => ['type' => 'integer', 'description' => 'A media-library id for the item image. Optional.'], + 'category_id' => ['type' => 'integer', 'description' => 'An existing category id. Optional.'], + 'active' => ['type' => 'boolean', 'description' => 'Whether the item is sellable/visible (default true).'], + 'featured' => ['type' => 'boolean', 'description' => 'Whether to feature the item (default false).'], + ], + ], $this->itemSet(...)), + + new PluginTool('item_get', 'read', 'The sellable item record for a SKU (name, price, category, flags), or none.', [ + 'type' => 'object', + 'required' => ['sku'], + 'properties' => ['sku' => $sku], + ], $this->itemGet(...)), + + new PluginTool('category_set', 'write', 'Create a category (omit id) or rename/reparent one (with id). Two levels only.', [ + 'type' => 'object', + 'required' => ['name'], + 'properties' => [ + 'id' => ['type' => 'integer', 'description' => 'Existing category id to update; omit to create.'], + 'name' => ['type' => 'string', 'description' => 'Category name.'], + 'parent_id' => ['type' => 'integer', 'description' => 'An existing top-level category id to nest under. Omit for a top-level category.'], + ], + ], $this->categorySet(...)), + + new PluginTool('category_get', 'read', 'One category by id.', [ + 'type' => 'object', + 'required' => ['id'], + 'properties' => ['id' => ['type' => 'integer', 'description' => 'The category id.']], + ], $this->categoryGet(...)), + + new PluginTool('categories', 'read', 'All categories, ordered as a two-level tree.', [ + 'type' => 'object', + 'properties' => new \stdClass(), + ], $this->categories(...)), ]; } @@ -232,6 +279,67 @@ private function movements(array $a, TokenPrincipal $p, EntryOpContext $c): arra return ['sku' => $sku, 'movements' => $this->ledger->movementsFor($sku, $limit)]; } + /** + * @param array $a + * @return array + */ + private function itemSet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $sku = $this->str($a, 'sku'); + // saveItem reads only its own allow-listed keys from the arguments — + // an unknown key (or a forged sku_code/timestamp) is ignored. + $this->catalog->saveItem($sku, $a, $this->now()); + return ['ok' => true, 'item' => $this->catalog->getItem($sku)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function itemGet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $sku = $this->str($a, 'sku'); + return ['sku' => $sku, 'item' => $this->catalog->getItem($sku)]; + } + + /** + * @param array $a + * @return array + */ + private function categorySet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return $this->guard(function () use ($a): array { + $id = $this->nullableInt($a, 'id'); + $parentId = $this->nullableInt($a, 'parent_id'); + $newId = $this->catalog->saveCategory($id, $this->str($a, 'name'), $parentId, $this->now()); + return ['ok' => true, 'category' => $this->catalog->getCategory($newId)]; + }); + } + + /** + * @param array $a + * @return array + */ + private function categoryGet(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + $id = $this->nullableInt($a, 'id'); + if ($id === null) { + return ['ok' => false, 'error' => 'invalid', 'message' => '"id" is required.']; + } + return ['id' => $id, 'category' => $this->catalog->getCategory($id)]; + } + + /** + * @param array $a + * @return array + */ + private function categories(array $a, TokenPrincipal $p, EntryOpContext $c): array + { + return ['categories' => $this->catalog->allCategories()]; + } + // --- helpers --------------------------------------------------------- /** @@ -303,6 +411,22 @@ private function strOr(array $a, string $key, string $default): string return $v ?? $default; } + /** @param array $a */ + private function nullableInt(array $a, string $key): ?int + { + $v = $a[$key] ?? null; + if ($v === null || $v === '') { + return null; + } + if (is_int($v)) { + return $v; + } + if (is_string($v) && preg_match('/^\d+$/', trim($v)) === 1) { + return (int) trim($v); + } + throw new \InvalidArgumentException("\"{$key}\" must be a whole number."); + } + /** @param array $a */ private function nullableStr(array $a, string $key): ?string { diff --git a/src/Schema.php b/src/Schema.php index 93906d2..49c2737 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -27,8 +27,14 @@ * a new domain's reason must not be an Inventory migration. * 4. `lot_id` / `unit_id` are nullable from day one, so lots and serials slot in * later without re-folding the ledger. - * 5. Catalog (item/SKU) lives in *content* (collections), not here — the ledger - * references a SKU only by its opaque `sku_code`. + * 5. A SKU may **optionally** carry a sellable *item* record (name, price, + * category, …) in {@see ITEM} — the retail counterpart to a content catalog, + * for goods you stock and sell as-is (ADR 0022). It is additive: the ledger + * still references a SKU only by its opaque `sku_code`, a SKU can have stock + * with no item (and vice-versa), and a pure-ledger user's tables are + * untouched. This supersedes the plugin's original "catalog lives only in + * content" stance — content collections remain the catalog for *editorial* + * items (a menu, a showcase); the item master is for *operational* stock. */ final class Schema { @@ -36,6 +42,8 @@ final class Schema public const LOCATION = 'inventory_location'; public const STOCK = 'inventory_stock'; public const RESERVATION = 'inventory_reservation'; + public const ITEM = 'inventory_item'; + public const CATEGORY = 'inventory_category'; /** * The reservation overlay (Commerce slice 1). A soft hold on stock — @@ -62,6 +70,48 @@ public static function reservations(): array ]; } + /** + * The optional item master (ADR 0022): a SKU's sellable attributes, keyed by + * the same opaque `sku_code` the ledger uses — still **no** foreign key into + * core (`image_media_id` is a soft ref resolved defensively at render). A + * lightweight two-level category taxonomy backs storefront browsing; a child + * category (one whose `parent_id` is set) can never itself be a parent, which + * fixes the depth at two and makes cycles structurally impossible. + * + * @return list each statement individually idempotent (ADR 0005) + */ + public static function items(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS ' . self::CATEGORY . ' ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + slug VARCHAR(140) NOT NULL, + parent_id BIGINT UNSIGNED NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uq_category_slug (slug), + INDEX idx_category_parent (parent_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + + 'CREATE TABLE IF NOT EXISTS ' . self::ITEM . ' ( + sku_code VARCHAR(80) NOT NULL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + price DECIMAL(18,2) NOT NULL DEFAULT 0, + unit VARCHAR(32) NULL, + description TEXT NULL, + image_media_id BIGINT UNSIGNED NULL, + category_id BIGINT UNSIGNED NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + featured TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + INDEX idx_item_category (category_id), + INDEX idx_item_active (active) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + ]; + } + /** @return list each statement individually idempotent (ADR 0005) */ public static function all(): array { diff --git a/tests/CatalogAdminTest.php b/tests/CatalogAdminTest.php new file mode 100644 index 0000000..1500554 --- /dev/null +++ b/tests/CatalogAdminTest.php @@ -0,0 +1,111 @@ + 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::items() as $sql) { + $db->execute($sql); + } + $db->execute('TRUNCATE ' . Schema::ITEM); + $db->execute('TRUNCATE ' . Schema::CATEGORY); + + $storage = new PluginStorage($db); + $this->catalog = new Catalog(static fn (): PluginStorage => $storage); + $this->admin = new CatalogAdmin($this->catalog); + } + + public function test_the_item_and_category_forms_are_present(): void + { + $html = $this->admin->render('tok'); + self::assertStringContainsString('action="/admin/catalog/item-save"', $html); + self::assertStringContainsString('action="/admin/catalog/category-save"', $html); + self::assertStringContainsString('name="_token" value="tok"', $html); + } + + public function test_an_items_values_are_escaped_on_render_not_live_markup(): void + { + // The store keeps author input raw (proven in CatalogTest); the page must + // escape it — this is the standing contract the storefront (Slice 2) shares. + $this->catalog->saveItem('xss', ['name' => '', 'price' => '1.00'], self::T); + $html = $this->admin->render('tok'); + + self::assertStringNotContainsString('', $html); + self::assertStringContainsString('<script>', $html); + } + + public function test_editing_an_item_prefills_the_form(): void + { + $this->catalog->saveItem('milk', ['name' => 'Whole Milk', 'price' => '1.20', 'unit' => 'litre'], self::T); + $html = $this->admin->render('tok', null, 'milk'); + + self::assertStringContainsString('value="Whole Milk"', $html); + self::assertStringContainsString('value="1.20"', $html); + self::assertStringContainsString('action="/admin/catalog/item-delete"', $html, 'editing offers a delete'); + } + + public function test_a_category_can_be_chosen_and_children_are_shown_nested(): void + { + $top = $this->catalog->saveCategory(null, 'Grocery', null, self::T); + $this->catalog->saveCategory(null, 'Fruit', $top, self::T); + $html = $this->admin->render('tok'); + + self::assertStringContainsString('