2025-11-21 10:49:41 +00:00
|
|
|
<?php
|
|
|
|
|
|
2026-05-15 18:26:24 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
namespace Blax\Shop\Models;
|
|
|
|
|
|
2025-12-03 12:59:01 +00:00
|
|
|
use Blax\Shop\Enums\StockStatus;
|
|
|
|
|
use Blax\Shop\Enums\StockType;
|
2025-11-21 10:49:41 +00:00
|
|
|
use Blax\Shop\Models\Product;
|
2025-11-23 14:07:12 +00:00
|
|
|
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;
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
{
|
2025-11-23 14:07:12 +00:00
|
|
|
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',
|
2025-12-03 12:59:01 +00:00
|
|
|
'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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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));
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* Scope: Get stock entries that are still pending (claims not yet released)
|
|
|
|
|
*/
|
2025-11-21 10:49:41 +00:00
|
|
|
public function scopePending($query)
|
|
|
|
|
{
|
2025-12-03 12:59:01 +00:00
|
|
|
return $query->where('status', StockStatus::PENDING->value);
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* Scope: Get stock entries that have been released/completed
|
|
|
|
|
*/
|
2025-11-21 10:49:41 +00:00
|
|
|
public function scopeReleased($query)
|
|
|
|
|
{
|
2025-12-03 12:59:01 +00:00
|
|
|
return $query->where('status', StockStatus::COMPLETED->value);
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +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');
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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');
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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()
|
|
|
|
|
{
|
2025-12-03 12:59:01 +00:00
|
|
|
return $this->status === StockStatus::COMPLETED ? $this->updated_at : null;
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* Backward compatibility accessor: Alias for expires_at
|
|
|
|
|
*/
|
2025-11-21 10:49:41 +00:00
|
|
|
public function getUntilAtAttribute()
|
|
|
|
|
{
|
|
|
|
|
return $this->expires_at;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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,
|
2025-12-03 12:59:01 +00:00
|
|
|
'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,
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* Release a claimed stock entry
|
|
|
|
|
*
|
|
|
|
|
* Changes status from PENDING to COMPLETED, marking the claim as released.
|
2025-12-04 11:35:39 +00:00
|
|
|
* For claims created with the two-entry pattern (DECREASE + CLAIMED), this will also
|
|
|
|
|
* create a RETURN entry to restore the stock to inventory.
|
2025-12-04 10:16:38 +00:00
|
|
|
*
|
|
|
|
|
* @return bool True if released successfully, false if not pending
|
|
|
|
|
*/
|
2025-11-21 10:49:41 +00:00
|
|
|
public function release(): bool
|
|
|
|
|
{
|
2025-12-03 12:59:01 +00:00
|
|
|
if ($this->status !== StockStatus::PENDING) {
|
2025-11-21 10:49:41 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return DB::transaction(function () {
|
2025-12-04 11:35:39 +00:00
|
|
|
// Mark claim as completed (released)
|
2025-12-03 12:59:01 +00:00
|
|
|
$this->status = StockStatus::COMPLETED;
|
2025-11-21 10:49:41 +00:00
|
|
|
$this->save();
|
|
|
|
|
|
2025-12-04 11:35:39 +00:00
|
|
|
// 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);
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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()
|
2025-12-03 12:59:01 +00:00
|
|
|
&& $this->status === StockStatus::PENDING
|
2025-11-21 10:49:41 +00:00
|
|
|
&& $this->expires_at->isPast();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* Check if this claim is currently active (PENDING status)
|
|
|
|
|
*/
|
2025-11-21 10:49:41 +00:00
|
|
|
public function isActive(): bool
|
|
|
|
|
{
|
2025-12-03 12:59:01 +00:00
|
|
|
return $this->status === StockStatus::PENDING;
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +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(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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()) {
|
|
|
|
|
$count++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $count;
|
|
|
|
|
}
|
2025-11-23 14:07:12 +00:00
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* Scope: Get completed/available stock entries
|
|
|
|
|
* These are physical stock changes (INCREASE/DECREASE) that have been finalized
|
|
|
|
|
*/
|
2026-05-15 18:26:24 +00:00
|
|
|
public function scopeAvailable($query)
|
2025-11-23 14:07:12 +00:00
|
|
|
{
|
2025-12-03 12:59:01 +00:00
|
|
|
return $query->where('status', StockStatus::COMPLETED->value);
|
2025-11-23 14:07:12 +00:00
|
|
|
}
|
2025-11-25 11:33:42 +00:00
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* Scope: Get active (pending) claimed stock entries
|
|
|
|
|
* These represent stock currently claimed but not yet released
|
|
|
|
|
*/
|
2026-05-15 18:26:24 +00:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +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();
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-04 10:16:38 +00:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2026-05-15 18:26:24 +00:00
|
|
|
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) {
|
2025-12-04 10:16:38 +00:00
|
|
|
// 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
|
|
|
}
|