R loan purchase to physical loan, A physical loan stuff
This commit is contained in:
parent
da6d89f668
commit
1ed301b759
|
|
@ -5,32 +5,37 @@ declare(strict_types=1);
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StockType Enum
|
* StockType — the kind of movement a {@see \Blax\Shop\Models\ProductStock}
|
||||||
|
* row represents.
|
||||||
*
|
*
|
||||||
* Defines the types of stock movements that can occur.
|
* Two-axis classification:
|
||||||
*
|
*
|
||||||
* Types:
|
* 1. Sign of the stock movement
|
||||||
* - CLAIMED: Stock claimed for reservation/booking (creates PENDING entry)
|
* INCREASE / RETURN → positive (stock added)
|
||||||
* Used for temporary allocations that can be released
|
* DECREASE → negative (stock removed)
|
||||||
* Examples: hotel bookings, equipment rentals, cart reservations
|
* CLAIMED → positive quantity but stored as a PENDING
|
||||||
|
* reservation that nets to negative against
|
||||||
|
* available stock until the claim is released
|
||||||
|
* PHYSICALLY_CLAIMED → same as CLAIMED, but never auto-released by
|
||||||
|
* {@see \Blax\Shop\Models\ProductStock::releaseExpired()}.
|
||||||
|
* Used for loans: the borrower physically has the
|
||||||
|
* item until they return it — the expires_at column
|
||||||
|
* carries the due date for overdue tracking, not a
|
||||||
|
* release deadline.
|
||||||
*
|
*
|
||||||
* - RETURN: Stock returned to inventory (e.g., customer returns)
|
* 2. Release model
|
||||||
* Creates a positive adjustment to physical stock
|
* INCREASE / DECREASE / RETURN → COMPLETED at write time, permanent
|
||||||
|
* CLAIMED → PENDING, auto-released at expires_at
|
||||||
|
* PHYSICALLY_CLAIMED → PENDING, manual release only
|
||||||
*
|
*
|
||||||
* - INCREASE: Stock added to inventory (e.g., new purchases, restocking)
|
* The two claim types share availability semantics — both subtract from
|
||||||
* Creates a positive adjustment to physical stock
|
* `available` and both contribute to `currently_claimed` — so most queries
|
||||||
*
|
* filter by {@see self::claimTypeValues()} rather than a single case.
|
||||||
* - DECREASE: Stock removed from inventory (e.g., sales, damage, loss)
|
|
||||||
* Creates a negative adjustment to physical stock
|
|
||||||
*
|
|
||||||
* Usage Flow:
|
|
||||||
* 1. INCREASE/DECREASE: Direct physical stock changes (COMPLETED status)
|
|
||||||
* 2. CLAIMED: Temporary allocation (PENDING status, can be released)
|
|
||||||
* 3. RETURN: Special case of INCREASE for returned items
|
|
||||||
*/
|
*/
|
||||||
enum StockType: string
|
enum StockType: string
|
||||||
{
|
{
|
||||||
case CLAIMED = 'claimed';
|
case CLAIMED = 'claimed';
|
||||||
|
case PHYSICALLY_CLAIMED = 'physically_claimed';
|
||||||
case RETURN = 'return';
|
case RETURN = 'return';
|
||||||
case INCREASE = 'increase';
|
case INCREASE = 'increase';
|
||||||
case DECREASE = 'decrease';
|
case DECREASE = 'decrease';
|
||||||
|
|
@ -39,9 +44,38 @@ enum StockType: string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::CLAIMED => 'Claimed',
|
self::CLAIMED => 'Claimed',
|
||||||
|
self::PHYSICALLY_CLAIMED => 'Physically claimed',
|
||||||
self::RETURN => 'Return',
|
self::RETURN => 'Return',
|
||||||
self::INCREASE => 'Increase',
|
self::INCREASE => 'Increase',
|
||||||
self::DECREASE => 'Decrease',
|
self::DECREASE => 'Decrease',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The claim-style types — both reserve stock against availability and
|
||||||
|
* keep a PENDING row in the ledger until released. CLAIMED auto-releases
|
||||||
|
* at `expires_at`; PHYSICALLY_CLAIMED is manual-release only.
|
||||||
|
*
|
||||||
|
* @return array<int, self>
|
||||||
|
*/
|
||||||
|
public static function claimTypes(): array
|
||||||
|
{
|
||||||
|
return [self::CLAIMED, self::PHYSICALLY_CLAIMED];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@see self::claimTypes()} but as string values, for use in
|
||||||
|
* `whereIn(...)` SQL clauses.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function claimTypeValues(): array
|
||||||
|
{
|
||||||
|
return [self::CLAIMED->value, self::PHYSICALLY_CLAIMED->value];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isClaim(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, self::claimTypes(), strict: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,15 +159,31 @@ class ProductStock extends Model
|
||||||
* @param string|null $note Optional note about the claim
|
* @param string|null $note Optional note about the claim
|
||||||
* @return self|null The created claim entry, or null if insufficient stock
|
* @return self|null The created claim entry, or null if insufficient stock
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Claim stock for a product (reservation/booking/loan).
|
||||||
|
*
|
||||||
|
* Creates a two-row entry:
|
||||||
|
* 1. DECREASE row (negative quantity, COMPLETED) — removes from
|
||||||
|
* `available` immediately.
|
||||||
|
* 2. Claim row (positive quantity, PENDING) — tracks the reservation
|
||||||
|
* until released. `$type` controls whether the claim auto-releases
|
||||||
|
* at `expires_at` (CLAIMED) or stays manual-release only
|
||||||
|
* (PHYSICALLY_CLAIMED, used for loans).
|
||||||
|
*
|
||||||
|
* @param StockType $type Either CLAIMED (default — booking-style,
|
||||||
|
* {@see self::releaseExpired()} eligible) or PHYSICALLY_CLAIMED
|
||||||
|
* (loan-style, manual release only).
|
||||||
|
*/
|
||||||
public static function claim(
|
public static function claim(
|
||||||
Product $product,
|
Product $product,
|
||||||
int $quantity,
|
int $quantity,
|
||||||
$reference = null,
|
$reference = null,
|
||||||
?\DateTimeInterface $from = null,
|
?\DateTimeInterface $from = null,
|
||||||
?\DateTimeInterface $until = null,
|
?\DateTimeInterface $until = null,
|
||||||
?string $note = null
|
?string $note = null,
|
||||||
|
StockType $type = StockType::CLAIMED,
|
||||||
): ?self {
|
): ?self {
|
||||||
return DB::transaction(function () use ($product, $quantity, $reference, $from, $until, $note) {
|
return DB::transaction(function () use ($product, $quantity, $reference, $from, $until, $note, $type) {
|
||||||
// When claiming for a future booking, check availability at the start date
|
// When claiming for a future booking, check availability at the start date
|
||||||
// Otherwise claims for different time periods would incorrectly conflict
|
// Otherwise claims for different time periods would incorrectly conflict
|
||||||
$checkDate = $from ?? now();
|
$checkDate = $from ?? now();
|
||||||
|
|
@ -193,7 +209,7 @@ class ProductStock extends Model
|
||||||
return self::create([
|
return self::create([
|
||||||
'product_id' => $product->id,
|
'product_id' => $product->id,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'type' => StockType::CLAIMED,
|
'type' => $type,
|
||||||
'status' => StockStatus::PENDING,
|
'status' => StockStatus::PENDING,
|
||||||
'reference_type' => $reference ? get_class($reference) : null,
|
'reference_type' => $reference ? get_class($reference) : null,
|
||||||
'reference_id' => $reference?->id,
|
'reference_id' => $reference?->id,
|
||||||
|
|
@ -304,7 +320,14 @@ class ProductStock extends Model
|
||||||
*/
|
*/
|
||||||
public static function releaseExpired(): int
|
public static function releaseExpired(): int
|
||||||
{
|
{
|
||||||
$expired = self::expired()->get();
|
// Auto-release only the CLAIMED type. PHYSICALLY_CLAIMED rows carry
|
||||||
|
// an `expires_at` to drive overdue tracking on the loan side, but
|
||||||
|
// they must NOT be auto-released — the borrower physically has the
|
||||||
|
// item until they bring it back, regardless of how overdue the
|
||||||
|
// due date is.
|
||||||
|
$expired = self::expired()
|
||||||
|
->where('type', StockType::CLAIMED->value)
|
||||||
|
->get();
|
||||||
$count = 0;
|
$count = 0;
|
||||||
|
|
||||||
foreach ($expired as $stock) {
|
foreach ($expired as $stock) {
|
||||||
|
|
@ -331,7 +354,9 @@ class ProductStock extends Model
|
||||||
*/
|
*/
|
||||||
public function scopeAvailableClaims($query)
|
public function scopeAvailableClaims($query)
|
||||||
{
|
{
|
||||||
return $query->where('type', StockType::CLAIMED->value)->where('status', StockStatus::PENDING->value);
|
return $query
|
||||||
|
->whereIn('type', StockType::claimTypeValues())
|
||||||
|
->where('status', StockStatus::PENDING->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -360,7 +385,7 @@ class ProductStock extends Model
|
||||||
*/
|
*/
|
||||||
public function scopeAvailableOnDate($query, \DateTimeInterface $date)
|
public function scopeAvailableOnDate($query, \DateTimeInterface $date)
|
||||||
{
|
{
|
||||||
return $query->where('type', StockType::CLAIMED->value)
|
return $query->whereIn('type', StockType::claimTypeValues())
|
||||||
->where('status', StockStatus::PENDING->value)
|
->where('status', StockStatus::PENDING->value)
|
||||||
->where(function ($q) use ($date) {
|
->where(function ($q) use ($date) {
|
||||||
$q->where(function ($subQuery) use ($date) {
|
$q->where(function ($subQuery) use ($date) {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,15 @@ declare(strict_types=1);
|
||||||
namespace Blax\Shop\Traits;
|
namespace Blax\Shop\Traits;
|
||||||
|
|
||||||
use Blax\Shop\Enums\PurchaseStatus;
|
use Blax\Shop\Enums\PurchaseStatus;
|
||||||
|
use Blax\Shop\Enums\StockStatus;
|
||||||
|
use Blax\Shop\Enums\StockType;
|
||||||
use Blax\Shop\Events\LoanExtended;
|
use Blax\Shop\Events\LoanExtended;
|
||||||
use Blax\Shop\Events\LoanReturned;
|
use Blax\Shop\Events\LoanReturned;
|
||||||
use Blax\Shop\Models\ProductPrice;
|
use Blax\Shop\Models\ProductPrice;
|
||||||
|
use Blax\Shop\Models\ProductStock;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -145,30 +149,86 @@ trait HasLoanLifecycle
|
||||||
$this->meta = $meta;
|
$this->meta = $meta;
|
||||||
$this->save();
|
$this->save();
|
||||||
|
|
||||||
|
// Push the paired physical claim's expires_at to match the new due
|
||||||
|
// date so calendar/overdue checks at the stock level stay in sync
|
||||||
|
// with the purchase's `until`. Loans created before this rewiring
|
||||||
|
// (no linked claim) silently skip.
|
||||||
|
if ($this->until !== null) {
|
||||||
|
$this->loanClaimQuery()
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->update(['expires_at' => $this->until]);
|
||||||
|
}
|
||||||
|
|
||||||
event(new LoanExtended($this, $weeks));
|
event(new LoanExtended($this, $weeks));
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the item returned: stamp meta.returned_at with the given moment
|
* Mark the item returned: stamp meta.returned_at, flip status to
|
||||||
* (or now()) and flip status to completed so the row reads as final.
|
* COMPLETED, and release the paired PHYSICALLY_CLAIMED stock entry
|
||||||
|
* (which automatically creates the offsetting RETURN row via
|
||||||
|
* {@see ProductStock::release()}). All three operations happen inside
|
||||||
|
* a single transaction so physical inventory is consistent at every
|
||||||
|
* observable instant.
|
||||||
|
*
|
||||||
|
* Idempotent: a second call on an already-returned loan is a no-op —
|
||||||
|
* the returned_at timestamp is not restamped and the claim is not
|
||||||
|
* re-released.
|
||||||
*/
|
*/
|
||||||
public function markReturned(?\DateTimeInterface $at = null): self
|
public function markReturned(?\DateTimeInterface $at = null): self
|
||||||
{
|
{
|
||||||
|
if ($this->isReturned()) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
$at ??= now();
|
$at ??= now();
|
||||||
|
|
||||||
$meta = (array) ($this->meta ?? []);
|
DB::transaction(function () use ($at) {
|
||||||
$meta['returned_at'] = Carbon::instance($at)->toIso8601String();
|
$meta = (array) ($this->meta ?? []);
|
||||||
$this->meta = $meta;
|
$meta['returned_at'] = Carbon::instance($at)->toIso8601String();
|
||||||
$this->status = PurchaseStatus::COMPLETED;
|
$this->meta = $meta;
|
||||||
$this->save();
|
$this->status = PurchaseStatus::COMPLETED;
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
// Release the paired physical claim. Each release() call
|
||||||
|
// creates a RETURN entry that offsets the DECREASE written
|
||||||
|
// alongside the original claim at checkout, so available stock
|
||||||
|
// naturally restores. For loans created before this rewiring
|
||||||
|
// (no linked claim), this short-circuits and the host is still
|
||||||
|
// responsible for the increaseStock(1) — but new code paths
|
||||||
|
// won't need to do anything.
|
||||||
|
$this->loanClaimQuery()->each(fn (ProductStock $claim) => $claim->release());
|
||||||
|
});
|
||||||
|
|
||||||
event(new LoanReturned($this));
|
event(new LoanReturned($this));
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder for the PHYSICALLY_CLAIMED stock row(s) created alongside
|
||||||
|
* this loan at checkout. Used by {@see self::markReturned()} (to
|
||||||
|
* release the claim) and {@see self::extend()} (to push expires_at).
|
||||||
|
*
|
||||||
|
* Polymorphic lookup uses the purchase's primary key directly — the
|
||||||
|
* reference_type was stored as the concrete ProductPurchase class at
|
||||||
|
* checkout time, but we look up by id alone since UUIDs are unique
|
||||||
|
* across the table. Filters to PENDING + PHYSICALLY_CLAIMED so any
|
||||||
|
* already-released or unrelated rows are skipped.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Builder<ProductStock>
|
||||||
|
*/
|
||||||
|
protected function loanClaimQuery(): \Illuminate\Database\Eloquent\Builder
|
||||||
|
{
|
||||||
|
$stockModel = config('shop.models.product_stock', ProductStock::class);
|
||||||
|
|
||||||
|
return $stockModel::query()
|
||||||
|
->where('reference_id', $this->getKey())
|
||||||
|
->where('type', StockType::PHYSICALLY_CLAIMED->value)
|
||||||
|
->where('status', StockStatus::PENDING->value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope: loans currently in the borrower's hands (not returned).
|
* Scope: loans currently in the borrower's hands (not returned).
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ trait HasStocks
|
||||||
{
|
{
|
||||||
return $this->stocks()
|
return $this->stocks()
|
||||||
->available()
|
->available()
|
||||||
->where('type', '!=', StockType::CLAIMED->value)
|
->whereNotIn('type', StockType::claimTypeValues())
|
||||||
->willExpire()
|
->willExpire()
|
||||||
->sum('quantity') ?? 0;
|
->sum('quantity') ?? 0;
|
||||||
}
|
}
|
||||||
|
|
@ -148,29 +148,27 @@ trait HasStocks
|
||||||
* regardless of whether they're temporarily out (on loan, claimed by a
|
* regardless of whether they're temporarily out (on loan, claimed by a
|
||||||
* cart/booking) or sitting on the shelf.
|
* cart/booking) or sitting on the shelf.
|
||||||
*
|
*
|
||||||
* available + currentlyClaimed + activeLoans
|
* physical = available + currentlyClaimed
|
||||||
*
|
*
|
||||||
* Why three terms?
|
* Why only two terms? Both CLAIMED and PHYSICALLY_CLAIMED rows count
|
||||||
* - available — units on the shelf, free for new use.
|
* toward `currentlyClaimed` (see {@see StockType::claimTypeValues()}),
|
||||||
* - currentlyClaimed — units held by a cart, booking, or other
|
* so cart reservations, bookings, AND loans all flow through the same
|
||||||
* reservation that hasn't been finalised
|
* claim machinery. Loans no longer need a separate `activeLoans` term —
|
||||||
* (will come back if the claim expires).
|
* the PHYSICALLY_CLAIMED stock row IS the active loan.
|
||||||
* - 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:
|
* Worked examples:
|
||||||
* - Tomato shop: bought 10, sold 3 → DECREASE -3 is permanent (no
|
* - Tomato shop: bought 10, sold 3 → DECREASE -3 is permanent (no
|
||||||
* claim/loan to offset). Physical = 7. Available = 7.
|
* claim/loan to offset). Physical = 7. Available = 7.
|
||||||
* - Library: bought 5, loaned 1 → DECREASE -1 + active loan → +1.
|
* - Library: bought 5, loaned 1 → one PHYSICALLY_CLAIMED row.
|
||||||
* Physical = 4 + 0 + 1 = 5. Available = 4.
|
* Available = 4, currentlyClaimed = 1, physical = 5.
|
||||||
* - Hotel: 1 room, future booking → claim sits in the future, no
|
* - Hotel: 1 room, future booking → CLAIMED row, claimed_from
|
||||||
* current claim yet. Physical = 1 + 0 + 0 = 1.
|
* in the future. Available = 1 today, currentlyClaimed = 0 today,
|
||||||
|
* physical = 1.
|
||||||
*
|
*
|
||||||
* Distinct from {@see self::getMaxStocksAttribute} (which sums every
|
* Distinct from {@see self::getMaxStocksAttribute} (which sums every
|
||||||
* INCREASE/RETURN row ever written and so inflates after every loan
|
* INCREASE/RETURN row ever written and so inflates after every loan
|
||||||
* return), and from {@see \Blax\Shop\Traits\MayBeLoanableProduct::getTotalQuantityAttribute}
|
* return cycle) and from
|
||||||
|
* {@see \Blax\Shop\Traits\MayBeLoanableProduct::getTotalQuantityAttribute}
|
||||||
* (which is loanable-only). This one works for every Product type.
|
* (which is loanable-only). This one works for every Product type.
|
||||||
*/
|
*/
|
||||||
public function getPhysicalStockAttribute(): int
|
public function getPhysicalStockAttribute(): int
|
||||||
|
|
@ -179,25 +177,7 @@ trait HasStocks
|
||||||
return PHP_INT_MAX;
|
return PHP_INT_MAX;
|
||||||
}
|
}
|
||||||
|
|
||||||
$available = $this->getAvailableStock();
|
return $this->getAvailableStock() + $this->getCurrentlyClaimedStock();
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -354,14 +334,16 @@ trait HasStocks
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For CLAIMED type, delegate to claimStock which handles the two-entry pattern
|
// For claim-style types, delegate to claimStock which handles the
|
||||||
if ($type === StockType::CLAIMED) {
|
// two-entry pattern (DECREASE + PENDING claim row).
|
||||||
|
if ($type->isClaim()) {
|
||||||
return $this->claimStock(
|
return $this->claimStock(
|
||||||
quantity: $quantity,
|
quantity: $quantity,
|
||||||
reference: $referencable,
|
reference: $referencable,
|
||||||
from: $from,
|
from: $from,
|
||||||
until: $until,
|
until: $until,
|
||||||
note: $note
|
note: $note,
|
||||||
|
type: $type
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -395,24 +377,22 @@ trait HasStocks
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claim stock for temporary use (reservation/booking)
|
* Claim stock for temporary use (reservation/booking/loan).
|
||||||
*
|
*
|
||||||
* This is different from decreaseStock - it:
|
* Different from decreaseStock — it:
|
||||||
* 1. Removes stock from available inventory (via DECREASE entry)
|
* 1. Removes stock from available inventory (via DECREASE entry)
|
||||||
* 2. Tracks it as a claim (via CLAIMED entry with PENDING status)
|
* 2. Tracks it as a claim (via CLAIMED/PHYSICALLY_CLAIMED entry with PENDING status)
|
||||||
* 3. Can be released back later (changes CLAIMED to COMPLETED)
|
* 3. Can be released later (status PENDING → COMPLETED + paired RETURN)
|
||||||
* 4. Supports date ranges for bookings (claimed_from to expires_at)
|
* 4. Supports date ranges (claimed_from → expires_at)
|
||||||
*
|
*
|
||||||
* Use cases:
|
* Use cases:
|
||||||
* - Hotel room bookings (claimed_from = check-in, expires_at = check-out)
|
* - Hotel room bookings → CLAIMED, expires_at = check-out (auto-releases).
|
||||||
* - Equipment rentals (claimed_from = rental start, expires_at = return date)
|
* - Cart reservations → CLAIMED, expires_at = cart expiry (auto-releases).
|
||||||
* - Cart reservations (no claimed_from, expires_at = cart expiry)
|
* - Library loans → PHYSICALLY_CLAIMED, expires_at = due date
|
||||||
|
* (overdue tracking only — does not auto-release).
|
||||||
*
|
*
|
||||||
* @param int $quantity Amount to claim
|
* @param StockType $type CLAIMED (default, auto-release) or
|
||||||
* @param mixed $reference Optional reference model (Order, Booking, Cart, etc.)
|
* PHYSICALLY_CLAIMED (manual release only — used by loans).
|
||||||
* @param DateTimeInterface|null $from When claim starts (null = immediately)
|
|
||||||
* @param DateTimeInterface|null $until When claim expires (null = permanent)
|
|
||||||
* @param string|null $note Optional note about the claim
|
|
||||||
* @return \Blax\Shop\Models\ProductStock|null The claim entry, or null if insufficient stock
|
* @return \Blax\Shop\Models\ProductStock|null The claim entry, or null if insufficient stock
|
||||||
*/
|
*/
|
||||||
public function claimStock(
|
public function claimStock(
|
||||||
|
|
@ -420,7 +400,8 @@ trait HasStocks
|
||||||
$reference = null,
|
$reference = null,
|
||||||
?DateTimeInterface $from = null,
|
?DateTimeInterface $from = null,
|
||||||
?DateTimeInterface $until = null,
|
?DateTimeInterface $until = null,
|
||||||
?string $note = null
|
?string $note = null,
|
||||||
|
StockType $type = StockType::CLAIMED,
|
||||||
): ?\Blax\Shop\Models\ProductStock {
|
): ?\Blax\Shop\Models\ProductStock {
|
||||||
|
|
||||||
if (!$this->manage_stock) {
|
if (!$this->manage_stock) {
|
||||||
|
|
@ -437,7 +418,8 @@ trait HasStocks
|
||||||
$reference,
|
$reference,
|
||||||
$from,
|
$from,
|
||||||
$until,
|
$until,
|
||||||
$note
|
$note,
|
||||||
|
$type,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($claim) {
|
if ($claim) {
|
||||||
|
|
@ -478,7 +460,7 @@ trait HasStocks
|
||||||
$baseStock = $this->stocks()
|
$baseStock = $this->stocks()
|
||||||
->withoutGlobalScope('willExpire')
|
->withoutGlobalScope('willExpire')
|
||||||
->where('status', StockStatus::COMPLETED->value)
|
->where('status', StockStatus::COMPLETED->value)
|
||||||
->where('type', '!=', StockType::CLAIMED->value)
|
->whereNotIn('type', StockType::claimTypeValues())
|
||||||
->where(function ($query) use ($date) {
|
->where(function ($query) use ($date) {
|
||||||
$query->whereNull('expires_at')
|
$query->whereNull('expires_at')
|
||||||
->orWhere('expires_at', '>', $date);
|
->orWhere('expires_at', '>', $date);
|
||||||
|
|
@ -488,16 +470,20 @@ trait HasStocks
|
||||||
// Add back claims that should not reduce availability at the given date
|
// Add back claims that should not reduce availability at the given date
|
||||||
$inactiveClaims = $this->stocks()
|
$inactiveClaims = $this->stocks()
|
||||||
->withoutGlobalScope('willExpire')
|
->withoutGlobalScope('willExpire')
|
||||||
->where('type', StockType::CLAIMED->value)
|
->whereIn('type', StockType::claimTypeValues())
|
||||||
->where('status', StockStatus::PENDING->value)
|
->where('status', StockStatus::PENDING->value)
|
||||||
->where(function ($query) use ($date) {
|
->where(function ($query) use ($date) {
|
||||||
$query->where(function ($q) use ($date) {
|
$query->where(function ($q) use ($date) {
|
||||||
// Claim has not started yet
|
// Claim has not started yet — applies to both claim types.
|
||||||
$q->whereNotNull('claimed_from')
|
$q->whereNotNull('claimed_from')
|
||||||
->where('claimed_from', '>', $date);
|
->where('claimed_from', '>', $date);
|
||||||
})->orWhere(function ($q) use ($date) {
|
})->orWhere(function ($q) use ($date) {
|
||||||
// Claim expired before the date
|
// Claim expired before the date — only for CLAIMED, which
|
||||||
$q->whereNotNull('expires_at')
|
// auto-releases at expires_at. PHYSICALLY_CLAIMED (loans)
|
||||||
|
// stays reserved until manually returned; expires_at is
|
||||||
|
// informational/overdue-tracking only.
|
||||||
|
$q->where('type', StockType::CLAIMED->value)
|
||||||
|
->whereNotNull('expires_at')
|
||||||
->where('expires_at', '<=', $date);
|
->where('expires_at', '<=', $date);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
@ -518,7 +504,7 @@ trait HasStocks
|
||||||
public function getCurrentlyClaimedStock(): int
|
public function getCurrentlyClaimedStock(): int
|
||||||
{
|
{
|
||||||
return abs($this->stocks()
|
return abs($this->stocks()
|
||||||
->where('type', StockType::CLAIMED->value)
|
->whereIn('type', StockType::claimTypeValues())
|
||||||
->where('status', StockStatus::PENDING->value)
|
->where('status', StockStatus::PENDING->value)
|
||||||
->willExpire()
|
->willExpire()
|
||||||
->where(function ($query) {
|
->where(function ($query) {
|
||||||
|
|
@ -538,7 +524,7 @@ trait HasStocks
|
||||||
public function getActiveAndPlannedClaimedStock(): int
|
public function getActiveAndPlannedClaimedStock(): int
|
||||||
{
|
{
|
||||||
return abs($this->stocks()
|
return abs($this->stocks()
|
||||||
->where('type', StockType::CLAIMED->value)
|
->whereIn('type', StockType::claimTypeValues())
|
||||||
->where('status', StockStatus::PENDING->value)
|
->where('status', StockStatus::PENDING->value)
|
||||||
->willExpire()
|
->willExpire()
|
||||||
->sum('quantity'));
|
->sum('quantity'));
|
||||||
|
|
@ -553,7 +539,7 @@ trait HasStocks
|
||||||
public function getFutureClaimedStock(?DateTimeInterface $from = null): int
|
public function getFutureClaimedStock(?DateTimeInterface $from = null): int
|
||||||
{
|
{
|
||||||
$query = $this->stocks()
|
$query = $this->stocks()
|
||||||
->where('type', StockType::CLAIMED->value)
|
->whereIn('type', StockType::claimTypeValues())
|
||||||
->where('status', StockStatus::PENDING->value)
|
->where('status', StockStatus::PENDING->value)
|
||||||
->willExpire();
|
->willExpire();
|
||||||
|
|
||||||
|
|
@ -677,7 +663,7 @@ trait HasStocks
|
||||||
|
|
||||||
$nextClaimEnd = $this->stocks()
|
$nextClaimEnd = $this->stocks()
|
||||||
->withoutGlobalScope('willExpire')
|
->withoutGlobalScope('willExpire')
|
||||||
->where('type', StockType::CLAIMED->value)
|
->whereIn('type', StockType::claimTypeValues())
|
||||||
->where('status', StockStatus::PENDING->value)
|
->where('status', StockStatus::PENDING->value)
|
||||||
->whereNotNull('expires_at')
|
->whereNotNull('expires_at')
|
||||||
->where('expires_at', '>', now())
|
->where('expires_at', '>', now())
|
||||||
|
|
@ -749,7 +735,7 @@ trait HasStocks
|
||||||
$baseStock = $this->stocks()
|
$baseStock = $this->stocks()
|
||||||
->withoutGlobalScope('willExpire')
|
->withoutGlobalScope('willExpire')
|
||||||
->where('status', StockStatus::COMPLETED->value)
|
->where('status', StockStatus::COMPLETED->value)
|
||||||
->where('type', '!=', StockType::CLAIMED->value)
|
->whereNotIn('type', StockType::claimTypeValues())
|
||||||
->where('created_at', '<=', $dateDayEnd)
|
->where('created_at', '<=', $dateDayEnd)
|
||||||
->where(function ($query) use ($date) {
|
->where(function ($query) use ($date) {
|
||||||
$query->whereNull('expires_at')
|
$query->whereNull('expires_at')
|
||||||
|
|
@ -758,16 +744,19 @@ trait HasStocks
|
||||||
->sum('quantity');
|
->sum('quantity');
|
||||||
|
|
||||||
// Add back claims that should not reduce availability at the given date.
|
// Add back claims that should not reduce availability at the given date.
|
||||||
|
// Same asymmetry as getAvailableStock: PHYSICALLY_CLAIMED rows stay
|
||||||
|
// reserved past expires_at (overdue loan still in borrower's hands).
|
||||||
$inactiveClaims = $this->stocks()
|
$inactiveClaims = $this->stocks()
|
||||||
->withoutGlobalScope('willExpire')
|
->withoutGlobalScope('willExpire')
|
||||||
->where('type', StockType::CLAIMED->value)
|
->whereIn('type', StockType::claimTypeValues())
|
||||||
->where('status', StockStatus::PENDING->value)
|
->where('status', StockStatus::PENDING->value)
|
||||||
->where(function ($query) use ($date) {
|
->where(function ($query) use ($date) {
|
||||||
$query->where(function ($q) use ($date) {
|
$query->where(function ($q) use ($date) {
|
||||||
$q->whereNotNull('claimed_from')
|
$q->whereNotNull('claimed_from')
|
||||||
->where('claimed_from', '>', $date);
|
->where('claimed_from', '>', $date);
|
||||||
})->orWhere(function ($q) use ($date) {
|
})->orWhere(function ($q) use ($date) {
|
||||||
$q->whereNotNull('expires_at')
|
$q->where('type', StockType::CLAIMED->value)
|
||||||
|
->whereNotNull('expires_at')
|
||||||
->where('expires_at', '<=', $date);
|
->where('expires_at', '<=', $date);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
@ -814,10 +803,10 @@ trait HasStocks
|
||||||
// Group conditions with OR to keep them within the product_id scope
|
// Group conditions with OR to keep them within the product_id scope
|
||||||
$query->where(function ($q) {
|
$query->where(function ($q) {
|
||||||
$q->where('status', StockStatus::COMPLETED->value)
|
$q->where('status', StockStatus::COMPLETED->value)
|
||||||
->where('type', '!=', StockType::CLAIMED->value);
|
->whereNotIn('type', StockType::claimTypeValues());
|
||||||
})->orWhere(function ($q) {
|
})->orWhere(function ($q) {
|
||||||
$q->where('status', StockStatus::PENDING->value)
|
$q->where('status', StockStatus::PENDING->value)
|
||||||
->where('type', StockType::CLAIMED->value);
|
->whereIn('type', StockType::claimTypeValues());
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
->get();
|
->get();
|
||||||
|
|
@ -861,7 +850,8 @@ trait HasStocks
|
||||||
foreach ($events as $eventTime) {
|
foreach ($events as $eventTime) {
|
||||||
$available = 0;
|
$available = 0;
|
||||||
foreach ($allStocks as $stock) {
|
foreach ($allStocks as $stock) {
|
||||||
if ($stock->status === StockStatus::COMPLETED && $stock->type !== StockType::CLAIMED) {
|
$isClaim = $stock->type instanceof StockType && $stock->type->isClaim();
|
||||||
|
if ($stock->status === StockStatus::COMPLETED && ! $isClaim) {
|
||||||
// A COMPLETED entry only contributes from the day it
|
// A COMPLETED entry only contributes from the day it
|
||||||
// was created — without this gate, a DECREASE from a
|
// was created — without this gate, a DECREASE from a
|
||||||
// loan placed today would retroactively reduce
|
// loan placed today would retroactively reduce
|
||||||
|
|
@ -874,10 +864,19 @@ trait HasStocks
|
||||||
if ($hasStarted && $notExpired) {
|
if ($hasStarted && $notExpired) {
|
||||||
$available += $stock->quantity;
|
$available += $stock->quantity;
|
||||||
}
|
}
|
||||||
} elseif ($stock->status === StockStatus::PENDING && $stock->type === StockType::CLAIMED) {
|
} elseif ($stock->status === StockStatus::PENDING && $isClaim) {
|
||||||
// Add back if NOT active at this timestamp
|
// Add back if NOT active at this timestamp. For
|
||||||
|
// CLAIMED (auto-release booking) we also add back
|
||||||
|
// past-expires_at — the booking window is over so the
|
||||||
|
// unit is free again. PHYSICALLY_CLAIMED (loans)
|
||||||
|
// stays reserved past expires_at because the
|
||||||
|
// borrower physically has the item until they return
|
||||||
|
// it — the calendar should show "unavailable" even
|
||||||
|
// for overdue loans.
|
||||||
$isNotStarted = $stock->claimed_from && $stock->claimed_from > $eventTime;
|
$isNotStarted = $stock->claimed_from && $stock->claimed_from > $eventTime;
|
||||||
$isExpired = $stock->expires_at && $stock->expires_at <= $eventTime;
|
$isExpired = $stock->type === StockType::CLAIMED
|
||||||
|
&& $stock->expires_at
|
||||||
|
&& $stock->expires_at <= $eventTime;
|
||||||
if ($isNotStarted || $isExpired) {
|
if ($isNotStarted || $isExpired) {
|
||||||
$available += $stock->quantity;
|
$available += $stock->quantity;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -163,12 +163,11 @@ trait MayBeLoanableProduct
|
||||||
|
|
||||||
$weeks ??= (int) config('shop.loan.default_duration_weeks', 2);
|
$weeks ??= (int) config('shop.loan.default_duration_weeks', 2);
|
||||||
$now = Carbon::now();
|
$now = Carbon::now();
|
||||||
|
$until = $now->copy()->addWeeks($weeks);
|
||||||
$price ??= $this->defaultPrice()->first();
|
$price ??= $this->defaultPrice()->first();
|
||||||
|
|
||||||
$purchase = DB::transaction(function () use ($borrower, $weeks, $price, $now): ProductPurchase {
|
$purchase = DB::transaction(function () use ($borrower, $price, $now, $until): ProductPurchase {
|
||||||
$this->decreaseStock(1);
|
$purchase = $this->purchases()->create([
|
||||||
|
|
||||||
return $this->purchases()->create([
|
|
||||||
'purchaser_id' => $borrower->getKey(),
|
'purchaser_id' => $borrower->getKey(),
|
||||||
'purchaser_type' => $borrower::class,
|
'purchaser_type' => $borrower::class,
|
||||||
'price_id' => $price?->id,
|
'price_id' => $price?->id,
|
||||||
|
|
@ -177,9 +176,27 @@ trait MayBeLoanableProduct
|
||||||
'amount_paid' => 0,
|
'amount_paid' => 0,
|
||||||
'status' => PurchaseStatus::PENDING,
|
'status' => PurchaseStatus::PENDING,
|
||||||
'from' => $now,
|
'from' => $now,
|
||||||
'until' => $now->copy()->addWeeks($weeks),
|
'until' => $until,
|
||||||
'meta' => ['extensions_used' => 0],
|
'meta' => ['extensions_used' => 0],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Loan model = booking-style claim that doesn't auto-release.
|
||||||
|
// The PHYSICALLY_CLAIMED row drives availability/calendar and
|
||||||
|
// carries the due date in `expires_at` for overdue tracking,
|
||||||
|
// while ProductStock::releaseExpired() pointedly skips this
|
||||||
|
// type — the borrower physically has the item until they bring
|
||||||
|
// it back, regardless of how overdue they get. markReturned()
|
||||||
|
// releases the claim, which creates the offsetting RETURN entry.
|
||||||
|
$this->claimStock(
|
||||||
|
quantity: 1,
|
||||||
|
reference: $purchase,
|
||||||
|
from: $now,
|
||||||
|
until: $until,
|
||||||
|
note: 'Loan to ' . class_basename($borrower::class) . ' #' . substr((string) $borrower->getKey(), 0, 8),
|
||||||
|
type: \Blax\Shop\Enums\StockType::PHYSICALLY_CLAIMED,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $purchase;
|
||||||
});
|
});
|
||||||
|
|
||||||
event(new LoanCreated($purchase));
|
event(new LoanCreated($purchase));
|
||||||
|
|
|
||||||
|
|
@ -179,24 +179,48 @@ class CheckOutToTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function mark_returned_does_not_restore_stock_intentionally(): void
|
public function mark_returned_releases_the_paired_physical_claim_and_restores_stock(): void
|
||||||
{
|
{
|
||||||
// Locking-in regression test for an opinionated design choice: the
|
// Replaces the prior "must not restore stock" assertion. Loans are
|
||||||
// package's markReturned() flips lifecycle state but leaves stock
|
// now modelled as PHYSICALLY_CLAIMED stock entries; markReturned()
|
||||||
// alone. Hosts that model loans as borrow-and-return (rather than
|
// releases that claim, which creates the offsetting RETURN row via
|
||||||
// permanent ownership transfer) must follow up with an explicit
|
// ProductStock::release() — so available stock comes back to where
|
||||||
// increaseStock(1) — see moonshiner-library's LoanController.
|
// it was before the loan with no host bookkeeping required.
|
||||||
|
$availableBefore = $this->book->fresh()->getAvailableStock();
|
||||||
|
|
||||||
$loan = $this->book->checkOutTo($this->borrower);
|
$loan = $this->book->checkOutTo($this->borrower);
|
||||||
$availableAfterCheckout = $this->book->fresh()->getAvailableStock();
|
$this->assertSame(
|
||||||
|
$availableBefore - 1,
|
||||||
|
$this->book->fresh()->getAvailableStock(),
|
||||||
|
'checkout drops available by quantity',
|
||||||
|
);
|
||||||
|
|
||||||
$loan->markReturned();
|
$loan->markReturned();
|
||||||
|
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
$availableAfterCheckout,
|
$availableBefore,
|
||||||
$this->book->fresh()->getAvailableStock(),
|
$this->book->fresh()->getAvailableStock(),
|
||||||
'markReturned() must not change stock — hosts opt in to that explicitly.',
|
'markReturned() restores available via the released claim',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function mark_returned_is_idempotent_and_does_not_double_restore_stock(): void
|
||||||
|
{
|
||||||
|
// A retried markReturned() call (network flake, double-click on a
|
||||||
|
// librarian button, etc.) must not inflate stock past the catalogue
|
||||||
|
// size by re-releasing an already-released claim.
|
||||||
|
$availableBefore = $this->book->fresh()->getAvailableStock();
|
||||||
|
$loan = $this->book->checkOutTo($this->borrower);
|
||||||
|
|
||||||
|
$loan->markReturned();
|
||||||
|
$afterFirst = $this->book->fresh()->getAvailableStock();
|
||||||
|
$loan->markReturned();
|
||||||
|
$afterSecond = $this->book->fresh()->getAvailableStock();
|
||||||
|
|
||||||
|
$this->assertSame($availableBefore, $afterFirst);
|
||||||
|
$this->assertSame($afterFirst, $afterSecond, 'second call must be a no-op');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -194,12 +194,12 @@ class LoanLifecycleTest extends TestCase
|
||||||
/* ───────────────────── edge cases ───────────────────── */
|
/* ───────────────────── edge cases ───────────────────── */
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function mark_returned_called_twice_keeps_the_first_returned_at_timestamp(): void
|
public function mark_returned_is_idempotent_first_write_wins(): void
|
||||||
{
|
{
|
||||||
// markReturned is idempotent-ish: calling it again overwrites the
|
// markReturned() now no-ops on already-returned loans so a retried
|
||||||
// returned_at timestamp. We document that behaviour explicitly so a
|
// call (network flake, double-submit) can't re-release the paired
|
||||||
// future refactor knows whether to keep it. If you want first-write-
|
// claim and inflate available stock past the catalogue size. The
|
||||||
// wins, change markReturned() to no-op when already returned.
|
// first returned_at timestamp is the canonical one.
|
||||||
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
|
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
|
||||||
$loan = $this->checkout();
|
$loan = $this->checkout();
|
||||||
|
|
||||||
|
|
@ -209,10 +209,11 @@ class LoanLifecycleTest extends TestCase
|
||||||
Carbon::setTestNow(Carbon::parse('2026-05-20 10:00:00'));
|
Carbon::setTestNow(Carbon::parse('2026-05-20 10:00:00'));
|
||||||
$loan->markReturned();
|
$loan->markReturned();
|
||||||
|
|
||||||
$this->assertNotSame($firstReturnedAt, $loan->returnedAt(), 'second call overwrites');
|
$this->assertSame($firstReturnedAt, $loan->returnedAt(), 'second call does not restamp');
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
Carbon::parse('2026-05-20 10:00:00')->toIso8601String(),
|
Carbon::parse('2026-05-14 10:00:00')->toIso8601String(),
|
||||||
$loan->returnedAt(),
|
$loan->returnedAt(),
|
||||||
|
'first write wins',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,11 +95,14 @@ class LoanShopCommandsTest extends TestCase
|
||||||
$book = $this->newBook('Hyperion', 'CMD-HYP');
|
$book = $this->newBook('Hyperion', 'CMD-HYP');
|
||||||
$book->increaseStock(3);
|
$book->increaseStock(3);
|
||||||
|
|
||||||
// Borrow twice, return one. Ledger = seed + 2 decreases + 1 increase.
|
// Borrow twice, return one. With the claim-based loan model the
|
||||||
|
// ledger carries: seed INCREASE, two claim cycles each writing a
|
||||||
|
// DECREASE + PHYSICALLY_CLAIMED row, plus one RETURN from the
|
||||||
|
// released claim — six rows total, but the operator only needs to
|
||||||
|
// see the standard increase/decrease verbs that the command renders.
|
||||||
$loan = $book->checkOutTo($this->borrower);
|
$loan = $book->checkOutTo($this->borrower);
|
||||||
$book->checkOutTo(User::factory()->create());
|
$book->checkOutTo(User::factory()->create());
|
||||||
$loan->markReturned();
|
$loan->markReturned();
|
||||||
$book->increaseStock(1);
|
|
||||||
|
|
||||||
$output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP']);
|
$output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP']);
|
||||||
|
|
||||||
|
|
@ -119,25 +122,20 @@ class LoanShopCommandsTest extends TestCase
|
||||||
public function assigned_for_loanable_product_must_not_inflate_after_a_return_cycle(): void
|
public function assigned_for_loanable_product_must_not_inflate_after_a_return_cycle(): void
|
||||||
{
|
{
|
||||||
// Regression test for the bug where shop:stocks rendered Assigned=4
|
// Regression test for the bug where shop:stocks rendered Assigned=4
|
||||||
// for a 3-copy book that had been borrowed-and-returned once. The
|
// for a 3-copy book that had been borrowed-and-returned once. With
|
||||||
// sequence is: INCREASE +3 (seed), DECREASE -1 (loan), INCREASE +1
|
// the new claim-based loan model the issue disappears at the source:
|
||||||
// (host-driven return restock). getMaxStocksAttribute sums every
|
// checkout writes a DECREASE + PHYSICALLY_CLAIMED pair, the return
|
||||||
// positive entry — so it returned 3 + 1 = 4 — which the command then
|
// releases the claim (status flip + RETURN row), and the net effect
|
||||||
// rendered verbatim. Fix: consult `total_quantity`, which is loan-aware
|
// on every accessor reads exactly 3 copies — no inflation possible.
|
||||||
// (available stock + active loans = physical inventory we own).
|
|
||||||
$book = $this->newBook('Hyperion', 'CMD-HYP-RC');
|
$book = $this->newBook('Hyperion', 'CMD-HYP-RC');
|
||||||
$book->increaseStock(3);
|
$book->increaseStock(3);
|
||||||
|
|
||||||
$loan = $book->checkOutTo($this->borrower);
|
$loan = $book->checkOutTo($this->borrower);
|
||||||
$loan->markReturned();
|
$loan->markReturned();
|
||||||
$book->increaseStock(1);
|
|
||||||
|
|
||||||
// Sanity: model accessors disagree — getMaxStocksAttribute is inflated
|
|
||||||
// (this is the documented limitation of the underlying calc), while
|
|
||||||
// total_quantity reports the truth. The command must use total_quantity.
|
|
||||||
$fresh = $book->fresh();
|
$fresh = $book->fresh();
|
||||||
$this->assertSame(4, $fresh->getMaxStocksAttribute(), 'inflated by design');
|
$this->assertSame(3, (int) $fresh->total_quantity, 'loan-aware accessor reads true count');
|
||||||
$this->assertSame(3, (int) $fresh->total_quantity, 'loan-aware accessor');
|
$this->assertSame(3, $fresh->getPhysicalStock(), 'physical reads true count');
|
||||||
|
|
||||||
// Detail view: ASSIGNED row must be 3, not 4.
|
// Detail view: ASSIGNED row must be 3, not 4.
|
||||||
$output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP-RC']);
|
$output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP-RC']);
|
||||||
|
|
@ -228,8 +226,7 @@ class LoanShopCommandsTest extends TestCase
|
||||||
$book = $this->newBook('Singular', 'CMD-SIN');
|
$book = $this->newBook('Singular', 'CMD-SIN');
|
||||||
$book->increaseStock(1);
|
$book->increaseStock(1);
|
||||||
$loan = $book->checkOutTo($this->borrower);
|
$loan = $book->checkOutTo($this->borrower);
|
||||||
$loan->markReturned();
|
$loan->markReturned(); // releases the claim → restores available
|
||||||
$book->increaseStock(1);
|
|
||||||
|
|
||||||
$output = $this->runOk(ShopAvailabilityCommand::class, [
|
$output = $this->runOk(ShopAvailabilityCommand::class, [
|
||||||
'product' => 'CMD-SIN',
|
'product' => 'CMD-SIN',
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ namespace Blax\Shop\Tests\Feature\Loan;
|
||||||
use Blax\Shop\Enums\ProductType;
|
use Blax\Shop\Enums\ProductType;
|
||||||
use Blax\Shop\Enums\StockStatus;
|
use Blax\Shop\Enums\StockStatus;
|
||||||
use Blax\Shop\Enums\StockType;
|
use Blax\Shop\Enums\StockType;
|
||||||
use Blax\Shop\Events\StockDecreased;
|
use Blax\Shop\Events\StockClaimed;
|
||||||
use Blax\Shop\Events\StockDepleted;
|
use Blax\Shop\Events\StockDepleted;
|
||||||
use Blax\Shop\Events\StockIncreased;
|
use Blax\Shop\Events\StockIncreased;
|
||||||
|
use Blax\Shop\Events\StockReleased;
|
||||||
use Blax\Shop\Events\StockReplenished;
|
use Blax\Shop\Events\StockReplenished;
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
use Blax\Shop\Models\ProductStock;
|
use Blax\Shop\Models\ProductStock;
|
||||||
|
|
@ -44,36 +45,43 @@ class LoanStockEventsTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function checkOutTo_dispatches_stock_decreased_with_correct_payload(): void
|
public function checkOutTo_dispatches_stock_claimed_with_a_physically_claimed_row(): void
|
||||||
{
|
{
|
||||||
Event::fake([StockDecreased::class]);
|
// Loans go through the claim machinery (PHYSICALLY_CLAIMED type), so
|
||||||
|
// the canonical "stock has moved" event is StockClaimed — not
|
||||||
|
// StockDecreased. The bookkeeping DECREASE row that claimStock writes
|
||||||
|
// internally bypasses the public decreaseStock() path (and so does
|
||||||
|
// not fire StockDecreased), matching how bookings have always worked.
|
||||||
|
Event::fake([StockClaimed::class]);
|
||||||
|
|
||||||
$this->book->checkOutTo($this->borrower);
|
$loan = $this->book->checkOutTo($this->borrower);
|
||||||
|
|
||||||
Event::assertDispatched(
|
Event::assertDispatched(
|
||||||
StockDecreased::class,
|
StockClaimed::class,
|
||||||
fn (StockDecreased $e) => $e->product->is($this->book)
|
fn (StockClaimed $e) => $e->product->is($this->book)
|
||||||
&& $e->availableAfter === 2
|
|
||||||
&& $e->entry instanceof ProductStock
|
&& $e->entry instanceof ProductStock
|
||||||
&& (int) $e->entry->quantity === -1
|
&& (int) $e->entry->quantity === 1
|
||||||
&& $e->entry->type === StockType::DECREASE
|
&& $e->entry->type === StockType::PHYSICALLY_CLAIMED
|
||||||
&& $e->entry->status === StockStatus::COMPLETED,
|
&& $e->entry->status === StockStatus::PENDING
|
||||||
|
&& (string) $e->entry->reference_id === (string) $loan->id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function checking_out_the_last_copy_dispatches_stock_depleted(): void
|
public function checking_out_the_last_copy_dispatches_stock_depleted(): void
|
||||||
{
|
{
|
||||||
// 3 copies on the shelf — borrow all three; the third call crosses the
|
// 3 copies on the shelf — borrow all three. The third call crosses
|
||||||
// last-copy boundary so StockDepleted must fire alongside StockDecreased.
|
// the last-copy boundary; claimStock's dispatchStockTransitions fires
|
||||||
|
// StockDepleted regardless of whether the decrement came from a
|
||||||
|
// direct decreaseStock or a claim.
|
||||||
$this->book->checkOutTo(User::factory()->create());
|
$this->book->checkOutTo(User::factory()->create());
|
||||||
$this->book->checkOutTo(User::factory()->create());
|
$this->book->checkOutTo(User::factory()->create());
|
||||||
|
|
||||||
Event::fake([StockDepleted::class, StockDecreased::class]);
|
Event::fake([StockDepleted::class, StockClaimed::class]);
|
||||||
|
|
||||||
$this->book->checkOutTo($this->borrower);
|
$this->book->checkOutTo($this->borrower);
|
||||||
|
|
||||||
Event::assertDispatched(StockDecreased::class);
|
Event::assertDispatched(StockClaimed::class);
|
||||||
Event::assertDispatched(
|
Event::assertDispatched(
|
||||||
StockDepleted::class,
|
StockDepleted::class,
|
||||||
fn (StockDepleted $e) => $e->product->is($this->book),
|
fn (StockDepleted $e) => $e->product->is($this->book),
|
||||||
|
|
@ -91,24 +99,26 @@ class LoanStockEventsTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function restocking_after_a_full_loan_dispatches_stock_replenished(): void
|
public function returning_a_fully_loaned_book_dispatches_replenished_and_released(): void
|
||||||
{
|
{
|
||||||
// Single-copy book, borrow it (depletes to 0), then a host-driven
|
// Single-copy book, borrow it (depletes to 0), then return it.
|
||||||
// increaseStock(1) on the return path must cross 0→>0 and fire
|
// markReturned() releases the paired claim, which creates the
|
||||||
// StockReplenished. Mirrors what moonshiner-library does in
|
// offsetting RETURN entry via the package's release() helper — so
|
||||||
// LoanController::returnLoan after $loan->markReturned().
|
// StockIncreased + StockReleased both fire, and the 0→1 boundary
|
||||||
|
// crossing additionally triggers StockReplenished. No host call
|
||||||
|
// to increaseStock() needed.
|
||||||
$single = EventLoanBook::create(['name' => 'Solitaire', 'sku' => 'SOL-EV-1']);
|
$single = EventLoanBook::create(['name' => 'Solitaire', 'sku' => 'SOL-EV-1']);
|
||||||
$single->increaseStock(1);
|
$single->increaseStock(1);
|
||||||
$loan = $single->checkOutTo($this->borrower);
|
$loan = $single->checkOutTo($this->borrower);
|
||||||
$loan->markReturned();
|
|
||||||
|
|
||||||
$this->assertSame(0, $single->fresh()->getAvailableStock());
|
$this->assertSame(0, $single->fresh()->getAvailableStock());
|
||||||
|
|
||||||
Event::fake([StockReplenished::class, StockIncreased::class]);
|
Event::fake([StockReplenished::class, StockIncreased::class, StockReleased::class]);
|
||||||
|
|
||||||
$single->increaseStock(1);
|
$loan->markReturned();
|
||||||
|
|
||||||
Event::assertDispatched(StockIncreased::class);
|
Event::assertDispatched(StockIncreased::class);
|
||||||
|
Event::assertDispatched(StockReleased::class);
|
||||||
Event::assertDispatched(
|
Event::assertDispatched(
|
||||||
StockReplenished::class,
|
StockReplenished::class,
|
||||||
fn (StockReplenished $e) => $e->product->is($single) && $e->availableAfter === 1,
|
fn (StockReplenished $e) => $e->product->is($single) && $e->availableAfter === 1,
|
||||||
|
|
@ -116,16 +126,15 @@ class LoanStockEventsTest extends TestCase
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function restocking_when_other_copies_are_free_does_not_dispatch_replenished(): void
|
public function returning_when_other_copies_are_free_does_not_dispatch_replenished(): void
|
||||||
{
|
{
|
||||||
// 3-copy book, borrow 1 → 2 available. Returning that copy goes 2→3,
|
// 3-copy book, borrow 1 → 2 available. Returning goes 2→3, NOT a
|
||||||
// NOT a 0→>0 transition, so StockReplenished must stay silent.
|
// 0→>0 transition, so StockReplenished must stay silent.
|
||||||
$loan = $this->book->checkOutTo($this->borrower);
|
$loan = $this->book->checkOutTo($this->borrower);
|
||||||
$loan->markReturned();
|
|
||||||
|
|
||||||
Event::fake([StockReplenished::class]);
|
Event::fake([StockReplenished::class]);
|
||||||
|
|
||||||
$this->book->increaseStock(1);
|
$loan->markReturned();
|
||||||
|
|
||||||
Event::assertNotDispatched(StockReplenished::class);
|
Event::assertNotDispatched(StockReplenished::class);
|
||||||
}
|
}
|
||||||
|
|
@ -133,11 +142,14 @@ class LoanStockEventsTest extends TestCase
|
||||||
#[Test]
|
#[Test]
|
||||||
public function event_wiring_holds_across_a_full_borrow_return_cycle(): void
|
public function event_wiring_holds_across_a_full_borrow_return_cycle(): void
|
||||||
{
|
{
|
||||||
// Full sequence: borrow → return-restock. We assert the relative count
|
// Full sequence: borrow → return. checkOutTo fires StockClaimed (no
|
||||||
// and payload of each event in one go so a future refactor that splits
|
// StockDecreased — claim machinery bypasses it). markReturned fires
|
||||||
// the path can't pass the per-step tests while breaking the rollup.
|
// StockReleased + StockIncreased (the release writes a RETURN entry
|
||||||
|
// through increaseStock). The 3→2 and 2→3 transitions are boundary-
|
||||||
|
// free so StockDepleted/StockReplenished stay quiet.
|
||||||
Event::fake([
|
Event::fake([
|
||||||
StockDecreased::class,
|
StockClaimed::class,
|
||||||
|
StockReleased::class,
|
||||||
StockIncreased::class,
|
StockIncreased::class,
|
||||||
StockDepleted::class,
|
StockDepleted::class,
|
||||||
StockReplenished::class,
|
StockReplenished::class,
|
||||||
|
|
@ -145,9 +157,9 @@ class LoanStockEventsTest extends TestCase
|
||||||
|
|
||||||
$loan = $this->book->checkOutTo($this->borrower);
|
$loan = $this->book->checkOutTo($this->borrower);
|
||||||
$loan->markReturned();
|
$loan->markReturned();
|
||||||
$this->book->increaseStock(1);
|
|
||||||
|
|
||||||
Event::assertDispatchedTimes(StockDecreased::class, 1);
|
Event::assertDispatchedTimes(StockClaimed::class, 1);
|
||||||
|
Event::assertDispatchedTimes(StockReleased::class, 1);
|
||||||
Event::assertDispatchedTimes(StockIncreased::class, 1);
|
Event::assertDispatchedTimes(StockIncreased::class, 1);
|
||||||
Event::assertNotDispatched(StockDepleted::class, '3→2 is not a depletion');
|
Event::assertNotDispatched(StockDepleted::class, '3→2 is not a depletion');
|
||||||
Event::assertNotDispatched(StockReplenished::class, '2→3 is not a replenishment');
|
Event::assertNotDispatched(StockReplenished::class, '2→3 is not a replenishment');
|
||||||
|
|
|
||||||
|
|
@ -125,10 +125,10 @@ class PhysicalStockTest extends TestCase
|
||||||
$loan = $book->checkOutTo($borrower);
|
$loan = $book->checkOutTo($borrower);
|
||||||
$this->assertSame(5, $book->fresh()->getPhysicalStock());
|
$this->assertSame(5, $book->fresh()->getPhysicalStock());
|
||||||
|
|
||||||
// Host-driven return: mark + restock (mirrors moonshiner's
|
// markReturned() now does the full job — releases the paired
|
||||||
// LoanController::returnLoan).
|
// physical claim, which writes the offsetting RETURN entry. No
|
||||||
|
// host-side increaseStock(1) needed.
|
||||||
$loan->markReturned();
|
$loan->markReturned();
|
||||||
$book->increaseStock(1);
|
|
||||||
|
|
||||||
$fresh = $book->fresh();
|
$fresh = $book->fresh();
|
||||||
$this->assertSame(5, $fresh->getAvailableStock());
|
$this->assertSame(5, $fresh->getAvailableStock());
|
||||||
|
|
@ -216,45 +216,38 @@ class PhysicalStockTest extends TestCase
|
||||||
#[Test]
|
#[Test]
|
||||||
public function physical_does_not_inflate_after_a_library_loan_return_cycle(): void
|
public function physical_does_not_inflate_after_a_library_loan_return_cycle(): void
|
||||||
{
|
{
|
||||||
// Regression: getMaxStocksAttribute sums every INCREASE row — including
|
// Regression: getMaxStocksAttribute sums every INCREASE row — the
|
||||||
// the +1 from a loan return — so for a borrow-and-return cycle it
|
// claim/release machinery writes a RETURN row at return time, which
|
||||||
// overstates "Assigned" as 6 on a 5-copy book. physical_stock uses the
|
// is INCREASE-like and so still inflates "Assigned". physical_stock
|
||||||
// available+claims+loans formula instead and stays correctly at 5.
|
// uses the available+claims formula instead and stays correctly at 5
|
||||||
|
// through any number of cycles.
|
||||||
$book = $this->book(5);
|
$book = $this->book(5);
|
||||||
$loan = $book->checkOutTo(User::factory()->create());
|
$loan = $book->checkOutTo(User::factory()->create());
|
||||||
$loan->markReturned();
|
$loan->markReturned();
|
||||||
$book->increaseStock(1);
|
|
||||||
|
|
||||||
$fresh = $book->fresh();
|
$fresh = $book->fresh();
|
||||||
$this->assertSame(6, $fresh->getMaxStocksAttribute(), 'documented limitation: max inflates per cycle');
|
$this->assertGreaterThan(5, $fresh->getMaxStocksAttribute(), 'documented limitation: max inflates per cycle');
|
||||||
$this->assertSame(5, $fresh->getPhysicalStock(), 'physical stays at the real owned count');
|
$this->assertSame(5, $fresh->getPhysicalStock(), 'physical stays at the real owned count');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function loan_quantity_above_one_aggregates_into_physical(): void
|
public function multi_unit_physical_claim_aggregates_into_physical(): void
|
||||||
{
|
{
|
||||||
// Defensive coverage: real-world loans are always quantity=1, but the
|
// Defensive coverage for the claim-based loan model: a
|
||||||
// formula sums purchase.quantity rather than counting rows, so a
|
// PHYSICALLY_CLAIMED row with quantity > 1 should contribute its
|
||||||
// hypothetical multi-unit loan would still account correctly.
|
// full quantity to physical (10 = 7 on shelf + 3 claimed).
|
||||||
$book = $this->book(10);
|
$book = $this->book(10);
|
||||||
$book->decreaseStock(3); // simulate the stock-side of a 3-unit loan
|
|
||||||
$borrower = User::factory()->create();
|
$book->claimStock(
|
||||||
ProductPurchase::create([
|
quantity: 3,
|
||||||
'purchasable_id' => $book->id,
|
from: now(),
|
||||||
'purchasable_type' => Product::class,
|
until: now()->addWeeks(2),
|
||||||
'purchaser_id' => $borrower->id,
|
type: \Blax\Shop\Enums\StockType::PHYSICALLY_CLAIMED,
|
||||||
'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();
|
$fresh = $book->fresh();
|
||||||
$this->assertSame(7, $fresh->getAvailableStock());
|
$this->assertSame(7, $fresh->getAvailableStock());
|
||||||
$this->assertSame(10, $fresh->getPhysicalStock(), '7 on shelf + 3 on loan = 10 owned');
|
$this->assertSame(3, $fresh->getCurrentlyClaimedStock());
|
||||||
|
$this->assertSame(10, $fresh->getPhysicalStock(), '7 on shelf + 3 physically claimed = 10 owned');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue