IR stocks documentation, C github tests

This commit is contained in:
Fabian @ Blax Software 2025-12-04 11:16:38 +01:00
parent c711afb570
commit 7db6f8047e
6 changed files with 525 additions and 8 deletions

View File

@ -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

View File

@ -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';

View File

@ -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';

View File

@ -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')

View File

@ -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) {

View File

@ -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'));
}
} }