feat: promote IsLoanableProduct to MayBeLoanableProduct on Product
Renames the host-attached IsLoanableProduct trait to MayBeLoanableProduct
and mixes it directly into Product, matching the existing MayBePoolProduct
shape. Hosts opt in with a single DEFAULT_TYPE constant — no more manual
`use ...Trait` ceremony to forget:
class Book extends Product
{
public const DEFAULT_TYPE = ProductType::LOANABLE;
}
The boot hook reads DEFAULT_TYPE on the concrete class and only applies
the LOANABLE creating-defaults (type, status, is_visible, manage_stock)
when it matches; type-specific helpers (checkOutTo, total_quantity,
available_quantity) early-out via isLoanable() so they're harmless on
non-loanable products. checkOutTo now throws NotLoanableProductException
when called on the wrong type, mirroring NotPoolProductException.
Also fixes total_quantity for the loan lifecycle: previously summed every
INCREASE entry in product_stocks, which inflated the displayed total
after each loan cycle because returns fire increaseStock(). Now reports
physical inventory as availableStock + activeLoans.
README gains a Testing section that surfaces the current phpunit summary
line, plus passing and assertion-count badges linking to it.
This commit is contained in:
parent
afdcd8bc75
commit
8eb1802ef8
17
README.md
17
README.md
|
|
@ -3,6 +3,8 @@
|
|||
# Laravel Shop
|
||||
|
||||
[](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml)
|
||||
[](#testing)
|
||||
[](#testing)
|
||||
[](https://packagist.org/packages/blax-software/laravel-shop)
|
||||
[](https://packagist.org/packages/blax-software/laravel-shop)
|
||||
[](https://packagist.org/packages/blax-software/laravel-shop)
|
||||
|
|
@ -184,13 +186,24 @@ $isAvailable = $room->availableOnDate(now(), now()->addHour());
|
|||
|
||||
## Testing
|
||||
|
||||
To run the package tests:
|
||||
We test this package for many edge cases across every surface — products,
|
||||
stock, pricing strategies, cart/checkout, loan lifecycle, pool aggregation,
|
||||
booking, Stripe sync and the event surface — so host applications can lean
|
||||
on the behaviour with confidence.
|
||||
|
||||
```
|
||||
Tests: 1228, Assertions: 3291
|
||||
```
|
||||
|
||||
CI runs the full suite on every push (see the badge above). To run it
|
||||
locally:
|
||||
|
||||
```bash
|
||||
./vendor/bin/phpunit
|
||||
```
|
||||
|
||||
The tests use an in-memory SQLite database and Orchestra Testbench.
|
||||
The tests use an in-memory SQLite database and Orchestra Testbench, so they
|
||||
run in roughly a minute with no external services required.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class NotLoanableProductException extends Exception
|
||||
{
|
||||
public function __construct(string $message = "This method is only for loanable products.")
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ use Blax\Shop\Traits\HasPrices;
|
|||
use Blax\Shop\Traits\HasPricingStrategy;
|
||||
use Blax\Shop\Traits\HasProductRelations;
|
||||
use Blax\Shop\Traits\HasStocks;
|
||||
use Blax\Shop\Traits\MayBeLoanableProduct;
|
||||
use Blax\Shop\Traits\MayBePoolProduct;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
|
@ -47,6 +48,7 @@ use Illuminate\Support\Facades\Cache;
|
|||
* - {@see HasCategories} — category attachment.
|
||||
* - {@see HasProductRelations} — cross-sells, upsells, pool↔single links.
|
||||
* - {@see MayBePoolProduct} — pool aggregation when `type = POOL`.
|
||||
* - {@see MayBeLoanableProduct} — loan lifecycle when `type = LOANABLE`.
|
||||
* - {@see ChecksIfBooking} — booking-product helpers when `type = BOOKING`.
|
||||
*
|
||||
* Host apps can extend this model (`Book extends Product` style) for free —
|
||||
|
|
@ -87,7 +89,7 @@ use Illuminate\Support\Facades\Cache;
|
|||
*/
|
||||
class Product extends Model implements Purchasable, Cartable
|
||||
{
|
||||
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct, ChecksIfBooking;
|
||||
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasPricingStrategy, HasCategories, HasProductRelations, MayBePoolProduct, MayBeLoanableProduct, ChecksIfBooking;
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ use Carbon\Carbon;
|
|||
* Domain status (returned / overdue / active) is derived; see
|
||||
* {@see getDomainStatus()} and the corresponding scopes.
|
||||
*
|
||||
* The product-side counterpart is {@see IsLoanableProduct}, which exposes a
|
||||
* The product-side counterpart is {@see MayBeLoanableProduct}, which exposes a
|
||||
* `loan()` helper to create a purchase row pre-filled for this lifecycle.
|
||||
*
|
||||
* # Host-model contract
|
||||
|
|
@ -43,7 +43,7 @@ use Carbon\Carbon;
|
|||
* @property \Blax\Shop\Enums\PurchaseStatus $status Set to `COMPLETED` on {@see self::markReturned()}.
|
||||
* @property string|null $price_id FK to {@see ProductPrice} used for cost calculation.
|
||||
* @property-read ProductPrice|null $price Eager-loadable price relation.
|
||||
* @property-read Model|null $purchasable The loaned item — typically a {@see IsLoanableProduct}-using model.
|
||||
* @property-read Model|null $purchasable The loaned item — typically a {@see MayBeLoanableProduct}-using model.
|
||||
*/
|
||||
trait HasLoanLifecycle
|
||||
{
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use Blax\Shop\Enums\ProductType;
|
|||
use Blax\Shop\Enums\PurchaseStatus;
|
||||
use Blax\Shop\Events\LoanCreated;
|
||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||
use Blax\Shop\Exceptions\NotLoanableProductException;
|
||||
use Blax\Shop\Models\ProductPrice;
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
use Carbon\Carbon;
|
||||
|
|
@ -16,60 +17,85 @@ use Illuminate\Database\Eloquent\Model;
|
|||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Drop on a {@see \Blax\Shop\Models\Product} subclass to declare it a
|
||||
* loanable item. Provides:
|
||||
* Mixed into {@see \Blax\Shop\Models\Product} so every product can be asked
|
||||
* "are you loanable?" and, if so, expose the loan-specific helpers below.
|
||||
* Mirrors the shape of {@see MayBePoolProduct}: the helpers early-out for
|
||||
* products whose `type` is not LOANABLE.
|
||||
*
|
||||
* - Sensible defaults on `creating` (type=LOANABLE, manage_stock=true,
|
||||
* status=PUBLISHED, is_visible=true) so callers can omit the e-commerce
|
||||
* columns and just give the product its domain attributes.
|
||||
* Plug-n-pray for host apps: declare the constant and you're done.
|
||||
*
|
||||
* - A virtual `total_quantity` setter that's translated into a stock
|
||||
* INCREASE entry the moment the row is saved — so a single
|
||||
* `Book::create(['title' => …, 'total_quantity' => 3])` produces a
|
||||
* book with three copies in stock.
|
||||
* class Book extends \Blax\Shop\Models\Product
|
||||
* {
|
||||
* public const DEFAULT_TYPE = ProductType::LOANABLE;
|
||||
* }
|
||||
*
|
||||
* - `checkOutTo($borrower, $weeks, $price)` — atomic decrement + purchase
|
||||
* creation + LoanCreated event dispatch, all in one call. Replaces the
|
||||
* DB::transaction + decreaseStock + purchases()->create + event boilerplate
|
||||
* every host controller would otherwise repeat.
|
||||
* The boot hook reads `DEFAULT_TYPE` on the concrete class and, when it's
|
||||
* LOANABLE, applies sensible defaults on `creating` (type, status=PUBLISHED,
|
||||
* is_visible=true, manage_stock=true) so callers can omit e-commerce columns
|
||||
* entirely. Hosts that want a loanable Product *instance* without declaring
|
||||
* the constant can still set `type` explicitly at create time.
|
||||
*
|
||||
* The `total_quantity` virtual fillable translates into a stock INCREASE
|
||||
* entry on `created` when (and only when) the product is loanable:
|
||||
*
|
||||
* Book::create(['name' => …, 'total_quantity' => 3])
|
||||
*
|
||||
* `checkOutTo($borrower, $weeks, $price)` wraps decrement + purchase
|
||||
* creation + LoanCreated dispatch in a single transaction; throws
|
||||
* NotLoanableProductException when called on a non-loanable product.
|
||||
*
|
||||
* Pair with {@see HasLoanLifecycle} on ProductPurchase (already mixed in via
|
||||
* the package's default ProductPurchase model) to get the full borrow →
|
||||
* extend → return state machine for free.
|
||||
*
|
||||
* Example host model:
|
||||
*
|
||||
* class Book extends \Blax\Shop\Models\Product
|
||||
* {
|
||||
* use \Blax\Shop\Traits\IsLoanableProduct;
|
||||
*
|
||||
* public function getTitleAttribute(): ?string { return $this->name; }
|
||||
* public function setTitleAttribute(?string $v): void { $this->attributes['name'] = $v; }
|
||||
* public function getIsbnAttribute(): ?string { return $this->sku; }
|
||||
* public function setIsbnAttribute(?string $v): void { $this->attributes['sku'] = $v; }
|
||||
* }
|
||||
* Host models use the inherited `name` and `sku` columns directly. Anything
|
||||
* domain-specific (author, page count, language…) belongs in
|
||||
* {@see \Blax\Shop\Models\ProductAttribute}, and the public API can rename
|
||||
* columns at the Resource layer.
|
||||
*/
|
||||
trait IsLoanableProduct
|
||||
trait MayBeLoanableProduct
|
||||
{
|
||||
/** Captured by setTotalQuantityAttribute; consumed in created(). */
|
||||
protected int $initialLoanableQuantity = 0;
|
||||
|
||||
public function isLoanable(): bool
|
||||
{
|
||||
return $this->type === ProductType::LOANABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Treat title / isbn / total_quantity as fillable virtual attributes by
|
||||
* default. Hosts that don't need them can override getFillable().
|
||||
* `total_quantity` stays universally fillable — setting it on a
|
||||
* non-loanable product is a no-op (the created() hook below only fires
|
||||
* the stock INCREASE when isLoanable()), so there's no need to gate
|
||||
* the fillable list per type.
|
||||
*/
|
||||
public function getFillable(): array
|
||||
{
|
||||
return array_merge(parent::getFillable(), ['title', 'isbn', 'total_quantity']);
|
||||
return array_merge(parent::getFillable(), ['total_quantity']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical inventory = copies on the shelf + copies currently loaned out.
|
||||
* We can't use getMaxStocksAttribute() here because it sums every
|
||||
* INCREASE entry in product_stocks — including the increaseStock() call
|
||||
* a return fires — which inflates the displayed total after each loan
|
||||
* cycle even though no new copies were ever acquired.
|
||||
*/
|
||||
public function getTotalQuantityAttribute(): int
|
||||
{
|
||||
if (! $this->isLoanable()) {
|
||||
return (int) $this->getMaxStocksAttribute();
|
||||
}
|
||||
|
||||
if (! $this->exists) {
|
||||
return $this->initialLoanableQuantity;
|
||||
}
|
||||
|
||||
return (int) $this->getMaxStocksAttribute();
|
||||
if ($this->manage_stock === false) {
|
||||
return PHP_INT_MAX;
|
||||
}
|
||||
|
||||
return $this->getAvailableStock() + $this->purchases()->activeLoans()->count();
|
||||
}
|
||||
|
||||
public function setTotalQuantityAttribute(int $value): void
|
||||
|
|
@ -83,9 +109,12 @@ trait IsLoanableProduct
|
|||
return $this->getAvailableStock();
|
||||
}
|
||||
|
||||
public static function bootIsLoanableProduct(): void
|
||||
public static function bootMayBeLoanableProduct(): void
|
||||
{
|
||||
static::creating(function ($product): void {
|
||||
if (self::resolveDefaultProductType($product) !== ProductType::LOANABLE) {
|
||||
return;
|
||||
}
|
||||
$product->type ??= ProductType::LOANABLE;
|
||||
$product->status ??= ProductStatus::PUBLISHED;
|
||||
$product->is_visible ??= true;
|
||||
|
|
@ -93,12 +122,19 @@ trait IsLoanableProduct
|
|||
});
|
||||
|
||||
static::created(function ($product): void {
|
||||
if ($product->initialLoanableQuantity > 0) {
|
||||
if ($product->isLoanable() && $product->initialLoanableQuantity > 0) {
|
||||
$product->increaseStock($product->initialLoanableQuantity);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static function resolveDefaultProductType(object $product): ?ProductType
|
||||
{
|
||||
$constant = $product::class . '::DEFAULT_TYPE';
|
||||
|
||||
return defined($constant) ? constant($constant) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically check out one unit of this product to a borrower.
|
||||
*
|
||||
|
|
@ -113,6 +149,7 @@ trait IsLoanableProduct
|
|||
* @param int|null $weeks Loan duration; defaults to shop.loan.default_duration_weeks
|
||||
* @param ProductPrice|null $price Override price; defaults to product's defaultPrice
|
||||
*
|
||||
* @throws NotLoanableProductException When called on a non-loanable product
|
||||
* @throws NotEnoughStockException When no copies are available
|
||||
*/
|
||||
public function checkOutTo(
|
||||
|
|
@ -120,6 +157,10 @@ trait IsLoanableProduct
|
|||
?int $weeks = null,
|
||||
?ProductPrice $price = null,
|
||||
): ProductPurchase {
|
||||
if (! $this->isLoanable()) {
|
||||
throw new NotLoanableProductException();
|
||||
}
|
||||
|
||||
$weeks ??= (int) config('shop.loan.default_duration_weeks', 2);
|
||||
$now = Carbon::now();
|
||||
$price ??= $this->defaultPrice()->first();
|
||||
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
namespace Blax\Shop\Tests\Feature\Loan;
|
||||
|
||||
use Blax\Shop\Enums\ProductType;
|
||||
use Blax\Shop\Enums\PurchaseStatus;
|
||||
use Blax\Shop\Events\LoanCreated;
|
||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Blax\Shop\Traits\IsLoanableProduct;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
|
@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\Test;
|
|||
use Workbench\App\Models\User;
|
||||
|
||||
/**
|
||||
* Coverage for {@see IsLoanableProduct::checkOutTo()} — the atomic
|
||||
* Coverage for {@see MayBeLoanableProduct::checkOutTo()} — the atomic
|
||||
* decreaseStock + purchase row + LoanCreated event path that every host
|
||||
* controller uses to start a loan. Until this file existed, the trait that
|
||||
* production code actually depends on had zero direct test coverage; the
|
||||
|
|
@ -200,15 +200,16 @@ class CheckOutToTest extends TestCase
|
|||
}
|
||||
|
||||
/**
|
||||
* Minimal loanable fixture: extending Product picks up the package's
|
||||
* polymorphism, the IsLoanableProduct trait wires up checkOutTo and the
|
||||
* total_quantity / available_quantity virtuals. Both base and subclass
|
||||
* resolve to the `products` table via Product::__construct, so no migration
|
||||
* is needed.
|
||||
* Minimal loanable fixture: extending Product gives us the package's
|
||||
* polymorphism and the MayBeLoanableProduct helpers (it's mixed into Product
|
||||
* itself); declaring DEFAULT_TYPE = LOANABLE is the plug-n-pray opt-in that
|
||||
* flips checkOutTo and the total_quantity / available_quantity virtuals into
|
||||
* loan mode. Both base and subclass resolve to the `products` table via
|
||||
* Product::__construct, so no migration is needed.
|
||||
*/
|
||||
class LoanableBook extends Product
|
||||
{
|
||||
use IsLoanableProduct;
|
||||
public const DEFAULT_TYPE = ProductType::LOANABLE;
|
||||
|
||||
protected $guarded = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ use Workbench\App\Models\User;
|
|||
/**
|
||||
* Loan lifecycle domain events.
|
||||
*
|
||||
* LoanCreated — auto-dispatched from IsLoanableProduct::checkOutTo()
|
||||
* LoanCreated — auto-dispatched from MayBeLoanableProduct::checkOutTo()
|
||||
* (see CheckOutToTest). Hosts that build ProductPurchase
|
||||
* rows directly — bypassing checkOutTo — must dispatch
|
||||
* the event themselves; that direct-construction path is
|
||||
|
|
@ -95,7 +95,7 @@ class LoanEventsTest extends TestCase
|
|||
#[Test]
|
||||
public function loan_created_can_be_dispatched_manually_when_bypassing_checkOutTo(): void
|
||||
{
|
||||
// The package auto-dispatches LoanCreated from IsLoanableProduct::
|
||||
// The package auto-dispatches LoanCreated from MayBeLoanableProduct::
|
||||
// checkOutTo() (see CheckOutToTest). Hosts that assemble a
|
||||
// ProductPurchase row directly — e.g. importing historical loans —
|
||||
// can still raise the same event so downstream listeners fire.
|
||||
|
|
|
|||
Loading…
Reference in New Issue