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 { return $this->stocks() ->available() ->where('type', '!=', StockType::CLAIMED->value) ->willExpire() ->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 { if (!$this->manage_stock) { return true; } 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 { if (!$this->manage_stock) { return true; } $available = $this->getAvailableStock(); if ($available < $quantity) { return throw new NotEnoughStockException("Not enough stock available for product ID {$this->id}"); } $this->stocks()->create([ 'quantity' => -$quantity, 'type' => StockType::DECREASE, 'status' => StockStatus::COMPLETED, 'expires_at' => $until, ]); $this->logStockChange(-$quantity, 'decrease'); $this->save(); 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 { if (!$this->manage_stock) { return false; } $this->stocks()->create([ 'quantity' => $quantity, 'type' => StockType::INCREASE, 'status' => StockStatus::COMPLETED, ]); $this->logStockChange($quantity, 'increase'); $this->save(); return true; } /** * Adjust stock with custom type and status * * More flexible than increaseStock/decreaseStock, allows: * - Custom stock type (INCREASE, DECREASE, RETURN, CLAIMED) * - Custom status (defaults to COMPLETED) * - Optional expiration date * - Optional claim start date (for CLAIMED type) * - Optional note for documentation * - Optional reference to related model (Order, Cart, Booking, etc.) * * Note: CLAIMED type creates two entries like claimStock() for consistency: * 1. DECREASE entry (COMPLETED) - reduces available stock * 2. CLAIMED entry (PENDING) - tracks the claim * * @param StockType $type The type of adjustment (INCREASE/RETURN add stock, DECREASE/CLAIMED remove stock) * @param int $quantity Amount to adjust (always positive, type determines direction) * @param \DateTimeInterface|null $until Optional expiration date (when stock expires or claim ends) * @param \DateTimeInterface|null $from Optional start date (used for CLAIMED type, defaults to now()) * @param StockStatus|null $status Optional status (defaults to COMPLETED, or PENDING for CLAIMED type) * @param string|null $note Optional note for documentation purposes * @param Model|null $referencable Optional polymorphic reference to related model * @return bool|\Blax\Shop\Models\ProductStock True if successful for non-CLAIMED types, ProductStock instance for CLAIMED type, false if stock management disabled * @throws NotEnoughStockException If insufficient stock available for DECREASE or CLAIMED types */ public function adjustStock( StockType $type, int $quantity, \DateTimeInterface|null $until = null, \DateTimeInterface|null $from = null, ?StockStatus $status = null, string|null $note = null, Model|null $referencable = null ) { if (!$this->manage_stock) { return false; } // For CLAIMED type, delegate to claimStock which handles the two-entry pattern if ($type === StockType::CLAIMED) { return $this->claimStock( quantity: $quantity, reference: $referencable, from: $from, until: $until, note: $note ); } // Validate stock availability for types that reduce inventory $isPositive = in_array($type, [StockType::INCREASE, StockType::RETURN]); if (!$isPositive) { // Only validate for COMPLETED status (PENDING doesn't affect available stock) $effectiveStatus = $status ?? StockStatus::COMPLETED; if ($effectiveStatus === StockStatus::COMPLETED && $this->getAvailableStock() < $quantity) { throw new NotEnoughStockException("Not enough stock available for product ID {$this->id}"); } } $adjustedQuantity = $isPositive ? $quantity : -$quantity; $this->stocks()->create([ 'quantity' => $adjustedQuantity, 'type' => $type, 'status' => $status ?? StockStatus::COMPLETED, 'expires_at' => $until, 'note' => $note, 'reference_type' => $referencable ? get_class($referencable) : null, 'reference_id' => $referencable ? $referencable->id : null, ]); $this->logStockChange($adjustedQuantity, 'adjust'); $this->save(); 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( int $quantity, $reference = null, ?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null, ?string $note = null ): ?\Blax\Shop\Models\ProductStock { if (!$this->manage_stock) { return null; } $stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); return $stockModel::claim( $this, $quantity, $reference, $from, $until, $note ); } /** * Get currently available stock * * This is the stock available for new orders/claims. * Calculated as: Sum of all COMPLETED entries that haven't expired (includes DECREASE from active claims) * CLAIMED entries are excluded as they track claims, not physical inventory. * * @return int Available quantity (PHP_INT_MAX if stock management disabled) */ public function getAvailableStock(): int { if (!$this->manage_stock) { return PHP_INT_MAX; } return max(0, $this->stocks() ->where('status', StockStatus::COMPLETED->value) ->where('type', '!=', StockType::CLAIMED->value) ->willExpire() ->sum('quantity')); } /** * Get total currently claimed stock * * Sum of all active (PENDING) claims. * This stock is unavailable but tracked separately from physical inventory. * Returns absolute value to always show positive quantity. * * @return int Total claimed quantity (always positive) */ public function getClaimedStock(): int { return abs($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 { DB::table('product_stock_logs')->insert([ 'product_id' => $this->id, 'quantity_change' => $quantityChange, 'quantity_after' => $this->getAvailableStock(), 'type' => $type, 'created_at' => now(), 'updated_at' => now(), ]); } /** * 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) { return $query->where(function ($q) { $q->where('manage_stock', false) ->orWhere(function ($q2) { $q2->where('manage_stock', true) ->whereRaw("(SELECT SUM(quantity) FROM " . config('shop.tables.product_stocks', 'product_stocks') . " WHERE product_id = " . config('shop.tables.products', 'products') . ".id) > 0"); }); }); } /** * 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) { $stockTable = config('shop.tables.product_stocks', 'product_stocks'); $productTable = config('shop.tables.products', 'products'); return $query->where('manage_stock', true) ->whereNotNull('low_stock_threshold') ->whereRaw("(SELECT COALESCE(SUM(quantity), 0) FROM {$stockTable} WHERE {$stockTable}.product_id = {$productTable}.id AND {$stockTable}.status = 'completed' AND ({$stockTable}.expires_at IS NULL OR {$stockTable}.expires_at > ?)) <= {$productTable}.low_stock_threshold", [ now() ]); } /** * Check if product stock is low * * @return bool True if stock management enabled, threshold set, and stock <= threshold */ public function isLowStock(): bool { if (!$this->manage_stock || !$this->low_stock_threshold) { return false; } 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() { $stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); return $stockModel::claims() ->willExpire() ->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 { if (!$this->manage_stock) { return PHP_INT_MAX; } $stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); // Get current available stock (includes all completed stocks minus all currently pending claims) $currentAvailable = $this->getAvailableStock(); // Get all currently pending claimed stocks (not date-filtered) $allClaimedStocks = $this->stocks() ->where('type', StockType::CLAIMED->value) ->where('status', StockStatus::PENDING->value) ->sum('quantity'); // Get stocks claimed on this specific date $claimedOnDate = $stockModel::availableOnDate($date) ->where('product_id', $this->id) ->sum('quantity'); // Available on date = current available + all claims - claims active on date return max(0, $currentAvailable + abs($allClaimedStocks) - abs($claimedOnDate)); } }