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
|
private function renderSummaryCounters(Product $product): void
|
||||||
{
|
{
|
||||||
|
$physical = $product->getPhysicalStock();
|
||||||
$available = $product->getAvailableStock();
|
$available = $product->getAvailableStock();
|
||||||
$currentClaims = $product->getCurrentlyClaimedStock();
|
$currentClaims = $product->getCurrentlyClaimedStock();
|
||||||
$futureClaims = $product->getFutureClaimedStock();
|
$futureClaims = $product->getFutureClaimedStock();
|
||||||
$activeAndPlanned = $product->getActiveAndPlannedClaimedStock();
|
$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(
|
$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),
|
$this->infinityOr($available),
|
||||||
$currentClaims,
|
$currentClaims,
|
||||||
$futureClaims,
|
$futureClaims,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,15 @@ class ShopStatsCommand extends Command
|
||||||
$rows[] = ['Products: published', $publishedProducts];
|
$rows[] = ['Products: published', $publishedProducts];
|
||||||
$rows[] = ['Products: visible', $visibleProducts];
|
$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[] = ['---', '---'];
|
$rows[] = ['---', '---'];
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class ShopStocksCommand extends Command
|
||||||
|
|
||||||
$rows = $products->map(function (Product $product): array {
|
$rows = $products->map(function (Product $product): array {
|
||||||
$assigned = $product->manage_stock ? $this->assignedCapacity($product) : null;
|
$assigned = $product->manage_stock ? $this->assignedCapacity($product) : null;
|
||||||
|
$physical = $product->manage_stock ? $product->getPhysicalStock() : null;
|
||||||
$used = $product->manage_stock ? $this->totalUsed($product) : null;
|
$used = $product->manage_stock ? $this->totalUsed($product) : null;
|
||||||
$available = $product->getAvailableStock();
|
$available = $product->getAvailableStock();
|
||||||
$claimed = $product->getCurrentlyClaimedStock();
|
$claimed = $product->getCurrentlyClaimedStock();
|
||||||
|
|
@ -48,6 +49,7 @@ class ShopStocksCommand extends Command
|
||||||
'name' => $this->truncate((string) $product->name, 30),
|
'name' => $this->truncate((string) $product->name, 30),
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'assigned' => $assigned === null ? '∞' : (string) $assigned,
|
'assigned' => $assigned === null ? '∞' : (string) $assigned,
|
||||||
|
'physical' => $physical === null ? '∞' : (string) $physical,
|
||||||
'used' => $used === null ? '—' : (string) $used,
|
'used' => $used === null ? '—' : (string) $used,
|
||||||
'available' => $available === PHP_INT_MAX ? '∞' : (string) $available,
|
'available' => $available === PHP_INT_MAX ? '∞' : (string) $available,
|
||||||
'claimed' => (string) $claimed,
|
'claimed' => (string) $claimed,
|
||||||
|
|
@ -56,7 +58,7 @@ class ShopStocksCommand extends Command
|
||||||
|
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
$this->table(
|
$this->table(
|
||||||
['ID', 'Name', 'Type', 'Assigned', 'Used', 'Available', 'Claimed'],
|
['ID', 'Name', 'Type', 'Assigned', 'Physical', 'Used', 'Available', 'Claimed'],
|
||||||
$rows,
|
$rows,
|
||||||
);
|
);
|
||||||
$this->line(' <fg=gray>Total products: '.$products->count().' '.
|
$this->line(' <fg=gray>Total products: '.$products->count().' '.
|
||||||
|
|
@ -89,6 +91,7 @@ class ShopStocksCommand extends Command
|
||||||
}
|
}
|
||||||
|
|
||||||
$assigned = $this->assignedCapacity($product);
|
$assigned = $this->assignedCapacity($product);
|
||||||
|
$physical = $product->getPhysicalStock();
|
||||||
$used = $this->totalUsed($product);
|
$used = $this->totalUsed($product);
|
||||||
$available = $product->getAvailableStock();
|
$available = $product->getAvailableStock();
|
||||||
$currentClaims = $product->getCurrentlyClaimedStock();
|
$currentClaims = $product->getCurrentlyClaimedStock();
|
||||||
|
|
@ -97,6 +100,7 @@ class ShopStocksCommand extends Command
|
||||||
|
|
||||||
$this->renderTotalsBox([
|
$this->renderTotalsBox([
|
||||||
['ASSIGNED', $assigned, 'cyan'],
|
['ASSIGNED', $assigned, 'cyan'],
|
||||||
|
['PHYSICAL', $physical, 'cyan'],
|
||||||
['USED', $used, 'gray'],
|
['USED', $used, 'gray'],
|
||||||
['AVAILABLE', $available, $available > 0 ? 'green' : 'red'],
|
['AVAILABLE', $available, $available > 0 ? 'green' : 'red'],
|
||||||
['CLAIMED NOW', $currentClaims, $currentClaims > 0 ? 'yellow' : 'gray'],
|
['CLAIMED NOW', $currentClaims, $currentClaims > 0 ? 'yellow' : 'gray'],
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ trait HasStocks
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if product is in stock
|
* Check if product is in stock
|
||||||
*
|
*
|
||||||
* @return bool True if stock management is disabled OR available stock > 0
|
* @return bool True if stock management is disabled OR available stock > 0
|
||||||
*/
|
*/
|
||||||
public function isInStock(): bool
|
public function isInStock(): bool
|
||||||
|
|
@ -143,6 +143,72 @@ trait HasStocks
|
||||||
return $this->getAvailableStock() > 0;
|
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)
|
* Decrease physical stock (inventory reduction)
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,35 @@ class CommandStatsTest extends TestCase
|
||||||
$this->assertMatchesRegularExpression('/Revenue \(paid\)\s*\|\s*40\.00\b/', $output);
|
$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]
|
#[Test]
|
||||||
public function shop_stats_includes_carts_and_orders_when_models_are_configured(): void
|
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) ─────────────── */
|
/* ─────────────────── 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]
|
#[Test]
|
||||||
public function shop_stocks_availability_headline_reads_zero_for_a_fully_loaned_book(): void
|
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