laravel-shop/src/Models/ProductStock.php

385 lines
13 KiB
PHP
Raw Normal View History

2025-11-21 10:49:41 +00:00
<?php
declare(strict_types=1);
2025-11-21 10:49:41 +00:00
namespace Blax\Shop\Models;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
2025-11-21 10:49:41 +00:00
use Blax\Shop\Models\Product;
use Blax\Workkit\Traits\HasExpiration;
2025-11-21 10:49:41 +00:00
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
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
*/
2025-11-21 10:49:41 +00:00
class ProductStock extends Model
{
use HasUuids, HasExpiration;
2025-11-21 10:49:41 +00:00
protected $fillable = [
'product_id',
'quantity',
'type',
'status',
'reference_type',
'reference_id',
2025-12-04 10:06:09 +00:00
'claimed_from',
2025-11-21 10:49:41 +00:00
'expires_at',
'note',
];
protected $casts = [
'quantity' => 'integer',
'type' => StockType::class,
'status' => StockStatus::class,
2025-12-04 10:06:09 +00:00
'claimed_from' => 'datetime',
2025-11-21 10:49:41 +00:00
'expires_at' => 'datetime',
];
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->setTable(config('shop.tables.product_stocks', 'product_stocks'));
}
protected static function booted()
{
static::created(function ($model) {
$model->logStockChange();
});
}
/**
* Get the product this stock entry belongs to
*/
2025-11-21 10:49:41 +00:00
public function product(): BelongsTo
{
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
*/
2025-11-21 10:49:41 +00:00
public function reference(): MorphTo
{
return $this->morphTo();
}
/**
* Scope: Get stock entries that are still pending (claims not yet released)
*/
2025-11-21 10:49:41 +00:00
public function scopePending($query)
{
return $query->where('status', StockStatus::PENDING->value);
2025-11-21 10:49:41 +00:00
}
/**
* Scope: Get stock entries that have been released/completed
*/
2025-11-21 10:49:41 +00:00
public function scopeReleased($query)
{
return $query->where('status', StockStatus::COMPLETED->value);
2025-11-21 10:49:41 +00:00
}
/**
* Scope: Get temporary stock entries (with expiration date)
*/
2025-11-21 10:49:41 +00:00
public function scopeTemporary($query)
{
return $query->whereNotNull('expires_at');
}
/**
* Scope: Get permanent stock entries (no expiration date)
*/
2025-11-21 10:49:41 +00:00
public function scopePermanent($query)
{
return $query->whereNull('expires_at');
}
/**
* Backward compatibility accessor: Get when the stock was released
* Returns updated_at if status is COMPLETED, otherwise null
*/
2025-11-21 10:49:41 +00:00
public function getReleasedAtAttribute()
{
return $this->status === StockStatus::COMPLETED ? $this->updated_at : null;
2025-11-21 10:49:41 +00:00
}
/**
* Backward compatibility accessor: Alias for expires_at
*/
2025-11-21 10:49:41 +00:00
public function getUntilAtAttribute()
{
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
*/
2025-12-04 10:06:09 +00:00
public static function claim(
2025-11-21 10:49:41 +00:00
Product $product,
int $quantity,
$reference = null,
2025-12-04 10:06:09 +00:00
?\DateTimeInterface $from = null,
2025-11-21 10:49:41 +00:00
?\DateTimeInterface $until = null,
?string $note = null
): ?self {
2025-12-04 10:06:09 +00:00
return DB::transaction(function () use ($product, $quantity, $reference, $from, $until, $note) {
2025-12-20 10:22:04 +00:00
// When claiming for a future booking, check availability at the start date
// Otherwise claims for different time periods would incorrectly conflict
$checkDate = $from ?? now();
// Manually check stock availability at the relevant date
if ($product->manage_stock) {
$available = $product->getAvailableStock($checkDate);
if ($available < $quantity) {
throw new \Blax\Shop\Exceptions\NotEnoughStockException(
"Not enough stock available for product ID {$product->id} at date {$checkDate->format('Y-m-d')}"
);
}
// Create DECREASE entry to reduce physical inventory
$product->stocks()->create([
'quantity' => -$quantity,
'type' => \Blax\Shop\Enums\StockType::DECREASE,
'status' => \Blax\Shop\Enums\StockStatus::COMPLETED,
'expires_at' => null, // Permanent reduction (until claim is released)
]);
2025-11-21 10:49:41 +00:00
}
return self::create([
'product_id' => $product->id,
'quantity' => $quantity,
2025-12-04 10:06:09 +00:00
'type' => StockType::CLAIMED,
'status' => StockStatus::PENDING,
2025-11-21 10:49:41 +00:00
'reference_type' => $reference ? get_class($reference) : null,
'reference_id' => $reference?->id,
2025-12-04 10:06:09 +00:00
'claimed_from' => $from,
2025-11-21 10:49:41 +00:00
'expires_at' => $until,
'note' => $note,
]);
});
}
/**
* Release a claimed stock entry
*
* Changes status from PENDING to COMPLETED, marking the claim as released.
* For claims created with the two-entry pattern (DECREASE + CLAIMED), this will also
* create a RETURN entry to restore the stock to inventory.
*
* @return bool True if released successfully, false if not pending
*/
public function release(bool $expired = false): bool
2025-11-21 10:49:41 +00:00
{
if ($this->status !== StockStatus::PENDING) {
2025-11-21 10:49:41 +00:00
return false;
}
return DB::transaction(function () use ($expired) {
// Mark claim as completed (released)
$this->status = StockStatus::COMPLETED;
2025-11-21 10:49:41 +00:00
$this->save();
// Return the claimed stock to inventory
// This creates a RETURN entry to offset the DECREASE that was created when claiming
$this->product->increaseStock($this->quantity, StockType::RETURN);
if ($expired) {
event(new \Blax\Shop\Events\StockClaimExpired($this->product, $this));
} else {
event(new \Blax\Shop\Events\StockReleased($this->product, $this));
}
2025-11-21 10:49:41 +00:00
return true;
});
}
/**
* Check if this is a permanent stock entry (no expiration)
*/
2025-11-21 10:49:41 +00:00
public function isPermanent(): bool
{
return is_null($this->expires_at);
}
/**
* Check if this is a temporary stock entry (has expiration date)
*/
2025-11-21 10:49:41 +00:00
public function isTemporary(): bool
{
return !is_null($this->expires_at);
}
/**
* Check if this temporary claim has expired
* Only applies to PENDING claims with past expiration dates
*/
2025-11-21 10:49:41 +00:00
public function isExpired(): bool
{
return $this->isTemporary()
&& $this->status === StockStatus::PENDING
2025-11-21 10:49:41 +00:00
&& $this->expires_at->isPast();
}
/**
* Check if this claim is currently active (PENDING status)
*/
2025-11-21 10:49:41 +00:00
public function isActive(): bool
{
return $this->status === StockStatus::PENDING;
2025-11-21 10:49:41 +00:00
}
/**
* Log stock changes to the product_stock_logs table
* Provides audit trail of all stock movements
*/
2025-11-21 10:49:41 +00:00
protected function logStockChange(): void
{
if (!config('shop.stock.log_changes', true)) {
return;
}
DB::table('product_stock_logs')->insert([
'product_id' => $this->product_id,
'quantity_change' => -$this->quantity,
'quantity_after' => $this->product->stock_quantity,
'type' => $this->type,
'note' => $this->note,
'reference_type' => $this->reference_type,
'reference_id' => $this->reference_id,
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* Release all expired stock claims
* Used by scheduled command to automatically release expired claims
*
* @return int Number of claims released
*/
2025-11-21 10:49:41 +00:00
public static function releaseExpired(): int
{
$expired = self::expired()->get();
$count = 0;
foreach ($expired as $stock) {
if ($stock->release(expired: true)) {
2025-11-21 10:49:41 +00:00
$count++;
}
}
return $count;
}
/**
* Scope: Get completed/available stock entries
* These are physical stock changes (INCREASE/DECREASE) that have been finalized
*/
public function scopeAvailable($query)
{
return $query->where('status', StockStatus::COMPLETED->value);
}
2025-11-25 11:33:42 +00:00
/**
* Scope: Get active (pending) claimed stock entries
* These represent stock currently claimed but not yet released
*/
public function scopeAvailableClaims($query)
2025-11-25 11:33:42 +00:00
{
2025-12-04 10:06:09 +00:00
return $query->where('type', StockType::CLAIMED->value)->where('status', StockStatus::PENDING->value);
2025-11-25 11:33:42 +00:00
}
/**
* Get all active claims (alias for availableClaims)
*/
2025-12-04 10:06:09 +00:00
public static function claims()
2025-11-25 11:33:42 +00:00
{
2025-12-04 10:06:09 +00:00
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 function scopeAvailableOnDate($query, \DateTimeInterface $date)
2025-12-04 10:06:09 +00:00
{
return $query->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->where(function ($q) use ($date) {
$q->where(function ($subQuery) use ($date) {
// Claimed items with claimed_from set
$subQuery->whereNotNull('claimed_from')
->where('claimed_from', '<=', $date)
->where(function ($dateQuery) use ($date) {
$dateQuery->whereNull('expires_at')
->orWhere('expires_at', '>=', $date);
});
})->orWhere(function ($subQuery) use ($date) {
// Claimed items without claimed_from (immediately claimed)
2025-12-04 10:06:09 +00:00
$subQuery->whereNull('claimed_from')
->where(function ($dateQuery) use ($date) {
$dateQuery->whereNull('expires_at')
->orWhere('expires_at', '>=', $date);
});
});
});
2025-11-25 11:33:42 +00:00
}
2025-11-21 10:49:41 +00:00
}