IR stocks documentation, C github tests
This commit is contained in:
parent
c711afb570
commit
7db6f8047e
|
|
@ -2,9 +2,9 @@ name: Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ master, develop ]
|
branches: [ master ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ master, develop ]
|
branches: [ master, dev ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
@ -64,4 +64,4 @@ jobs:
|
||||||
composer run-script post-autoload-dump
|
composer run-script post-autoload-dump
|
||||||
|
|
||||||
- name: Execute tests
|
- name: Execute tests
|
||||||
run: vendor/bin/phpunit --testdox
|
run: vendor/bin/phpunit
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,33 @@
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StockStatus Enum
|
||||||
|
*
|
||||||
|
* Defines the lifecycle status of stock entries.
|
||||||
|
*
|
||||||
|
* Statuses:
|
||||||
|
* - PENDING: Stock claim is active but not yet finalized
|
||||||
|
* Used for: Active reservations, bookings, cart claims
|
||||||
|
* Can be: Released (changed to COMPLETED) or Cancelled
|
||||||
|
* Effect: Stock is allocated but tracked as claimed
|
||||||
|
*
|
||||||
|
* - COMPLETED: Stock movement is finalized
|
||||||
|
* Used for: Physical stock changes (INCREASE/DECREASE/RETURN)
|
||||||
|
* Also for: Released claims (no longer active)
|
||||||
|
* Effect: Counted as physical stock, cannot be modified
|
||||||
|
*
|
||||||
|
* - CANCELLED: Stock entry was cancelled
|
||||||
|
* Used for: Cancelled reservations, voided transactions
|
||||||
|
* Effect: Not counted in any calculations
|
||||||
|
*
|
||||||
|
* Typical Flow:
|
||||||
|
* 1. Claim created -> PENDING
|
||||||
|
* 2. Claim released -> COMPLETED
|
||||||
|
* 3. Or claim cancelled -> CANCELLED
|
||||||
|
*
|
||||||
|
* Physical stock changes (INCREASE/DECREASE) are always COMPLETED.
|
||||||
|
*/
|
||||||
enum StockStatus: string
|
enum StockStatus: string
|
||||||
{
|
{
|
||||||
case PENDING = 'pending';
|
case PENDING = 'pending';
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,30 @@
|
||||||
|
|
||||||
namespace Blax\Shop\Enums;
|
namespace Blax\Shop\Enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StockType Enum
|
||||||
|
*
|
||||||
|
* Defines the types of stock movements that can occur.
|
||||||
|
*
|
||||||
|
* Types:
|
||||||
|
* - CLAIMED: Stock claimed for reservation/booking (creates PENDING entry)
|
||||||
|
* Used for temporary allocations that can be released
|
||||||
|
* Examples: hotel bookings, equipment rentals, cart reservations
|
||||||
|
*
|
||||||
|
* - RETURN: Stock returned to inventory (e.g., customer returns)
|
||||||
|
* Creates a positive adjustment to physical stock
|
||||||
|
*
|
||||||
|
* - INCREASE: Stock added to inventory (e.g., new purchases, restocking)
|
||||||
|
* Creates a positive adjustment to physical stock
|
||||||
|
*
|
||||||
|
* - 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';
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,33 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProductStock Model
|
||||||
|
*
|
||||||
|
* Represents stock movements and claims for products. This model tracks:
|
||||||
|
* - Stock increases/decreases (physical inventory changes)
|
||||||
|
* - Stock claims (reservations/bookings for future use)
|
||||||
|
* - Temporary vs permanent stock allocations
|
||||||
|
* - Date-based stock availability for bookings/rentals
|
||||||
|
*
|
||||||
|
* Stock Flow:
|
||||||
|
* 1. INCREASE/DECREASE: Physical stock changes (COMPLETED status)
|
||||||
|
* - Positive quantity = stock added to inventory
|
||||||
|
* - Negative quantity = stock removed from inventory
|
||||||
|
*
|
||||||
|
* 2. CLAIMED: Temporary allocation of stock (PENDING status)
|
||||||
|
* - Creates a DECREASE entry (negative quantity, COMPLETED)
|
||||||
|
* - Creates a CLAIMED entry (positive quantity, PENDING)
|
||||||
|
* - Net effect: stock is allocated but tracked separately
|
||||||
|
* - Can have claimed_from (when claim starts) and expires_at (when claim ends)
|
||||||
|
* - When released: CLAIMED status changes to COMPLETED
|
||||||
|
*
|
||||||
|
* Key Concepts:
|
||||||
|
* - Physical Stock: Sum of all COMPLETED status stocks (includes INCREASE/DECREASE)
|
||||||
|
* - Available Stock: Physical stock minus currently PENDING claims
|
||||||
|
* - Claimed Stock: Sum of PENDING claims (temporarily unavailable)
|
||||||
|
* - Available on Date: Available stock considering only claims active on specific date
|
||||||
|
*/
|
||||||
class ProductStock extends Model
|
class ProductStock extends Model
|
||||||
{
|
{
|
||||||
use HasUuids, HasExpiration;
|
use HasUuids, HasExpiration;
|
||||||
|
|
@ -49,47 +76,87 @@ class ProductStock extends Model
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the product this stock entry belongs to
|
||||||
|
*/
|
||||||
public function product(): BelongsTo
|
public function product(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(config('shop.models.product', Product::class));
|
return $this->belongsTo(config('shop.models.product', Product::class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the related model (e.g., Order, User, Booking) that triggered this stock change
|
||||||
|
* Used to track what caused the stock movement or claim
|
||||||
|
*/
|
||||||
public function reference(): MorphTo
|
public function reference(): MorphTo
|
||||||
{
|
{
|
||||||
return $this->morphTo();
|
return $this->morphTo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Get stock entries that are still pending (claims not yet released)
|
||||||
|
*/
|
||||||
public function scopePending($query)
|
public function scopePending($query)
|
||||||
{
|
{
|
||||||
return $query->where('status', StockStatus::PENDING->value);
|
return $query->where('status', StockStatus::PENDING->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Get stock entries that have been released/completed
|
||||||
|
*/
|
||||||
public function scopeReleased($query)
|
public function scopeReleased($query)
|
||||||
{
|
{
|
||||||
return $query->where('status', StockStatus::COMPLETED->value);
|
return $query->where('status', StockStatus::COMPLETED->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Get temporary stock entries (with expiration date)
|
||||||
|
*/
|
||||||
public function scopeTemporary($query)
|
public function scopeTemporary($query)
|
||||||
{
|
{
|
||||||
return $query->whereNotNull('expires_at');
|
return $query->whereNotNull('expires_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Get permanent stock entries (no expiration date)
|
||||||
|
*/
|
||||||
public function scopePermanent($query)
|
public function scopePermanent($query)
|
||||||
{
|
{
|
||||||
return $query->whereNull('expires_at');
|
return $query->whereNull('expires_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backward compatibility accessors
|
/**
|
||||||
|
* Backward compatibility accessor: Get when the stock was released
|
||||||
|
* Returns updated_at if status is COMPLETED, otherwise null
|
||||||
|
*/
|
||||||
public function getReleasedAtAttribute()
|
public function getReleasedAtAttribute()
|
||||||
{
|
{
|
||||||
return $this->status === StockStatus::COMPLETED ? $this->updated_at : null;
|
return $this->status === StockStatus::COMPLETED ? $this->updated_at : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward compatibility accessor: Alias for expires_at
|
||||||
|
*/
|
||||||
public function getUntilAtAttribute()
|
public function getUntilAtAttribute()
|
||||||
{
|
{
|
||||||
return $this->expires_at;
|
return $this->expires_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim stock for a product (reservation/booking)
|
||||||
|
*
|
||||||
|
* This creates a two-part entry:
|
||||||
|
* 1. DECREASE entry (negative quantity, COMPLETED) - removes from physical stock
|
||||||
|
* 2. CLAIMED entry (positive quantity, PENDING) - tracks the claim
|
||||||
|
*
|
||||||
|
* @param Product $product The product to claim stock from
|
||||||
|
* @param int $quantity Amount of stock to claim
|
||||||
|
* @param mixed $reference Optional reference model (Order, Booking, etc.)
|
||||||
|
* @param \DateTimeInterface|null $from When the claim starts (null = immediately)
|
||||||
|
* @param \DateTimeInterface|null $until When the claim expires (null = permanent)
|
||||||
|
* @param string|null $note Optional note about the claim
|
||||||
|
* @return self|null The created claim entry, or null if insufficient stock
|
||||||
|
*/
|
||||||
public static function claim(
|
public static function claim(
|
||||||
Product $product,
|
Product $product,
|
||||||
int $quantity,
|
int $quantity,
|
||||||
|
|
@ -117,6 +184,15 @@ class ProductStock extends Model
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a claimed stock entry
|
||||||
|
*
|
||||||
|
* Changes status from PENDING to COMPLETED, marking the claim as released.
|
||||||
|
* Note: This does NOT add stock back - the stock remains decreased.
|
||||||
|
* To return stock to inventory, use increaseStock() on the product.
|
||||||
|
*
|
||||||
|
* @return bool True if released successfully, false if not pending
|
||||||
|
*/
|
||||||
public function release(): bool
|
public function release(): bool
|
||||||
{
|
{
|
||||||
if ($this->status !== StockStatus::PENDING) {
|
if ($this->status !== StockStatus::PENDING) {
|
||||||
|
|
@ -131,16 +207,26 @@ class ProductStock extends Model
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a permanent stock entry (no expiration)
|
||||||
|
*/
|
||||||
public function isPermanent(): bool
|
public function isPermanent(): bool
|
||||||
{
|
{
|
||||||
return is_null($this->expires_at);
|
return is_null($this->expires_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a temporary stock entry (has expiration date)
|
||||||
|
*/
|
||||||
public function isTemporary(): bool
|
public function isTemporary(): bool
|
||||||
{
|
{
|
||||||
return !is_null($this->expires_at);
|
return !is_null($this->expires_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this temporary claim has expired
|
||||||
|
* Only applies to PENDING claims with past expiration dates
|
||||||
|
*/
|
||||||
public function isExpired(): bool
|
public function isExpired(): bool
|
||||||
{
|
{
|
||||||
return $this->isTemporary()
|
return $this->isTemporary()
|
||||||
|
|
@ -148,11 +234,18 @@ class ProductStock extends Model
|
||||||
&& $this->expires_at->isPast();
|
&& $this->expires_at->isPast();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this claim is currently active (PENDING status)
|
||||||
|
*/
|
||||||
public function isActive(): bool
|
public function isActive(): bool
|
||||||
{
|
{
|
||||||
return $this->status === StockStatus::PENDING;
|
return $this->status === StockStatus::PENDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log stock changes to the product_stock_logs table
|
||||||
|
* Provides audit trail of all stock movements
|
||||||
|
*/
|
||||||
protected function logStockChange(): void
|
protected function logStockChange(): void
|
||||||
{
|
{
|
||||||
if (!config('shop.stock.log_changes', true)) {
|
if (!config('shop.stock.log_changes', true)) {
|
||||||
|
|
@ -172,6 +265,12 @@ class ProductStock extends Model
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release all expired stock claims
|
||||||
|
* Used by scheduled command to automatically release expired claims
|
||||||
|
*
|
||||||
|
* @return int Number of claims released
|
||||||
|
*/
|
||||||
public static function releaseExpired(): int
|
public static function releaseExpired(): int
|
||||||
{
|
{
|
||||||
$expired = self::expired()->get();
|
$expired = self::expired()->get();
|
||||||
|
|
@ -186,21 +285,48 @@ class ProductStock extends Model
|
||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Get completed/available stock entries
|
||||||
|
* These are physical stock changes (INCREASE/DECREASE) that have been finalized
|
||||||
|
*/
|
||||||
public static function scopeAvailable($query)
|
public static function scopeAvailable($query)
|
||||||
{
|
{
|
||||||
return $query->where('status', StockStatus::COMPLETED->value);
|
return $query->where('status', StockStatus::COMPLETED->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Get active (pending) claimed stock entries
|
||||||
|
* These represent stock currently claimed but not yet released
|
||||||
|
*/
|
||||||
public static function scopeAvailableClaims($query)
|
public static function scopeAvailableClaims($query)
|
||||||
{
|
{
|
||||||
return $query->where('type', StockType::CLAIMED->value)->where('status', StockStatus::PENDING->value);
|
return $query->where('type', StockType::CLAIMED->value)->where('status', StockStatus::PENDING->value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active claims (alias for availableClaims)
|
||||||
|
*/
|
||||||
public static function claims()
|
public static function claims()
|
||||||
{
|
{
|
||||||
return self::availableClaims();
|
return self::availableClaims();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Get stock claims that are active on a specific date
|
||||||
|
*
|
||||||
|
* Used for date-based availability checking (bookings, rentals, etc.)
|
||||||
|
* A claim is considered active on a date if:
|
||||||
|
* - It has claimed_from <= date (or null = immediate) AND
|
||||||
|
* - It has expires_at >= date (or null = permanent)
|
||||||
|
* - Status is PENDING
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* - Claim from day 5-10: Active on days 5,6,7,8,9,10
|
||||||
|
* - Claim with no claimed_from, expires day 10: Active from creation until day 10
|
||||||
|
* - Claim from day 5, no expires_at: Active from day 5 forever
|
||||||
|
*
|
||||||
|
* @param \DateTimeInterface $date The date to check availability for
|
||||||
|
*/
|
||||||
public static function scopeAvailableOnDate($query, \DateTimeInterface $date)
|
public static function scopeAvailableOnDate($query, \DateTimeInterface $date)
|
||||||
{
|
{
|
||||||
return $query->where('type', StockType::CLAIMED->value)
|
return $query->where('type', StockType::CLAIMED->value)
|
||||||
|
|
@ -215,7 +341,7 @@ class ProductStock extends Model
|
||||||
->orWhere('expires_at', '>=', $date);
|
->orWhere('expires_at', '>=', $date);
|
||||||
});
|
});
|
||||||
})->orWhere(function ($subQuery) use ($date) {
|
})->orWhere(function ($subQuery) use ($date) {
|
||||||
// Claimed items without claimed_from (always claimed)
|
// Claimed items without claimed_from (immediately claimed)
|
||||||
$subQuery->whereNull('claimed_from')
|
$subQuery->whereNull('claimed_from')
|
||||||
->where(function ($dateQuery) use ($date) {
|
->where(function ($dateQuery) use ($date) {
|
||||||
$dateQuery->whereNull('expires_at')
|
$dateQuery->whereNull('expires_at')
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,48 @@ use Carbon\Carbon;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HasStocks Trait
|
||||||
|
*
|
||||||
|
* Provides stock management functionality to Product models.
|
||||||
|
*
|
||||||
|
* Key Features:
|
||||||
|
* - Basic stock operations (increase, decrease, adjust)
|
||||||
|
* - Stock claims for bookings/reservations
|
||||||
|
* - Date-based availability checking
|
||||||
|
* - Low stock detection
|
||||||
|
* - Stock movement logging
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* - Add 'manage_stock' boolean column to products table
|
||||||
|
* - Set manage_stock = true to enable stock tracking
|
||||||
|
* - Use increaseStock/decreaseStock for inventory changes
|
||||||
|
* - Use claimStock for reservations/bookings
|
||||||
|
* - Use availableOnDate for date-based availability
|
||||||
|
*
|
||||||
|
* Stock Calculation:
|
||||||
|
* - Physical Stock = Sum of all COMPLETED entries
|
||||||
|
* - Available Stock = Physical Stock (accounts for pending claims via their DECREASE entries)
|
||||||
|
* - Claimed Stock = Sum of PENDING claims
|
||||||
|
* - Available on Date = Available Stock + All Claims - Claims Active on Date
|
||||||
|
*/
|
||||||
trait HasStocks
|
trait HasStocks
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Get all stock entries for this product
|
||||||
|
*/
|
||||||
public function stocks(): HasMany
|
public function stocks(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'));
|
return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attribute accessor: Get available physical stock
|
||||||
|
*
|
||||||
|
* Sums all COMPLETED stock entries that haven't expired.
|
||||||
|
* This includes INCREASE (+), DECREASE (-), and released claims.
|
||||||
|
* Does NOT include PENDING claims (they're tracked separately).
|
||||||
|
*/
|
||||||
public function getAvailableStocksAttribute(): int
|
public function getAvailableStocksAttribute(): int
|
||||||
{
|
{
|
||||||
return $this->stocks()
|
return $this->stocks()
|
||||||
|
|
@ -24,6 +59,11 @@ trait HasStocks
|
||||||
->sum('quantity') ?? 0;
|
->sum('quantity') ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if product is in stock
|
||||||
|
*
|
||||||
|
* @return bool True if stock management is disabled OR available stock > 0
|
||||||
|
*/
|
||||||
public function isInStock(): bool
|
public function isInStock(): bool
|
||||||
{
|
{
|
||||||
if (!$this->manage_stock) {
|
if (!$this->manage_stock) {
|
||||||
|
|
@ -33,6 +73,17 @@ trait HasStocks
|
||||||
return $this->getAvailableStock() > 0;
|
return $this->getAvailableStock() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrease physical stock (inventory reduction)
|
||||||
|
*
|
||||||
|
* Creates a DECREASE entry with negative quantity and COMPLETED status.
|
||||||
|
* This represents actual stock leaving the inventory (sold, damaged, etc.).
|
||||||
|
*
|
||||||
|
* @param int $quantity Amount to decrease
|
||||||
|
* @param Carbon|null $until Optional expiration (for temporary decreases)
|
||||||
|
* @return bool True if successful
|
||||||
|
* @throws NotEnoughStockException If insufficient stock available
|
||||||
|
*/
|
||||||
public function decreaseStock(int $quantity = 1, Carbon|null $until = null): bool
|
public function decreaseStock(int $quantity = 1, Carbon|null $until = null): bool
|
||||||
{
|
{
|
||||||
if (!$this->manage_stock) {
|
if (!$this->manage_stock) {
|
||||||
|
|
@ -57,6 +108,15 @@ trait HasStocks
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increase physical stock (inventory addition)
|
||||||
|
*
|
||||||
|
* Creates an INCREASE entry with positive quantity and COMPLETED status.
|
||||||
|
* This represents stock being added to inventory (purchased, returned, etc.).
|
||||||
|
*
|
||||||
|
* @param int $quantity Amount to increase
|
||||||
|
* @return bool True if successful, false if stock management disabled
|
||||||
|
*/
|
||||||
public function increaseStock(int $quantity = 1): bool
|
public function increaseStock(int $quantity = 1): bool
|
||||||
{
|
{
|
||||||
if (!$this->manage_stock) {
|
if (!$this->manage_stock) {
|
||||||
|
|
@ -76,6 +136,20 @@ trait HasStocks
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjust stock with custom type and status
|
||||||
|
*
|
||||||
|
* More flexible than increaseStock/decreaseStock, allows:
|
||||||
|
* - Custom stock type (INCREASE, DECREASE, RETURN)
|
||||||
|
* - Custom status (defaults to COMPLETED)
|
||||||
|
* - Optional expiration date
|
||||||
|
*
|
||||||
|
* @param StockType $type The type of adjustment
|
||||||
|
* @param int $quantity Amount to adjust (always positive, type determines direction)
|
||||||
|
* @param \DateTimeInterface|null $until Optional expiration date
|
||||||
|
* @param StockStatus|null $status Optional status (defaults to COMPLETED)
|
||||||
|
* @return bool True if successful, false if stock management disabled
|
||||||
|
*/
|
||||||
public function adjustStock(
|
public function adjustStock(
|
||||||
StockType $type,
|
StockType $type,
|
||||||
int $quantity,
|
int $quantity,
|
||||||
|
|
@ -86,20 +160,45 @@ trait HasStocks
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// INCREASE and RETURN add stock (positive), DECREASE and CLAIMED remove stock (negative)
|
||||||
|
$isPositive = in_array($type, [StockType::INCREASE, StockType::RETURN]);
|
||||||
|
$adjustedQuantity = $isPositive ? $quantity : -$quantity;
|
||||||
|
|
||||||
$this->stocks()->create([
|
$this->stocks()->create([
|
||||||
'quantity' => $type === StockType::INCREASE ? $quantity : -$quantity,
|
'quantity' => $adjustedQuantity,
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'status' => $status ?? StockStatus::COMPLETED,
|
'status' => $status ?? StockStatus::COMPLETED,
|
||||||
'expires_at' => $until,
|
'expires_at' => $until,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->logStockChange($type === StockType::INCREASE ? $quantity : -$quantity, 'adjust');
|
$this->logStockChange($adjustedQuantity, 'adjust');
|
||||||
|
|
||||||
$this->save();
|
$this->save();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim stock for temporary use (reservation/booking)
|
||||||
|
*
|
||||||
|
* This is different from decreaseStock - it:
|
||||||
|
* 1. Removes stock from available inventory (via DECREASE entry)
|
||||||
|
* 2. Tracks it as a claim (via CLAIMED entry with PENDING status)
|
||||||
|
* 3. Can be released back later (changes CLAIMED to COMPLETED)
|
||||||
|
* 4. Supports date ranges for bookings (claimed_from to expires_at)
|
||||||
|
*
|
||||||
|
* Use cases:
|
||||||
|
* - Hotel room bookings (claimed_from = check-in, expires_at = check-out)
|
||||||
|
* - Equipment rentals (claimed_from = rental start, expires_at = return date)
|
||||||
|
* - Cart reservations (no claimed_from, expires_at = cart expiry)
|
||||||
|
*
|
||||||
|
* @param int $quantity Amount to claim
|
||||||
|
* @param mixed $reference Optional reference model (Order, Booking, Cart, etc.)
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
public function claimStock(
|
public function claimStock(
|
||||||
int $quantity,
|
int $quantity,
|
||||||
$reference = null,
|
$reference = null,
|
||||||
|
|
@ -124,6 +223,14 @@ trait HasStocks
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get currently available stock
|
||||||
|
*
|
||||||
|
* This is the stock available for new orders/claims.
|
||||||
|
* Calculated as: Sum of all COMPLETED entries (includes DECREASE from active claims)
|
||||||
|
*
|
||||||
|
* @return int Available quantity (PHP_INT_MAX if stock management disabled)
|
||||||
|
*/
|
||||||
public function getAvailableStock(): int
|
public function getAvailableStock(): int
|
||||||
{
|
{
|
||||||
if (!$this->manage_stock) {
|
if (!$this->manage_stock) {
|
||||||
|
|
@ -133,11 +240,28 @@ trait HasStocks
|
||||||
return max(0, $this->AvailableStocks);
|
return max(0, $this->AvailableStocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total currently claimed stock
|
||||||
|
*
|
||||||
|
* Sum of all active (PENDING) claims.
|
||||||
|
* This stock is unavailable but tracked separately from physical inventory.
|
||||||
|
*
|
||||||
|
* @return int Total claimed quantity
|
||||||
|
*/
|
||||||
public function getClaimedStock(): int
|
public function getClaimedStock(): int
|
||||||
{
|
{
|
||||||
return $this->activeStocks()->sum('quantity');
|
return $this->stocks()
|
||||||
|
->where('type', StockType::CLAIMED->value)
|
||||||
|
->where('status', StockStatus::PENDING->value)
|
||||||
|
->sum('quantity');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a stock change to the audit log
|
||||||
|
*
|
||||||
|
* @param int $quantityChange The change in quantity (positive or negative)
|
||||||
|
* @param string $type The type of change (increase, decrease, adjust)
|
||||||
|
*/
|
||||||
protected function logStockChange(int $quantityChange, string $type): void
|
protected function logStockChange(int $quantityChange, string $type): void
|
||||||
{
|
{
|
||||||
DB::table('product_stock_logs')->insert([
|
DB::table('product_stock_logs')->insert([
|
||||||
|
|
@ -150,6 +274,13 @@ trait HasStocks
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query scope: Get products that are in stock
|
||||||
|
*
|
||||||
|
* Includes products with:
|
||||||
|
* - Stock management disabled (always in stock), OR
|
||||||
|
* - Stock management enabled AND available stock > 0
|
||||||
|
*/
|
||||||
public function scopeInStock($query)
|
public function scopeInStock($query)
|
||||||
{
|
{
|
||||||
return $query->where(function ($q) {
|
return $query->where(function ($q) {
|
||||||
|
|
@ -161,6 +292,14 @@ trait HasStocks
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query scope: Get products with low stock
|
||||||
|
*
|
||||||
|
* Returns products where:
|
||||||
|
* - Stock management is enabled
|
||||||
|
* - low_stock_threshold is set
|
||||||
|
* - Available stock <= threshold
|
||||||
|
*/
|
||||||
public function scopeLowStock($query)
|
public function scopeLowStock($query)
|
||||||
{
|
{
|
||||||
$stockTable = config('shop.tables.product_stocks', 'product_stocks');
|
$stockTable = config('shop.tables.product_stocks', 'product_stocks');
|
||||||
|
|
@ -173,6 +312,11 @@ trait HasStocks
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if product stock is low
|
||||||
|
*
|
||||||
|
* @return bool True if stock management enabled, threshold set, and stock <= threshold
|
||||||
|
*/
|
||||||
public function isLowStock(): bool
|
public function isLowStock(): bool
|
||||||
{
|
{
|
||||||
if (!$this->manage_stock || !$this->low_stock_threshold) {
|
if (!$this->manage_stock || !$this->low_stock_threshold) {
|
||||||
|
|
@ -182,6 +326,17 @@ trait HasStocks
|
||||||
return $this->getAvailableStock() <= $this->low_stock_threshold;
|
return $this->getAvailableStock() <= $this->low_stock_threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active claims for this product
|
||||||
|
*
|
||||||
|
* Returns query builder for PENDING claims that haven't expired yet.
|
||||||
|
* Useful for:
|
||||||
|
* - Viewing current reservations
|
||||||
|
* - Checking what's claimed but not released
|
||||||
|
* - Managing active bookings
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Builder
|
||||||
|
*/
|
||||||
public function claims()
|
public function claims()
|
||||||
{
|
{
|
||||||
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
|
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
|
||||||
|
|
@ -191,6 +346,30 @@ trait HasStocks
|
||||||
->where('product_id', $this->id);
|
->where('product_id', $this->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available stock on a specific date
|
||||||
|
*
|
||||||
|
* This is crucial for booking/rental systems where you need to know:
|
||||||
|
* "How many units are available on date X?"
|
||||||
|
*
|
||||||
|
* Calculation:
|
||||||
|
* 1. Start with current available stock
|
||||||
|
* 2. Add back all currently pending claims (they reduce available stock)
|
||||||
|
* 3. Subtract only the claims that are active on the specific date
|
||||||
|
*
|
||||||
|
* Example with 100 units:
|
||||||
|
* - Claim 1: 20 units, days 5-10
|
||||||
|
* - Claim 2: 30 units, days 8-15
|
||||||
|
* - Current available: 50 (100 - 20 - 30)
|
||||||
|
* - Available on day 3: 100 (no claims active)
|
||||||
|
* - Available on day 6: 80 (only claim 1 active)
|
||||||
|
* - Available on day 9: 50 (both claims active)
|
||||||
|
* - Available on day 12: 70 (only claim 2 active)
|
||||||
|
* - Available on day 20: 100 (no claims active)
|
||||||
|
*
|
||||||
|
* @param \DateTimeInterface $date The date to check availability for
|
||||||
|
* @return int Available stock on that date (PHP_INT_MAX if stock management disabled)
|
||||||
|
*/
|
||||||
public function availableOnDate(\DateTimeInterface $date): int
|
public function availableOnDate(\DateTimeInterface $date): int
|
||||||
{
|
{
|
||||||
if (!$this->manage_stock) {
|
if (!$this->manage_stock) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace Blax\Shop\Tests\Feature;
|
namespace Blax\Shop\Tests\Feature;
|
||||||
|
|
||||||
use Blax\Shop\Enums\StockStatus;
|
use Blax\Shop\Enums\StockStatus;
|
||||||
|
use Blax\Shop\Enums\StockType;
|
||||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
use Blax\Shop\Models\ProductStock;
|
use Blax\Shop\Models\ProductStock;
|
||||||
|
|
@ -481,4 +482,164 @@ class ProductStockTest extends TestCase
|
||||||
$this->assertCount(1, $claimsOnDay12);
|
$this->assertCount(1, $claimsOnDay12);
|
||||||
$this->assertEquals($claim2->id, $claimsOnDay12->first()->id);
|
$this->assertEquals($claim2->id, $claimsOnDay12->first()->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_can_get_claimed_stock_amount()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->withStocks(100)->create();
|
||||||
|
|
||||||
|
// Claim some stock
|
||||||
|
$product->claimStock(quantity: 25);
|
||||||
|
$product->claimStock(quantity: 15);
|
||||||
|
|
||||||
|
// Should return total claimed
|
||||||
|
$this->assertEquals(40, $product->getClaimedStock());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_checks_if_claim_is_active()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->withStocks(100)->create();
|
||||||
|
|
||||||
|
$claim = $product->claimStock(quantity: 10);
|
||||||
|
|
||||||
|
$this->assertTrue($claim->isActive());
|
||||||
|
|
||||||
|
$claim->release();
|
||||||
|
|
||||||
|
$this->assertFalse($claim->fresh()->isActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_releases_expired_claims()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->withStocks(100)->create();
|
||||||
|
|
||||||
|
// Create expired claim
|
||||||
|
$expiredClaim = $product->claimStock(
|
||||||
|
quantity: 20,
|
||||||
|
until: now()->subHour()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create active claim
|
||||||
|
$activeClaim = $product->claimStock(
|
||||||
|
quantity: 15,
|
||||||
|
until: now()->addHours(2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Release expired claims
|
||||||
|
$count = \Blax\Shop\Models\ProductStock::releaseExpired();
|
||||||
|
|
||||||
|
$this->assertEquals(1, $count);
|
||||||
|
$this->assertEquals(StockStatus::COMPLETED, $expiredClaim->fresh()->status);
|
||||||
|
$this->assertEquals(StockStatus::PENDING, $activeClaim->fresh()->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_has_reference_relationship()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->withStocks(100)->create();
|
||||||
|
$user = \Workbench\App\Models\User::factory()->create();
|
||||||
|
|
||||||
|
$claim = $product->claimStock(
|
||||||
|
quantity: 10,
|
||||||
|
reference: $user,
|
||||||
|
note: 'Reserved for user'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertNotNull($claim->reference);
|
||||||
|
$this->assertEquals($user->id, $claim->reference->id);
|
||||||
|
$this->assertEquals(get_class($user), $claim->reference_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_handles_return_stock_type()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create(['manage_stock' => true]);
|
||||||
|
|
||||||
|
$product->increaseStock(50);
|
||||||
|
$product->decreaseStock(10);
|
||||||
|
|
||||||
|
// Use adjustStock with RETURN type (adds stock back)
|
||||||
|
$product->adjustStock(
|
||||||
|
type: StockType::RETURN,
|
||||||
|
quantity: 5
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh to get updated stock
|
||||||
|
$product = $product->fresh();
|
||||||
|
|
||||||
|
// Should have 45 total (50 - 10 + 5)
|
||||||
|
$this->assertEquals(45, $product->getAvailableStock());
|
||||||
|
|
||||||
|
// Verify the return entry exists
|
||||||
|
$returnEntry = $product->stocks()->where('type', StockType::RETURN->value)->first();
|
||||||
|
$this->assertNotNull($returnEntry);
|
||||||
|
$this->assertEquals(5, $returnEntry->quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function temporary_scope_filters_correctly()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->withStocks(100)->create();
|
||||||
|
|
||||||
|
$temporary = $product->claimStock(quantity: 10, until: now()->addDay());
|
||||||
|
$permanent = $product->claimStock(quantity: 20);
|
||||||
|
|
||||||
|
$temporaryStocks = \Blax\Shop\Models\ProductStock::temporary()->get();
|
||||||
|
$permanentStocks = \Blax\Shop\Models\ProductStock::permanent()->get();
|
||||||
|
|
||||||
|
$this->assertTrue($temporaryStocks->contains($temporary));
|
||||||
|
$this->assertFalse($temporaryStocks->contains($permanent));
|
||||||
|
$this->assertTrue($permanentStocks->contains($permanent));
|
||||||
|
$this->assertFalse($permanentStocks->contains($temporary));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function it_tracks_stock_with_custom_status()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->create(['manage_stock' => true]);
|
||||||
|
|
||||||
|
// Add stock with PENDING status
|
||||||
|
$product->adjustStock(
|
||||||
|
type: StockType::INCREASE,
|
||||||
|
quantity: 50,
|
||||||
|
status: StockStatus::PENDING
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not be counted in available stock (only COMPLETED counts)
|
||||||
|
$this->assertEquals(0, $product->getAvailableStock());
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
$stockEntry = $product->stocks()->where('type', StockType::INCREASE->value)->first();
|
||||||
|
$stockEntry->status = StockStatus::COMPLETED;
|
||||||
|
$stockEntry->save();
|
||||||
|
|
||||||
|
// Now should be available
|
||||||
|
$this->assertEquals(50, $product->fresh()->getAvailableStock());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @test */
|
||||||
|
public function backward_compatibility_accessors_work()
|
||||||
|
{
|
||||||
|
$product = Product::factory()->withStocks(100)->create();
|
||||||
|
|
||||||
|
$claim = $product->claimStock(
|
||||||
|
quantity: 10,
|
||||||
|
until: now()->addDays(5)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test released_at accessor (should be null for pending)
|
||||||
|
$this->assertNull($claim->released_at);
|
||||||
|
|
||||||
|
// Test until_at accessor (alias for expires_at)
|
||||||
|
$this->assertEquals($claim->expires_at->format('Y-m-d'), $claim->until_at->format('Y-m-d'));
|
||||||
|
|
||||||
|
// Release the claim
|
||||||
|
$claim->release();
|
||||||
|
|
||||||
|
// Now released_at should return updated_at
|
||||||
|
$this->assertNotNull($claim->fresh()->released_at);
|
||||||
|
$this->assertEquals($claim->fresh()->updated_at->format('Y-m-d H:i:s'), $claim->fresh()->released_at->format('Y-m-d H:i:s'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue