BFI commands, A test
This commit is contained in:
parent
2bc64c6369
commit
da6d89f668
|
|
@ -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(
|
||||
' <fg=green;options=bold>Available %s</> <fg=yellow>Currently claimed %d</> <fg=blue>Future claims %d</> <fg=magenta>Active & planned %d</>',
|
||||
' <fg=cyan;options=bold>Physical %s</> <fg=green;options=bold>Available %s</> <fg=yellow>Currently claimed %d</> <fg=blue>Future claims %d</> <fg=magenta>Active & planned %d</>',
|
||||
$this->infinityOr($physical),
|
||||
$this->infinityOr($available),
|
||||
$currentClaims,
|
||||
$futureClaims,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(' <fg=gray>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'],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Tests\Feature\Product;
|
||||
|
||||
use Blax\Shop\Enums\ProductStatus;
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
use Blax\Shop\Enums\PurchaseStatus;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Workbench\App\Models\User;
|
||||
|
||||
/**
|
||||
* Coverage for the new physical_stock accessor on Product (via HasStocks).
|
||||
*
|
||||
* The concept: physical = available + currently claimed + active loans.
|
||||
* It represents the count of units the business still owns regardless of
|
||||
* whether they're temporarily checked out. The three test classes below
|
||||
* each exercise the formula on a different product shape:
|
||||
*
|
||||
* - Tomato shop → DECREASE is permanent, physical == available.
|
||||
* - Library book → DECREASE on loan, INCREASE on return; physical stays
|
||||
* at the catalogue size throughout the cycle.
|
||||
* - Hotel room → CLAIMED for bookings; physical includes active claims.
|
||||
*/
|
||||
class PhysicalStockTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function tomato(int $initialStock): Product
|
||||
{
|
||||
$tomato = Product::create([
|
||||
'name' => '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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue