diff --git a/src/InventoryAdmin.php b/src/InventoryAdmin.php index f021704..70e6e0f 100644 --- a/src/InventoryAdmin.php +++ b/src/InventoryAdmin.php @@ -39,17 +39,17 @@ 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 + * @param ?string $sku a specific SKU (from ?sku=) — renders the drill-down instead of the overview */ - public function render(string $csrf = '', ?string $notice = null, ?string $q = null): string + public function render(string $csrf = '', ?string $notice = null, ?string $q = null, ?string $sku = null): string { $s = ($this->storage)(); + if ($sku !== null && trim($sku) !== '') { + return $this->renderDetail($s, trim($sku), $notice); + } $q = $q !== null ? trim($q) : ''; - $banner = ''; - if ($notice !== null && isset(self::NOTICES[$notice])) { - [$kind, $msg] = self::NOTICES[$notice]; - $banner = '
' . $this->e($msg) . '
'; - } + $banner = $this->notice($notice); $locations = []; foreach ($s->select('SELECT id, code FROM ' . Schema::LOCATION . ' ORDER BY code') as $l) { @@ -101,7 +101,7 @@ public function render(string $csrf = '', ?string $notice = null, ?string $q = n $reserved = (string) $r['reserved']; $avail = number_format(max(0, (float) $onHand - (float) $reserved), 4, '.', ''); $loc = $locations[(int) $r['location_id']] ?? (string) $r['location_id']; - $html .= '' . $this->e((string) $r['sku_code']) . '' + $html .= '' . $this->skuLink((string) $r['sku_code']) . '' . '' . $this->e($loc) . '' . '' . $this->e($onHand) . ' ' . $this->e((string) $r['uom']) . '' . '' . $this->e($reserved) . '' @@ -122,7 +122,7 @@ public function render(string $csrf = '', ?string $notice = null, ?string $q = n . 'SKULocationQtyReasonActorWhen'; foreach ($movements as $m) { $loc = $locations[(int) $m['location_id']] ?? (string) $m['location_id']; - $html .= '' . $this->e((string) $m['sku_code']) . '' + $html .= '' . $this->skuLink((string) $m['sku_code']) . '' . '' . $this->e($loc) . '' . '' . $this->e((string) $m['qty']) . ' ' . $this->e((string) $m['uom']) . '' . '' . $this->e((string) $m['reason']) . '' @@ -135,6 +135,138 @@ public function render(string $csrf = '', ?string $notice = null, ?string $q = n return $html; } + /** + * The per-SKU drill-down (?sku=): stock by location, open holds with their ref + * and age, and the full movement trail. Read-only — every value escaped, every + * query bound to this plugin's own tables. + */ + private function renderDetail(PluginStorage $s, string $sku, ?string $notice): string + { + $locations = []; + foreach ($s->select('SELECT id, code FROM ' . Schema::LOCATION . ' ORDER BY code') as $l) { + $locations[(int) $l['id']] = (string) $l['code']; + } + $loc = fn (int $id): string => $locations[$id] ?? (string) $id; + + $stock = $s->select( + 'SELECT 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 WHERE s.sku_code = :sku ORDER BY s.location_id', + ['sku' => $sku], + ); + $holds = $s->select( + 'SELECT ref, location_id, qty, created_at FROM ' . Schema::RESERVATION . ' WHERE sku_code = :sku ORDER BY created_at', + ['sku' => $sku], + ); + $moves = $s->select( + 'SELECT qty, uom, reason, ref_type, ref_id, actor, note, occurred_at + FROM ' . Schema::MOVEMENT . ' WHERE sku_code = :sku ORDER BY id DESC LIMIT 100', + ['sku' => $sku], + ); + + $html = '

Inventory

' . $this->notice($notice) + . '

← All stock

' + . '

SKU ' . $this->e($sku) . '

'; + + if ($stock === [] && $holds === [] && $moves === []) { + return $html . '

Nothing recorded for this SKU. It may be a mistyped code — check the stock list.

'; + } + + // Stock by location. + $html .= '

Stock by location

'; + if ($stock === []) { + $html .= '

None on hand.

'; + } else { + $html .= '
' + . '' + . ''; + foreach ($stock as $r) { + $onHand = (string) $r['on_hand']; + $avail = number_format(max(0, (float) $onHand - (float) $r['reserved']), 4, '.', ''); + $html .= '' + . '' + . '' + . ''; + } + $html .= '
LocationOn handReservedAvailable
' . $this->e($loc((int) $r['location_id'])) . '' . $this->e($onHand) . ' ' . $this->e((string) $r['uom']) . '' . $this->e((string) $r['reserved']) . '' . $this->e($avail) . '
'; + } + + // Open holds — answers "why is N reserved?". + $html .= '

Open holds

'; + if ($holds === []) { + $html .= '

No stock is currently reserved.

'; + } else { + $html .= '
' + . ''; + foreach ($holds as $h) { + $html .= '' + . '' + . '' + . ''; + } + $html .= '
RefLocationQtyAge
' . $this->e((string) $h['ref']) . '' . $this->e($loc((int) $h['location_id'])) . '' . $this->e((string) $h['qty']) . '' . $this->e($this->age((string) $h['created_at'])) . '
'; + } + + // Full movement trail. + $html .= '

Movement trail

'; + if ($moves === []) { + $html .= '

No movements.

'; + } else { + $html .= '
' + . ''; + foreach ($moves as $m) { + $ref = trim(((string) ($m['ref_type'] ?? '')) . ' ' . ((string) ($m['ref_id'] ?? ''))); + $html .= '' + . '' + . '' + . '' + . ''; + } + $html .= '
QtyReasonRefActorWhen
' . $this->e((string) $m['qty']) . ' ' . $this->e((string) $m['uom']) . '' . $this->e((string) $m['reason']) . '' . ($ref === '' ? '—' : $this->e($ref)) . '' . $this->e((string) $m['actor']) . '' . $this->e((string) $m['occurred_at']) . '
'; + } + + return $html; + } + + private function notice(?string $notice): string + { + if ($notice === null || !isset(self::NOTICES[$notice])) { + return ''; + } + [$kind, $msg] = self::NOTICES[$notice]; + return '
' . $this->e($msg) . '
'; + } + + /** A stock-row SKU as a link to its drill-down. */ + private function skuLink(string $sku): string + { + return '' . $this->e($sku) . ''; + } + + /** A compact human age from a 'Y-m-d H:i:s' timestamp (e.g. "3d 4h", "just now"). */ + private function age(string $ts): string + { + $then = strtotime($ts); + if ($then === false) { + return $ts; + } + $secs = max(0, time() - $then); + if ($secs < 60) { + return 'just now'; + } + $mins = intdiv($secs, 60); + if ($mins < 60) { + return $mins . 'm'; + } + $hours = intdiv($mins, 60); + if ($hours < 24) { + return $hours . 'h ' . ($mins % 60) . 'm'; + } + $days = intdiv($hours, 24); + return $days . 'd ' . ($hours % 24) . 'h'; + } + /** * The known-SKU and known-location suggestion lists, referenced by the form * inputs. Suggestions only — a new SKU/location can still be typed. diff --git a/src/InventoryPlugin.php b/src/InventoryPlugin.php index f1157ab..06d7cc1 100644 --- a/src/InventoryPlugin.php +++ b/src/InventoryPlugin.php @@ -61,7 +61,7 @@ public function register(PluginContext $context): void 'inventory', 'Inventory', '📦', - static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new InventoryAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('q')), + static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new InventoryAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('q'), $r->query('sku')), self::ID . ':write', ); $context->adminPages()->action('inventory', 'receive', static function (Request $r) use ($ledger): Response { diff --git a/tests/InventoryAdminTest.php b/tests/InventoryAdminTest.php index f217a6a..d45f907 100644 --- a/tests/InventoryAdminTest.php +++ b/tests/InventoryAdminTest.php @@ -8,6 +8,7 @@ use Nimbus\Plugin\PluginStorage; use NimbusCMS\Inventory\InventoryAdmin; use NimbusCMS\Inventory\Ledger; +use NimbusCMS\Inventory\Reservations; use NimbusCMS\Inventory\Schema; use PHPUnit\Framework\TestCase; @@ -94,4 +95,44 @@ public function test_a_notice_code_maps_to_a_message_and_unknown_shows_nothing() self::assertStringContainsString('Choose two different locations', $this->admin->render('tok', 'samelocation')); self::assertStringNotContainsString('nb-notice', $this->admin->render('tok', 'nonsense-code')); } + + public function test_the_stock_rows_link_to_the_sku_drilldown(): void + { + $html = $this->admin->render('tok'); + self::assertStringContainsString('href="/admin/inventory?sku=house-blend"', $html); + } + + public function test_the_sku_drilldown_shows_stock_holds_and_trail(): void + { + // Put a hold on house-blend so the drill-down has an open hold to explain. + $storage = new PluginStorage($this->db); + $ledger = new Ledger(static fn (): PluginStorage => $storage); + $res = new Reservations(static fn (): PluginStorage => $storage, $ledger); + $loc = $ledger->ensureLocation('main', 'Main', '2026-01-01 09:00:00'); + $res->reserve('house-blend', $loc, '5', 'ORD-TEST:1', '2026-01-01 10:00:00'); + + $html = $this->admin->render('tok', null, null, 'house-blend'); + + self::assertStringContainsString('SKU house-blend', $html); + self::assertStringContainsString('Stock by location', $html); + self::assertStringContainsString('Open holds', $html); + self::assertStringContainsString('ORD-TEST:1', $html, 'the hold ref explains the reservation'); + self::assertStringContainsString('Movement trail', $html); + self::assertStringContainsString('receipt', $html, 'the seeding receipt is in the trail'); + } + + public function test_the_drilldown_escapes_and_binds_a_hostile_sku(): void + { + // Reflected-XSS + SQLi guard on ?sku=: bound (no error, no rows) and escaped. + $html = $this->admin->render('tok', null, null, '">'); + + self::assertStringNotContainsString('', $html); + self::assertStringContainsString('Nothing recorded for this SKU', $html); + } + + public function test_an_unknown_sku_shows_a_helpful_note(): void + { + $html = $this->admin->render('tok', null, null, 'no-such-sku'); + self::assertStringContainsString('Nothing recorded for this SKU', $html); + } }