From da6d89f6683fcb43770b57846ddf1c6a181b9e57 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Sun, 17 May 2026 14:09:03 +0200 Subject: [PATCH] BFI commands, A test --- .../Commands/ShopAvailabilityCommand.php | 8 +- src/Console/Commands/ShopStatsCommand.php | 9 + src/Console/Commands/ShopStocksCommand.php | 6 +- src/Traits/HasStocks.php | 68 ++++- tests/Feature/CommandStatsTest.php | 29 ++ tests/Feature/Loan/LoanShopCommandsTest.php | 33 +++ tests/Feature/Product/PhysicalStockTest.php | 260 ++++++++++++++++++ 7 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/Product/PhysicalStockTest.php diff --git a/src/Console/Commands/ShopAvailabilityCommand.php b/src/Console/Commands/ShopAvailabilityCommand.php index efce9b2..0c8911d 100644 --- a/src/Console/Commands/ShopAvailabilityCommand.php +++ b/src/Console/Commands/ShopAvailabilityCommand.php @@ -80,13 +80,19 @@ class ShopAvailabilityCommand extends Command private function renderSummaryCounters(Product $product): void { + $physical = $product->getPhysicalStock(); $available = $product->getAvailableStock(); $currentClaims = $product->getCurrentlyClaimedStock(); $futureClaims = $product->getFutureClaimedStock(); $activeAndPlanned = $product->getActiveAndPlannedClaimedStock(); + // "Physical" is the count of units the business still owns — sums + // available + currently claimed + active loans. For a tomato shop it + // matches available (sales are permanent); for a library it stays at + // the catalogue size regardless of how many copies are currently out. $this->line(sprintf( - ' Available %s Currently claimed %d Future claims %d Active & planned %d', + ' Physical %s Available %s Currently claimed %d Future claims %d Active & planned %d', + $this->infinityOr($physical), $this->infinityOr($available), $currentClaims, $futureClaims, diff --git a/src/Console/Commands/ShopStatsCommand.php b/src/Console/Commands/ShopStatsCommand.php index 0836504..3933ab1 100644 --- a/src/Console/Commands/ShopStatsCommand.php +++ b/src/Console/Commands/ShopStatsCommand.php @@ -32,6 +32,15 @@ class ShopStatsCommand extends Command $rows[] = ['Products: published', $publishedProducts]; $rows[] = ['Products: visible', $visibleProducts]; + // Physical inventory rollup — how many units the business still owns + // across every managed product (loaned/claimed copies count). Skips + // unmanaged products so a single "no scarcity" item doesn't render + // ∞ at the rollup level. + $physicalUnits = $productModel::where('manage_stock', true) + ->get() + ->sum(fn ($product) => $product->getPhysicalStock()); + $rows[] = ['Products: physical units', $physicalUnits]; + $rows[] = ['---', '---']; // Actions diff --git a/src/Console/Commands/ShopStocksCommand.php b/src/Console/Commands/ShopStocksCommand.php index 25e7882..bdbe74f 100644 --- a/src/Console/Commands/ShopStocksCommand.php +++ b/src/Console/Commands/ShopStocksCommand.php @@ -38,6 +38,7 @@ class ShopStocksCommand extends Command $rows = $products->map(function (Product $product): array { $assigned = $product->manage_stock ? $this->assignedCapacity($product) : null; + $physical = $product->manage_stock ? $product->getPhysicalStock() : null; $used = $product->manage_stock ? $this->totalUsed($product) : null; $available = $product->getAvailableStock(); $claimed = $product->getCurrentlyClaimedStock(); @@ -48,6 +49,7 @@ class ShopStocksCommand extends Command 'name' => $this->truncate((string) $product->name, 30), 'type' => $type, 'assigned' => $assigned === null ? '∞' : (string) $assigned, + 'physical' => $physical === null ? '∞' : (string) $physical, 'used' => $used === null ? '—' : (string) $used, 'available' => $available === PHP_INT_MAX ? '∞' : (string) $available, 'claimed' => (string) $claimed, @@ -56,7 +58,7 @@ class ShopStocksCommand extends Command $this->newLine(); $this->table( - ['ID', 'Name', 'Type', 'Assigned', 'Used', 'Available', 'Claimed'], + ['ID', 'Name', 'Type', 'Assigned', 'Physical', 'Used', 'Available', 'Claimed'], $rows, ); $this->line(' Total products: '.$products->count().' '. @@ -89,6 +91,7 @@ class ShopStocksCommand extends Command } $assigned = $this->assignedCapacity($product); + $physical = $product->getPhysicalStock(); $used = $this->totalUsed($product); $available = $product->getAvailableStock(); $currentClaims = $product->getCurrentlyClaimedStock(); @@ -97,6 +100,7 @@ class ShopStocksCommand extends Command $this->renderTotalsBox([ ['ASSIGNED', $assigned, 'cyan'], + ['PHYSICAL', $physical, 'cyan'], ['USED', $used, 'gray'], ['AVAILABLE', $available, $available > 0 ? 'green' : 'red'], ['CLAIMED NOW', $currentClaims, $currentClaims > 0 ? 'yellow' : 'gray'], diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php index b79adf5..960ce6c 100644 --- a/src/Traits/HasStocks.php +++ b/src/Traits/HasStocks.php @@ -131,7 +131,7 @@ trait HasStocks /** * Check if product is in stock - * + * * @return bool True if stock management is disabled OR available stock > 0 */ public function isInStock(): bool @@ -143,6 +143,72 @@ trait HasStocks return $this->getAvailableStock() > 0; } + /** + * Physical inventory — how many units the business still owns right now, + * regardless of whether they're temporarily out (on loan, claimed by a + * cart/booking) or sitting on the shelf. + * + * available + currentlyClaimed + activeLoans + * + * Why three terms? + * - available — units on the shelf, free for new use. + * - currentlyClaimed — units held by a cart, booking, or other + * reservation that hasn't been finalised + * (will come back if the claim expires). + * - activeLoans — units checked out via a PENDING + * {@see \Blax\Shop\Models\ProductPurchase} + * row that hasn't been returned yet + * (loaned items still belong to the library). + * + * Worked examples: + * - Tomato shop: bought 10, sold 3 → DECREASE -3 is permanent (no + * claim/loan to offset). Physical = 7. Available = 7. + * - Library: bought 5, loaned 1 → DECREASE -1 + active loan → +1. + * Physical = 4 + 0 + 1 = 5. Available = 4. + * - Hotel: 1 room, future booking → claim sits in the future, no + * current claim yet. Physical = 1 + 0 + 0 = 1. + * + * Distinct from {@see self::getMaxStocksAttribute} (which sums every + * INCREASE/RETURN row ever written and so inflates after every loan + * return), and from {@see \Blax\Shop\Traits\MayBeLoanableProduct::getTotalQuantityAttribute} + * (which is loanable-only). This one works for every Product type. + */ + public function getPhysicalStockAttribute(): int + { + if (!$this->manage_stock) { + return PHP_INT_MAX; + } + + $available = $this->getAvailableStock(); + $currentClaims = $this->getCurrentlyClaimedStock(); + + // Query loans by purchasable_id only — the morphMany on $this->purchases() + // narrows by purchasable_type using static::class, which silently + // misses rows written under a subclass type (e.g. App\Models\Book) + // when the caller resolved the product via the base Product class + // (as `shop:stocks:availability` does). Product UUIDs are unique + // across the table so dropping the type filter is safe. + $purchaseModel = config( + 'shop.models.product_purchase', + \Blax\Shop\Models\ProductPurchase::class, + ); + $activeLoans = (int) $purchaseModel::query() + ->where('purchasable_id', $this->getKey()) + ->activeLoans() + ->sum('quantity'); + + return $available + $currentClaims + $activeLoans; + } + + /** + * Convenience method form so callers reading dynamically can pick either + * `$product->physical_stock` or `$product->getPhysicalStock()`. + */ + public function getPhysicalStock(): int + { + return $this->getPhysicalStockAttribute(); + } + /** * Decrease physical stock (inventory reduction) * diff --git a/tests/Feature/CommandStatsTest.php b/tests/Feature/CommandStatsTest.php index a50c8b9..801d6ef 100644 --- a/tests/Feature/CommandStatsTest.php +++ b/tests/Feature/CommandStatsTest.php @@ -143,6 +143,35 @@ class CommandStatsTest extends TestCase $this->assertMatchesRegularExpression('/Revenue \(paid\)\s*\|\s*40\.00\b/', $output); } + #[Test] + public function shop_stats_reports_physical_units_summed_across_managed_products(): void + { + // Two managed products: 10 tomatoes (physical=10), library book with + // 5 copies one of which is on loan (physical=5 since loans count). + // Plus an unmanaged eBook that must NOT be summed in (would render ∞). + $tomato = $this->newProduct(['manage_stock' => true]); + $tomato->increaseStock(10); + + $book = Product::create([ + 'name' => 'Library Book', + 'sku' => 'STAT-BOOK', + 'type' => ProductType::LOANABLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => true, + ]); + $book->increaseStock(5); + $book->checkOutTo(\Workbench\App\Models\User::factory()->create()); + + $this->newProduct(['manage_stock' => false]); // unmanaged, must skip + + Artisan::call(ShopStatsCommand::class); + $output = Artisan::output(); + + // Tomato 10 + Book 5 (loaned copy still counts) = 15 physical units. + $this->assertMatchesRegularExpression('/Products: physical units\s*\|\s*15\b/', $output); + } + #[Test] public function shop_stats_includes_carts_and_orders_when_models_are_configured(): void { diff --git a/tests/Feature/Loan/LoanShopCommandsTest.php b/tests/Feature/Loan/LoanShopCommandsTest.php index dbefbdc..c528e09 100644 --- a/tests/Feature/Loan/LoanShopCommandsTest.php +++ b/tests/Feature/Loan/LoanShopCommandsTest.php @@ -165,6 +165,39 @@ class LoanShopCommandsTest extends TestCase /* ─────────────────── shop:stocks:availability (calendar) ─────────────── */ + #[Test] + public function shop_stocks_availability_headline_surfaces_physical_count(): void + { + // Coverage for the new "Physical N" headline that complements + // "Available N". For a loanable product the gap between the two is the + // loan tally — physical stays at the catalogue size while available + // drops as copies go out. + $book = $this->newBook('Hyperion', 'CMD-HYP-PHYS'); + $book->increaseStock(3); + $book->checkOutTo($this->borrower); + + $output = $this->runOk(ShopAvailabilityCommand::class, ['product' => 'CMD-HYP-PHYS']); + + $this->assertStringContainsString('Physical 3', $output, 'physical = 2 on shelf + 1 active loan = 3'); + $this->assertStringContainsString('Available 2', $output); + } + + #[Test] + public function shop_stocks_detail_view_carries_a_physical_box(): void + { + // Operator-side: shop:stocks {product} renders the totals box; the + // PHYSICAL cell should sit next to ASSIGNED and reflect the loan-aware + // count. + $book = $this->newBook('Atlas', 'CMD-ATLAS-PHYS'); + $book->increaseStock(4); + $book->checkOutTo($this->borrower); + + $output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-ATLAS-PHYS']); + + $this->assertStringContainsString('PHYSICAL', $output); + $this->assertSame(4, $book->fresh()->getPhysicalStock(), 'model agrees with command'); + } + #[Test] public function shop_stocks_availability_headline_reads_zero_for_a_fully_loaned_book(): void { diff --git a/tests/Feature/Product/PhysicalStockTest.php b/tests/Feature/Product/PhysicalStockTest.php new file mode 100644 index 0000000..899abe3 --- /dev/null +++ b/tests/Feature/Product/PhysicalStockTest.php @@ -0,0 +1,260 @@ + 'Heirloom Tomato', + 'sku' => 'TOM-PHYS', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => true, + ]); + $tomato->increaseStock($initialStock); + + return $tomato; + } + + private function book(int $copies): Product + { + $book = Product::create([ + 'name' => 'Hyperion', + 'sku' => 'HYP-PHYS', + 'type' => ProductType::LOANABLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => true, + ]); + $book->increaseStock($copies); + + return $book; + } + + private function room(): Product + { + $room = Product::create([ + 'name' => 'Suite 1', + 'sku' => 'ROOM-PHYS', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => true, + ]); + $room->increaseStock(1); + + return $room; + } + + /* ──────────────── consumable (tomato shop) ──────────────── */ + + #[Test] + public function tomato_shop_physical_equals_available_with_no_activity(): void + { + $tomato = $this->tomato(10); + + $this->assertSame(10, $tomato->getPhysicalStock()); + $this->assertSame(10, $tomato->physical_stock); + $this->assertSame(10, $tomato->getAvailableStock()); + } + + #[Test] + public function selling_a_tomato_reduces_physical_and_available_in_lockstep(): void + { + // A sale is recorded as a permanent DECREASE — no offsetting active + // loan or claim. Both physical and available drop by the sold count. + $tomato = $this->tomato(10); + $tomato->decreaseStock(3); + + $this->assertSame(7, $tomato->getAvailableStock(), '3 sold → 7 on the shelf'); + $this->assertSame(7, $tomato->getPhysicalStock(), 'sold tomatoes are eaten — gone from physical inventory too'); + } + + /* ──────────────── loanable (library book) ──────────────── */ + + #[Test] + public function loaning_a_book_drops_available_but_not_physical(): void + { + // Loaned books still belong to the library — physical stays at the + // catalogue size, available drops by the loaned count. + $book = $this->book(5); + $borrower = User::factory()->create(); + + $book->checkOutTo($borrower); + + $fresh = $book->fresh(); + $this->assertSame(4, $fresh->getAvailableStock(), 'one copy is out → 4 on the shelf'); + $this->assertSame(5, $fresh->getPhysicalStock(), 'the loaned copy is still ours → physical = 5'); + } + + #[Test] + public function returning_a_loan_restores_available_and_physical_stays_steady(): void + { + $book = $this->book(5); + $borrower = User::factory()->create(); + + $loan = $book->checkOutTo($borrower); + $this->assertSame(5, $book->fresh()->getPhysicalStock()); + + // Host-driven return: mark + restock (mirrors moonshiner's + // LoanController::returnLoan). + $loan->markReturned(); + $book->increaseStock(1); + + $fresh = $book->fresh(); + $this->assertSame(5, $fresh->getAvailableStock()); + $this->assertSame(5, $fresh->getPhysicalStock(), 'physical never wavered through the loan cycle'); + } + + #[Test] + public function multiple_concurrent_loans_each_contribute_to_physical(): void + { + $book = $this->book(5); + $book->checkOutTo(User::factory()->create()); + $book->checkOutTo(User::factory()->create()); + $book->checkOutTo(User::factory()->create()); + + $fresh = $book->fresh(); + $this->assertSame(2, $fresh->getAvailableStock(), '3 out → 2 on shelf'); + $this->assertSame(5, $fresh->getPhysicalStock(), '3 active loans + 2 available = 5 owned'); + } + + /* ──────────────── booking (hotel room) ──────────────── */ + + #[Test] + public function active_claim_counts_toward_physical(): void + { + // A claim active right now (e.g. a cart reservation) holds the unit + // back from new use, but the business still owns it. + Carbon::setTestNow(Carbon::parse('2026-05-17 12:00:00')); + $room = $this->room(); + $room->claimStock( + 1, + null, + Carbon::parse('2026-05-17 09:00:00'), + Carbon::parse('2026-05-17 18:00:00'), + 'cart hold', + ); + + $fresh = $room->fresh(); + $this->assertSame(0, $fresh->getAvailableStock()); + $this->assertSame(1, $fresh->getCurrentlyClaimedStock()); + $this->assertSame(1, $fresh->getPhysicalStock(), 'claimed-but-still-ours counts as physical'); + } + + #[Test] + public function future_only_claim_does_not_inflate_physical(): void + { + // A booking starting tomorrow doesn't reduce today's available stock, + // so it must not double-count toward today's physical either. + Carbon::setTestNow(Carbon::parse('2026-05-17 12:00:00')); + $room = $this->room(); + $room->claimStock( + 1, + null, + Carbon::parse('2026-05-20 09:00:00'), + Carbon::parse('2026-05-21 09:00:00'), + 'next-week booking', + ); + + $fresh = $room->fresh(); + $this->assertSame(1, $fresh->getAvailableStock(), 'future booking does not reduce today'); + $this->assertSame(0, $fresh->getCurrentlyClaimedStock(), 'future claim is not "current"'); + $this->assertSame(1, $fresh->getPhysicalStock(), 'physical = 1 + 0 + 0 = 1'); + } + + /* ──────────────── unmanaged stock ──────────────── */ + + #[Test] + public function unmanaged_stock_reports_infinite_physical(): void + { + // manage_stock=false makes available/physical both PHP_INT_MAX — the + // package's universal "no scarcity" signal. + $product = Product::create([ + 'name' => 'eBook', + 'sku' => 'EB-PHYS', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => false, + ]); + + $this->assertSame(PHP_INT_MAX, $product->getPhysicalStock()); + } + + /* ──────────────── distinct from existing accessors ──────────────── */ + + #[Test] + public function physical_does_not_inflate_after_a_library_loan_return_cycle(): void + { + // Regression: getMaxStocksAttribute sums every INCREASE row — including + // the +1 from a loan return — so for a borrow-and-return cycle it + // overstates "Assigned" as 6 on a 5-copy book. physical_stock uses the + // available+claims+loans formula instead and stays correctly at 5. + $book = $this->book(5); + $loan = $book->checkOutTo(User::factory()->create()); + $loan->markReturned(); + $book->increaseStock(1); + + $fresh = $book->fresh(); + $this->assertSame(6, $fresh->getMaxStocksAttribute(), 'documented limitation: max inflates per cycle'); + $this->assertSame(5, $fresh->getPhysicalStock(), 'physical stays at the real owned count'); + } + + #[Test] + public function loan_quantity_above_one_aggregates_into_physical(): void + { + // Defensive coverage: real-world loans are always quantity=1, but the + // formula sums purchase.quantity rather than counting rows, so a + // hypothetical multi-unit loan would still account correctly. + $book = $this->book(10); + $book->decreaseStock(3); // simulate the stock-side of a 3-unit loan + $borrower = User::factory()->create(); + ProductPurchase::create([ + 'purchasable_id' => $book->id, + 'purchasable_type' => Product::class, + 'purchaser_id' => $borrower->id, + 'purchaser_type' => User::class, + 'quantity' => 3, + 'amount' => 0, + 'amount_paid' => 0, + 'status' => PurchaseStatus::PENDING, + 'from' => now(), + 'until' => now()->addWeeks(2), + 'meta' => ['extensions_used' => 0], + ]); + + $fresh = $book->fresh(); + $this->assertSame(7, $fresh->getAvailableStock()); + $this->assertSame(10, $fresh->getPhysicalStock(), '7 on shelf + 3 on loan = 10 owned'); + } +}