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
148 changes: 140 additions & 8 deletions src/InventoryAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div class="nb-notice nb-notice-' . ($kind === 'ok' ? 'ok' : 'error') . '">' . $this->e($msg) . '</div>';
}
$banner = $this->notice($notice);

$locations = [];
foreach ($s->select('SELECT id, code FROM ' . Schema::LOCATION . ' ORDER BY code') as $l) {
Expand Down Expand Up @@ -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 .= '<tr><td data-label="SKU"><code>' . $this->e((string) $r['sku_code']) . '</code></td>'
$html .= '<tr><td data-label="SKU">' . $this->skuLink((string) $r['sku_code']) . '</td>'
. '<td data-label="Location">' . $this->e($loc) . '</td>'
. '<td data-label="On hand" style="text-align:right">' . $this->e($onHand) . ' ' . $this->e((string) $r['uom']) . '</td>'
. '<td data-label="Reserved" style="text-align:right">' . $this->e($reserved) . '</td>'
Expand All @@ -122,7 +122,7 @@ public function render(string $csrf = '', ?string $notice = null, ?string $q = n
. '<th>SKU</th><th>Location</th><th style="text-align:right">Qty</th><th>Reason</th><th>Actor</th><th>When</th></tr></thead><tbody>';
foreach ($movements as $m) {
$loc = $locations[(int) $m['location_id']] ?? (string) $m['location_id'];
$html .= '<tr><td data-label="SKU"><code>' . $this->e((string) $m['sku_code']) . '</code></td>'
$html .= '<tr><td data-label="SKU">' . $this->skuLink((string) $m['sku_code']) . '</td>'
. '<td data-label="Location">' . $this->e($loc) . '</td>'
. '<td data-label="Qty" style="text-align:right">' . $this->e((string) $m['qty']) . ' ' . $this->e((string) $m['uom']) . '</td>'
. '<td data-label="Reason">' . $this->e((string) $m['reason']) . '</td>'
Expand All @@ -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 = '<div class="nb-page-head"><h1>Inventory</h1></div>' . $this->notice($notice)
. '<p style="margin:-8px 0 16px"><a href="/admin/inventory">&larr; All stock</a></p>'
. '<h2 style="margin-top:0">SKU <code>' . $this->e($sku) . '</code></h2>';

if ($stock === [] && $holds === [] && $moves === []) {
return $html . '<p class="nb-muted">Nothing recorded for this SKU. It may be a mistyped code — check the <a href="/admin/inventory">stock list</a>.</p>';
}

// Stock by location.
$html .= '<h3>Stock by location</h3>';
if ($stock === []) {
$html .= '<p class="nb-muted">None on hand.</p>';
} else {
$html .= '<div class="nb-table-wrap nb-stack"><table class="nb-table"><thead><tr>'
. '<th>Location</th><th style="text-align:right">On hand</th>'
. '<th style="text-align:right">Reserved</th><th style="text-align:right">Available</th></tr></thead><tbody>';
foreach ($stock as $r) {
$onHand = (string) $r['on_hand'];
$avail = number_format(max(0, (float) $onHand - (float) $r['reserved']), 4, '.', '');
$html .= '<tr><td data-label="Location">' . $this->e($loc((int) $r['location_id'])) . '</td>'
. '<td data-label="On hand" style="text-align:right">' . $this->e($onHand) . ' ' . $this->e((string) $r['uom']) . '</td>'
. '<td data-label="Reserved" style="text-align:right">' . $this->e((string) $r['reserved']) . '</td>'
. '<td data-label="Available" style="text-align:right"><strong>' . $this->e($avail) . '</strong></td></tr>';
}
$html .= '</tbody></table></div>';
}

// Open holds — answers "why is N reserved?".
$html .= '<h3 style="margin-top:1.5rem">Open holds</h3>';
if ($holds === []) {
$html .= '<p class="nb-muted">No stock is currently reserved.</p>';
} else {
$html .= '<div class="nb-table-wrap nb-stack"><table class="nb-table"><thead><tr>'
. '<th>Ref</th><th>Location</th><th style="text-align:right">Qty</th><th>Age</th></tr></thead><tbody>';
foreach ($holds as $h) {
$html .= '<tr><td data-label="Ref"><code>' . $this->e((string) $h['ref']) . '</code></td>'
. '<td data-label="Location">' . $this->e($loc((int) $h['location_id'])) . '</td>'
. '<td data-label="Qty" style="text-align:right">' . $this->e((string) $h['qty']) . '</td>'
. '<td data-label="Age" class="nb-muted">' . $this->e($this->age((string) $h['created_at'])) . '</td></tr>';
}
$html .= '</tbody></table></div>';
}

// Full movement trail.
$html .= '<h3 style="margin-top:1.5rem">Movement trail</h3>';
if ($moves === []) {
$html .= '<p class="nb-muted">No movements.</p>';
} else {
$html .= '<div class="nb-table-wrap nb-stack"><table class="nb-table"><thead><tr>'
. '<th style="text-align:right">Qty</th><th>Reason</th><th>Ref</th><th>Actor</th><th>When</th></tr></thead><tbody>';
foreach ($moves as $m) {
$ref = trim(((string) ($m['ref_type'] ?? '')) . ' ' . ((string) ($m['ref_id'] ?? '')));
$html .= '<tr><td data-label="Qty" style="text-align:right">' . $this->e((string) $m['qty']) . ' ' . $this->e((string) $m['uom']) . '</td>'
. '<td data-label="Reason">' . $this->e((string) $m['reason']) . '</td>'
. '<td data-label="Ref" class="nb-muted">' . ($ref === '' ? '—' : $this->e($ref)) . '</td>'
. '<td data-label="Actor">' . $this->e((string) $m['actor']) . '</td>'
. '<td data-label="When" class="nb-muted">' . $this->e((string) $m['occurred_at']) . '</td></tr>';
}
$html .= '</tbody></table></div>';
}

return $html;
}

private function notice(?string $notice): string
{
if ($notice === null || !isset(self::NOTICES[$notice])) {
return '';
}
[$kind, $msg] = self::NOTICES[$notice];
return '<div class="nb-notice nb-notice-' . ($kind === 'ok' ? 'ok' : 'error') . '">' . $this->e($msg) . '</div>';
}

/** A stock-row SKU as a link to its drill-down. */
private function skuLink(string $sku): string
{
return '<a href="/admin/inventory?sku=' . $this->e(rawurlencode($sku)) . '"><code>' . $this->e($sku) . '</code></a>';
}

/** 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.
Expand Down
2 changes: 1 addition & 1 deletion src/InventoryPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 41 additions & 0 deletions tests/InventoryAdminTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 <code>house-blend</code>', $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, '"><script>alert(1)</script>');

self::assertStringNotContainsString('<script>alert(1)</script>', $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);
}
}
Loading