IA product category scopes & methods, I adjust stock method, R stocks

This commit is contained in:
Fabian @ Blax Software 2025-12-04 12:35:39 +01:00
parent 7db6f8047e
commit fe0fa63919
7 changed files with 1065 additions and 32 deletions

View File

@ -11,6 +11,7 @@ use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Traits\HasCategories;
use Blax\Shop\Traits\HasPrices;
use Blax\Shop\Traits\HasStocks;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -23,7 +24,7 @@ use Illuminate\Support\Facades\Cache;
class Product extends Model implements Purchasable, Cartable
{
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices;
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasCategories;
protected $fillable = [
'slug',
@ -141,14 +142,6 @@ class Product extends Model implements Purchasable, Cartable
return $this->hasMany(self::class, 'parent_id');
}
public function categories(): BelongsToMany
{
return $this->belongsToMany(
config('shop.models.product_category'),
'product_category_product'
);
}
public function attributes(): HasMany
{
return $this->hasMany(config('shop.models.product_attribute', 'Blax\Shop\Models\ProductAttribute'));
@ -241,13 +234,6 @@ class Product extends Model implements Purchasable, Cartable
});
}
public function scopeByCategory($query, $categoryId)
{
return $query->whereHas('categories', function ($q) use ($categoryId) {
$q->where('id', $categoryId);
});
}
public function scopeSearch($query, string $search)
{
return $query->where(function ($q) use ($search) {

View File

@ -188,8 +188,8 @@ 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.
* 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
*/
@ -200,9 +200,14 @@ class ProductStock extends Model
}
return DB::transaction(function () {
// Mark claim as completed (released)
$this->status = StockStatus::COMPLETED;
$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);
return true;
});
}

View File

@ -0,0 +1,112 @@
<?php
namespace Blax\Shop\Traits;
use Blax\Shop\Models\ProductCategory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
trait HasCategories
{
public function categories(): BelongsToMany
{
return $this->belongsToMany(
config('shop.models.product_category'),
'product_category_product'
);
}
public function scopeByCategory($query, ProductCategory|string $category_or_id)
{
$categoryId = $category_or_id instanceof ProductCategory
? $category_or_id->id
: $category_or_id;
return $query->whereHas('categories', function ($q) use ($categoryId) {
$q->where('id', $categoryId);
});
}
public function scopeByCategories($query, array $category_ids)
{
foreach ($category_ids as $category_id) {
$query->byCategory($category_id);
}
return $query;
}
public function scopeWithoutCategory($query, ProductCategory|string $category_or_id)
{
$categoryId = $category_or_id instanceof ProductCategory
? $category_or_id->id
: $category_or_id;
return $query->whereDoesntHave('categories', function ($q) use ($categoryId) {
$q->where('id', $categoryId);
});
}
public function scopeWithoutCategories($query, array $category_ids)
{
foreach ($category_ids as $category_id) {
$query->withoutCategory($category_id);
}
return $query;
}
public function assignCategory(ProductCategory $category): void
{
$this->categories()->attach($category);
}
public function assignCategories(array $categories): void
{
foreach ($categories as $category) {
$this->assignCategory($category);
}
}
public function removeCategory(ProductCategory $category): void
{
$this->categories()->detach($category);
}
public function removeCategories(array $categories): void
{
foreach ($categories as $category) {
$this->removeCategory($category);
}
}
public function syncCategories(array $categories): void
{
$this->categories()->sync($categories);
}
public function assignCategoryByName(string $name): void
{
$category = config('shop.models.product_category')::firstOrCreate(['name' => $name]);
$this->assignCategory($category);
}
public function assignCategoriesByNames(array $names): void
{
foreach ($names as $name) {
$this->assignCategoryByName($name);
}
}
public function asssignCategoryBySlug(string $slug): void
{
$category = config('shop.models.product_category')::firstOrCreate(['slug' => $slug]);
$this->assignCategory($category);
}
public function assignCategoriesBySlugs(array $slugs): void
{
foreach ($slugs as $slug) {
$this->asssignCategoryBySlug($slug);
}
}
}

View File

@ -6,6 +6,7 @@ use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
@ -55,6 +56,7 @@ trait HasStocks
{
return $this->stocks()
->available()
->where('type', '!=', StockType::CLAIMED->value)
->willExpire()
->sum('quantity') ?? 0;
}
@ -90,7 +92,8 @@ trait HasStocks
return true;
}
if ($this->AvailableStocks < $quantity) {
$available = $this->getAvailableStock();
if ($available < $quantity) {
return throw new NotEnoughStockException("Not enough stock available for product ID {$this->id}");
}
@ -140,28 +143,61 @@ trait HasStocks
* Adjust stock with custom type and status
*
* More flexible than increaseStock/decreaseStock, allows:
* - Custom stock type (INCREASE, DECREASE, RETURN)
* - 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.)
*
* @param StockType $type The type of adjustment
* 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
* @param StockStatus|null $status Optional status (defaults to COMPLETED)
* @return bool True if successful, false if stock management disabled
* @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;
}
// INCREASE and RETURN add stock (positive), DECREASE and CLAIMED remove stock (negative)
// 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([
@ -169,6 +205,9 @@ trait HasStocks
'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');
@ -227,7 +266,8 @@ 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)
* 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)
*/
@ -237,7 +277,11 @@ trait HasStocks
return PHP_INT_MAX;
}
return max(0, $this->AvailableStocks);
return max(0, $this->stocks()
->where('status', StockStatus::COMPLETED->value)
->where('type', '!=', StockType::CLAIMED->value)
->willExpire()
->sum('quantity'));
}
/**
@ -245,15 +289,16 @@ trait HasStocks
*
* 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
* @return int Total claimed quantity (always positive)
*/
public function getClaimedStock(): int
{
return $this->stocks()
return abs($this->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->sum('quantity');
->sum('quantity'));
}
/**
@ -267,7 +312,7 @@ trait HasStocks
DB::table('product_stock_logs')->insert([
'product_id' => $this->id,
'quantity_change' => $quantityChange,
'quantity_after' => $this->stock_quantity,
'quantity_after' => $this->getAvailableStock(),
'type' => $type,
'created_at' => now(),
'updated_at' => now(),
@ -307,7 +352,7 @@ trait HasStocks
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 IN ('completed', 'pending') AND ({$stockTable}.expires_at IS NULL OR {$stockTable}.expires_at > ?)) <= {$productTable}.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()
]);
}

View File

@ -225,4 +225,307 @@ class ProductCategoryTest extends TestCase
$this->assertEquals($parent->id, $child->parent->id);
$this->assertNull($grandparent->parent);
}
/** @test */
public function it_can_filter_products_by_category_using_instance()
{
$category1 = ProductCategory::factory()->create();
$category2 = ProductCategory::factory()->create();
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$product3 = Product::factory()->create();
$product1->categories()->attach($category1->id);
$product2->categories()->attach($category1->id);
$product3->categories()->attach($category2->id);
$results = Product::byCategory($category1)->get();
$this->assertCount(2, $results);
$this->assertTrue($results->contains($product1));
$this->assertTrue($results->contains($product2));
$this->assertFalse($results->contains($product3));
}
/** @test */
public function it_can_filter_products_by_category_using_id_string()
{
$category = ProductCategory::factory()->create();
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$product3 = Product::factory()->create();
$product1->categories()->attach($category->id);
$product2->categories()->attach($category->id);
$results = Product::byCategory($category->id)->get();
$this->assertCount(2, $results);
$this->assertTrue($results->contains($product1));
$this->assertTrue($results->contains($product2));
$this->assertFalse($results->contains($product3));
}
/** @test */
public function it_can_filter_products_by_multiple_categories()
{
$category1 = ProductCategory::factory()->create();
$category2 = ProductCategory::factory()->create();
$category3 = ProductCategory::factory()->create();
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$product3 = Product::factory()->create();
$product4 = Product::factory()->create();
// Product 1 has category1 and category2
$product1->categories()->attach([$category1->id, $category2->id]);
// Product 2 has only category1
$product2->categories()->attach($category1->id);
// Product 3 has only category2
$product3->categories()->attach($category2->id);
// Product 4 has category3
$product4->categories()->attach($category3->id);
$results = Product::byCategories([$category1->id, $category2->id])->get();
// Only product1 should match (has both categories)
$this->assertCount(1, $results);
$this->assertTrue($results->contains($product1));
$this->assertFalse($results->contains($product2));
$this->assertFalse($results->contains($product3));
$this->assertFalse($results->contains($product4));
}
/** @test */
public function it_can_filter_products_without_specific_category()
{
$category1 = ProductCategory::factory()->create();
$category2 = ProductCategory::factory()->create();
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$product3 = Product::factory()->create();
$product1->categories()->attach($category1->id);
$product2->categories()->attach($category2->id);
// product3 has no categories
$results = Product::withoutCategory($category1)->get();
$this->assertCount(2, $results);
$this->assertFalse($results->contains($product1));
$this->assertTrue($results->contains($product2));
$this->assertTrue($results->contains($product3));
}
/** @test */
public function it_can_filter_products_without_category_using_instance()
{
$category = ProductCategory::factory()->create();
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$product1->categories()->attach($category->id);
$results = Product::withoutCategory($category)->get();
$this->assertCount(1, $results);
$this->assertFalse($results->contains($product1));
$this->assertTrue($results->contains($product2));
}
/** @test */
public function it_can_filter_products_without_multiple_categories()
{
$category1 = ProductCategory::factory()->create();
$category2 = ProductCategory::factory()->create();
$category3 = ProductCategory::factory()->create();
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$product3 = Product::factory()->create();
$product4 = Product::factory()->create();
// Product 1 has category1 and category2
$product1->categories()->attach([$category1->id, $category2->id]);
// Product 2 has only category1
$product2->categories()->attach($category1->id);
// Product 3 has only category3
$product3->categories()->attach($category3->id);
// Product 4 has no categories
$results = Product::withoutCategories([$category1->id, $category2->id])->get();
// Only products without both category1 AND category2 should match
$this->assertCount(2, $results);
$this->assertFalse($results->contains($product1));
$this->assertFalse($results->contains($product2));
$this->assertTrue($results->contains($product3));
$this->assertTrue($results->contains($product4));
}
/** @test */
public function it_can_assign_a_category_to_product()
{
$product = Product::factory()->create();
$category = ProductCategory::factory()->create();
$product->assignCategory($category);
$this->assertCount(1, $product->fresh()->categories);
$this->assertTrue($product->categories->contains($category));
}
/** @test */
public function it_can_assign_multiple_categories_to_product()
{
$product = Product::factory()->create();
$category1 = ProductCategory::factory()->create();
$category2 = ProductCategory::factory()->create();
$category3 = ProductCategory::factory()->create();
$product->assignCategories([$category1, $category2, $category3]);
$this->assertCount(3, $product->fresh()->categories);
$this->assertTrue($product->categories->contains($category1));
$this->assertTrue($product->categories->contains($category2));
$this->assertTrue($product->categories->contains($category3));
}
/** @test */
public function it_can_remove_a_category_from_product()
{
$product = Product::factory()->create();
$category1 = ProductCategory::factory()->create();
$category2 = ProductCategory::factory()->create();
$product->categories()->attach([$category1->id, $category2->id]);
$this->assertCount(2, $product->fresh()->categories);
$product->removeCategory($category1);
$this->assertCount(1, $product->fresh()->categories);
$this->assertFalse($product->categories->contains($category1));
$this->assertTrue($product->categories->contains($category2));
}
/** @test */
public function it_can_remove_multiple_categories_from_product()
{
$product = Product::factory()->create();
$category1 = ProductCategory::factory()->create();
$category2 = ProductCategory::factory()->create();
$category3 = ProductCategory::factory()->create();
$product->categories()->attach([$category1->id, $category2->id, $category3->id]);
$this->assertCount(3, $product->fresh()->categories);
$product->removeCategories([$category1, $category2]);
$this->assertCount(1, $product->fresh()->categories);
$this->assertFalse($product->categories->contains($category1));
$this->assertFalse($product->categories->contains($category2));
$this->assertTrue($product->categories->contains($category3));
}
/** @test */
public function it_can_sync_categories_on_product()
{
$product = Product::factory()->create();
$category1 = ProductCategory::factory()->create();
$category2 = ProductCategory::factory()->create();
$category3 = ProductCategory::factory()->create();
// Initially assign category1 and category2
$product->categories()->attach([$category1->id, $category2->id]);
$this->assertCount(2, $product->fresh()->categories);
// Sync to category2 and category3 (removes category1, keeps category2, adds category3)
$product->syncCategories([$category2->id, $category3->id]);
$this->assertCount(2, $product->fresh()->categories);
$this->assertFalse($product->categories->contains($category1));
$this->assertTrue($product->categories->contains($category2));
$this->assertTrue($product->categories->contains($category3));
}
/** @test */
public function it_can_assign_category_by_name()
{
$product = Product::factory()->create();
$product->assignCategoryByName('Electronics');
$this->assertCount(1, $product->fresh()->categories);
$category = $product->categories->first();
$this->assertEquals('Electronics', $category->name);
}
/** @test */
public function it_can_assign_category_by_name_without_creating_duplicates()
{
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$product1->assignCategoryByName('Electronics');
$product2->assignCategoryByName('Electronics');
$this->assertCount(1, ProductCategory::where('name', 'Electronics')->get());
}
/** @test */
public function it_can_assign_multiple_categories_by_names()
{
$product = Product::factory()->create();
$product->assignCategoriesByNames(['Electronics', 'Gadgets', 'Accessories']);
$this->assertCount(3, $product->fresh()->categories);
$categoryNames = $product->categories->pluck('name')->toArray();
$this->assertContains('Electronics', $categoryNames);
$this->assertContains('Gadgets', $categoryNames);
$this->assertContains('Accessories', $categoryNames);
}
/** @test */
public function it_can_assign_category_by_slug()
{
$product = Product::factory()->create();
$product->asssignCategoryBySlug('electronics');
$this->assertCount(1, $product->fresh()->categories);
$category = $product->categories->first();
$this->assertEquals('electronics', $category->slug);
}
/** @test */
public function it_can_assign_category_by_slug_without_creating_duplicates()
{
$product1 = Product::factory()->create();
$product2 = Product::factory()->create();
$product1->asssignCategoryBySlug('electronics');
$product2->asssignCategoryBySlug('electronics');
$this->assertCount(1, ProductCategory::where('slug', 'electronics')->get());
}
/** @test */
public function it_can_assign_multiple_categories_by_slugs()
{
$product = Product::factory()->create();
$product->assignCategoriesBySlugs(['electronics', 'gadgets', 'accessories']);
$this->assertCount(3, $product->fresh()->categories);
$categorySlugs = $product->categories->pluck('slug')->toArray();
$this->assertContains('electronics', $categorySlugs);
$this->assertContains('gadgets', $categorySlugs);
$this->assertContains('accessories', $categorySlugs);
}
}

View File

@ -642,4 +642,389 @@ class ProductStockTest extends TestCase
$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'));
}
/** @test */
public function adjust_stock_increase_type_affects_available_stock_correctly()
{
$product = Product::factory()->create(['manage_stock' => true]);
$this->assertEquals(0, $product->getAvailableStock());
// Add stock using adjustStock with INCREASE type
$product->adjustStock(
type: StockType::INCREASE,
quantity: 50
);
$this->assertEquals(50, $product->getAvailableStock());
// Add more stock
$product->adjustStock(
type: StockType::INCREASE,
quantity: 30
);
$this->assertEquals(80, $product->getAvailableStock());
}
/** @test */
public function adjust_stock_decrease_type_affects_available_stock_correctly()
{
$product = Product::factory()->withStocks(100)->create();
$this->assertEquals(100, $product->getAvailableStock());
// Decrease stock using adjustStock
$product->adjustStock(
type: StockType::DECREASE,
quantity: 20
);
$this->assertEquals(80, $product->getAvailableStock());
// Decrease more stock
$product->adjustStock(
type: StockType::DECREASE,
quantity: 15
);
$this->assertEquals(65, $product->getAvailableStock());
}
/** @test */
public function adjust_stock_return_type_affects_available_stock_correctly()
{
$product = Product::factory()->withStocks(50)->create();
$product->decreaseStock(10);
$this->assertEquals(40, $product->getAvailableStock());
// Return stock using adjustStock with RETURN type
$product->adjustStock(
type: StockType::RETURN,
quantity: 8
);
$this->assertEquals(48, $product->getAvailableStock());
}
/** @test */
public function adjust_stock_claimed_type_affects_available_and_claimed_stock_correctly()
{
$product = Product::factory()->withStocks(100)->create();
$this->assertEquals(100, $product->getAvailableStock());
$this->assertEquals(0, $product->getClaimedStock());
// Claim stock using adjustStock with CLAIMED type
// Note: adjustStock(CLAIMED) now delegates to claimStock() for consistency
// This creates: DECREASE (COMPLETED) + CLAIMED (PENDING)
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 25
);
// Available stock is reduced by the DECREASE entry
$this->assertEquals(75, $product->getAvailableStock());
// Claimed stock shows the pending claim (always positive now)
$this->assertEquals(25, $product->getClaimedStock());
}
/** @test */
public function adjust_stock_with_until_parameter_expires_correctly()
{
$product = Product::factory()->withStocks(100)->create();
// Add stock with expiration date in the future
$product->adjustStock(
type: StockType::INCREASE,
quantity: 50,
until: now()->addDays(5)
);
// Should be available now
$this->assertEquals(150, $product->getAvailableStock());
// Travel to after expiration
$this->travel(6)->days();
// Should no longer be counted as available
$this->assertEquals(100, $product->getAvailableStock());
}
/** @test */
public function adjust_stock_claimed_with_from_and_until_affects_availability_by_date()
{
$product = Product::factory()->withStocks(100)->create();
// Claim stock from day 5 to day 10 using adjustStock
// Now works correctly because adjustStock(CLAIMED) uses the same pattern as claimStock
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 30,
from: now()->addDays(5),
until: now()->addDays(10)
);
// Current available stock is reduced by the DECREASE entry
$this->assertEquals(70, $product->getAvailableStock());
// Check availability on specific dates
$availableOnDay3 = $product->availableOnDate(now()->addDays(3));
$this->assertEquals(100, $availableOnDay3); // Before claim starts
$availableOnDay7 = $product->availableOnDate(now()->addDays(7));
$this->assertEquals(70, $availableOnDay7); // During claim period
$availableOnDay12 = $product->availableOnDate(now()->addDays(12));
$this->assertEquals(100, $availableOnDay12); // After claim expires
}
/** @test */
public function adjust_stock_multiple_claimed_types_accumulate_in_claimed_stock()
{
$product = Product::factory()->withStocks(200)->create();
// Make multiple claims using adjustStock
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 20
);
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 35
);
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 15
);
// Available stock is reduced by all claims
$this->assertEquals(130, $product->getAvailableStock());
// Total claimed stock (always positive)
$this->assertEquals(70, $product->getClaimedStock());
}
/** @test */
public function adjust_stock_claimed_with_completed_status_does_not_count_as_claimed()
{
$product = Product::factory()->withStocks(100)->create();
// Note: adjustStock(CLAIMED) now always delegates to claimStock() which creates PENDING claims
// This test no longer makes sense with the corrected implementation
// Manual claim with COMPLETED status can still be created directly
$product->stocks()->create([
'type' => StockType::CLAIMED,
'quantity' => 25,
'status' => StockStatus::COMPLETED,
]);
// Available stock unchanged (this is completed/released claim)
$this->assertEquals(100, $product->getAvailableStock());
// Should NOT count as claimed stock (only PENDING claims count)
$this->assertEquals(0, $product->getClaimedStock());
}
/** @test */
public function adjust_stock_with_mixed_types_calculates_correctly()
{
$product = Product::factory()->create(['manage_stock' => true]);
// Start with 100
$product->adjustStock(type: StockType::INCREASE, quantity: 100);
$this->assertEquals(100, $product->getAvailableStock());
// Claim 30 - now reduces available stock
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 30
);
$this->assertEquals(70, $product->getAvailableStock());
$this->assertEquals(30, $product->getClaimedStock());
// Decrease 20 (regular decrease with COMPLETED status)
$product->adjustStock(type: StockType::DECREASE, quantity: 20);
$this->assertEquals(50, $product->getAvailableStock());
$this->assertEquals(30, $product->getClaimedStock());
// Return 10 (adds back to stock)
$product->adjustStock(type: StockType::RETURN, quantity: 10);
$this->assertEquals(60, $product->getAvailableStock());
$this->assertEquals(30, $product->getClaimedStock());
// Increase 25
$product->adjustStock(type: StockType::INCREASE, quantity: 25);
$this->assertEquals(85, $product->getAvailableStock());
$this->assertEquals(30, $product->getClaimedStock());
}
/** @test */
public function adjust_stock_claimed_without_from_is_immediately_active()
{
$product = Product::factory()->withStocks(100)->create();
// Claim without 'from' date - should be immediately active
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 40,
until: now()->addDays(10)
);
// Should be claimed right now
$availableNow = $product->availableOnDate(now());
$this->assertEquals(60, $availableNow);
// Should still be claimed tomorrow
$availableTomorrow = $product->availableOnDate(now()->addDays(1));
$this->assertEquals(60, $availableTomorrow);
// Should be released after expiration
$availableAfter = $product->availableOnDate(now()->addDays(11));
$this->assertEquals(100, $availableAfter);
}
/** @test */
public function adjust_stock_claimed_without_until_is_permanent_claim()
{
$product = Product::factory()->withStocks(100)->create();
// Permanent claim from day 5 onwards (no until date)
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 35,
from: now()->addDays(5)
);
// Before 'from' date: full stock available
$availableOnDay3 = $product->availableOnDate(now()->addDays(3));
$this->assertEquals(100, $availableOnDay3);
// After 'from' date: permanently reduced
$availableOnDay10 = $product->availableOnDate(now()->addDays(10));
$this->assertEquals(65, $availableOnDay10);
// Far future: still reduced (permanent)
$availableOnDay100 = $product->availableOnDate(now()->addDays(100));
$this->assertEquals(65, $availableOnDay100);
}
/** @test */
public function adjust_stock_with_overlapping_claimed_periods_calculates_correctly()
{
$product = Product::factory()->withStocks(100)->create();
// First claim: days 5-15
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 20,
from: now()->addDays(5),
until: now()->addDays(15)
);
// Second claim: days 10-20
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 30,
from: now()->addDays(10),
until: now()->addDays(20)
);
// Day 3: no claims active
$this->assertEquals(100, $product->availableOnDate(now()->addDays(3)));
// Day 7: only first claim active
$this->assertEquals(80, $product->availableOnDate(now()->addDays(7)));
// Day 12: both claims active
$this->assertEquals(50, $product->availableOnDate(now()->addDays(12)));
// Day 18: only second claim active
$this->assertEquals(70, $product->availableOnDate(now()->addDays(18)));
// Day 25: no claims active
$this->assertEquals(100, $product->availableOnDate(now()->addDays(25)));
// Current available stock (both claims reduce it)
$this->assertEquals(50, $product->getAvailableStock());
// Total claimed stock
$this->assertEquals(50, $product->getClaimedStock());
}
/** @test */
public function adjust_stock_with_note_and_reference_tracks_correctly()
{
$product = Product::factory()->withStocks(100)->create();
$user = \Workbench\App\Models\User::factory()->create();
// Claim with note and reference
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 25,
note: 'VIP customer reservation',
referencable: $user
);
// Available stock is reduced
$this->assertEquals(75, $product->getAvailableStock());
// Claimed stock shows positive value
$this->assertEquals(25, $product->getClaimedStock());
// Verify note and reference are stored
$claim = $product->stocks()->where('type', StockType::CLAIMED->value)->first();
$this->assertEquals('VIP customer reservation', $claim->note);
$this->assertEquals($user->id, $claim->reference_id);
$this->assertEquals(get_class($user), $claim->reference_type);
}
/** @test */
public function adjust_stock_expired_claims_dont_affect_current_availability()
{
$product = Product::factory()->withStocks(100)->create();
// Create an expired claim using adjustStock
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 30,
from: now()->subDays(5),
until: now()->subDays(1)
);
// Available stock is reduced (the DECREASE is permanent)
$this->assertEquals(70, $product->getAvailableStock());
// Claimed stock still shows the pending claim
$this->assertEquals(30, $product->getClaimedStock());
// Active claims (not expired) should be empty
$activeClaims = $product->claims()->get();
$this->assertCount(0, $activeClaims);
}
/** @test */
public function adjust_stock_releasing_claimed_updates_calculations()
{
$product = Product::factory()->withStocks(100)->create();
// Create claim using adjustStock
$product->adjustStock(
type: StockType::CLAIMED,
quantity: 40
);
$this->assertEquals(60, $product->getAvailableStock());
$this->assertEquals(40, $product->getClaimedStock());
// Find and release the claim
$claim = $product->stocks()
->where('type', StockType::CLAIMED->value)
->where('status', StockStatus::PENDING->value)
->first();
$claim->release();
// Claimed stock should drop to 0
$this->assertEquals(0, $product->getClaimedStock());
// Available stock is restored when claim is released
$this->assertEquals(100, $product->getAvailableStock());
}
}

View File

@ -234,4 +234,201 @@ class StockManagementTest extends TestCase
$this->assertFalse($product->fresh()->isInStock());
}
/** @test */
public function it_can_adjust_stock_with_from_parameter_for_claimed_type()
{
$product = Product::factory()->withStocks(100)->create();
$fromDate = now()->addDays(3);
$untilDate = now()->addDays(10);
$result = $product->adjustStock(
type: \Blax\Shop\Enums\StockType::CLAIMED,
quantity: 15,
until: $untilDate,
from: $fromDate
);
// adjustStock(CLAIMED) now returns ProductStock (delegates to claimStock)
$this->assertInstanceOf(\Blax\Shop\Models\ProductStock::class, $result);
// Now creates two entries: DECREASE (COMPLETED) + CLAIMED (PENDING)
$claimedStock = $product->stocks()->where('type', 'claimed')->first();
$this->assertNotNull($claimedStock);
$this->assertEquals(15, $claimedStock->quantity); // Positive quantity
$this->assertEquals($fromDate->format('Y-m-d H:i:s'), $claimedStock->claimed_from->format('Y-m-d H:i:s'));
$this->assertEquals($untilDate->format('Y-m-d H:i:s'), $claimedStock->expires_at->format('Y-m-d H:i:s'));
// Check for the DECREASE entry
$decreaseStock = $product->stocks()->where('type', 'decrease')->first();
$this->assertNotNull($decreaseStock);
$this->assertEquals(-15, $decreaseStock->quantity);
}
/** @test */
public function it_uses_now_as_default_from_date_for_claimed_type()
{
$product = Product::factory()->withStocks(100)->create();
$result = $product->adjustStock(
type: \Blax\Shop\Enums\StockType::CLAIMED,
quantity: 10
);
// adjustStock(CLAIMED) now returns ProductStock
$this->assertInstanceOf(\Blax\Shop\Models\ProductStock::class, $result);
$claimedStock = $product->stocks()->where('type', 'claimed')->first();
// claimed_from defaults to null when not provided (claim active immediately)
$this->assertNull($claimedStock->claimed_from);
}
/** @test */
public function it_does_not_set_claimed_from_for_non_claimed_types()
{
$product = Product::factory()->withStocks(100)->create();
$product->adjustStock(
type: \Blax\Shop\Enums\StockType::INCREASE,
quantity: 10,
from: now()->addDays(5)
);
$stock = $product->stocks()->where('type', 'increase')->first();
$this->assertNull($stock->claimed_from);
}
/** @test */
public function it_can_adjust_stock_with_note_parameter()
{
$product = Product::factory()->withStocks(100)->create();
$note = 'Customer requested extra units for bulk order #12345';
$result = $product->adjustStock(
type: \Blax\Shop\Enums\StockType::INCREASE,
quantity: 50,
note: $note
);
$this->assertTrue($result);
$stock = $product->stocks()->where('type', 'increase')->where('quantity', 50)->first();
$this->assertNotNull($stock);
$this->assertEquals($note, $stock->note);
}
/** @test */
public function it_can_adjust_stock_with_referencable_model()
{
$product = Product::factory()->withStocks(100)->create();
$referencedProduct = Product::factory()->create();
$result = $product->adjustStock(
type: \Blax\Shop\Enums\StockType::DECREASE,
quantity: 10,
referencable: $referencedProduct
);
$this->assertTrue($result);
$stock = $product->stocks()->where('type', 'decrease')->where('quantity', -10)->first();
$this->assertNotNull($stock);
$this->assertEquals(Product::class, $stock->reference_type);
$this->assertEquals($referencedProduct->id, $stock->reference_id);
}
/** @test */
public function it_can_adjust_stock_with_all_parameters_combined()
{
$product = Product::factory()->withStocks(100)->create();
$referencedProduct = Product::factory()->create();
$fromDate = now()->addDays(2);
$untilDate = now()->addDays(7);
$note = 'Reserved for special event booking';
$result = $product->adjustStock(
type: \Blax\Shop\Enums\StockType::CLAIMED,
quantity: 25,
until: $untilDate,
from: $fromDate,
note: $note,
referencable: $referencedProduct
);
// adjustStock(CLAIMED) now returns ProductStock
$this->assertInstanceOf(\Blax\Shop\Models\ProductStock::class, $result);
$stock = $product->stocks()->where('type', 'claimed')->first();
$this->assertNotNull($stock);
$this->assertEquals(25, $stock->quantity); // Positive quantity
$this->assertEquals('pending', $stock->status->value);
$this->assertEquals($fromDate->format('Y-m-d H:i:s'), $stock->claimed_from->format('Y-m-d H:i:s'));
$this->assertEquals($untilDate->format('Y-m-d H:i:s'), $stock->expires_at->format('Y-m-d H:i:s'));
$this->assertEquals($note, $stock->note);
$this->assertEquals(Product::class, $stock->reference_type);
$this->assertEquals($referencedProduct->id, $stock->reference_id);
}
/** @test */
public function it_adjusts_stock_with_correct_quantity_signs_based_on_type()
{
$product = Product::factory()->withStocks(100)->create();
// INCREASE should add stock (positive)
$product->adjustStock(
type: \Blax\Shop\Enums\StockType::INCREASE,
quantity: 20
);
$increaseStock = $product->stocks()->where('type', 'increase')->where('quantity', 20)->first();
$this->assertNotNull($increaseStock);
$this->assertEquals(20, $increaseStock->quantity);
// RETURN should add stock (positive)
$product->adjustStock(
type: \Blax\Shop\Enums\StockType::RETURN,
quantity: 15
);
$returnStock = $product->stocks()->where('type', 'return')->where('quantity', 15)->first();
$this->assertNotNull($returnStock);
$this->assertEquals(15, $returnStock->quantity);
// DECREASE should remove stock (negative)
$product->adjustStock(
type: \Blax\Shop\Enums\StockType::DECREASE,
quantity: 10
);
$decreaseStock = $product->stocks()->where('type', 'decrease')->where('quantity', -10)->first();
$this->assertNotNull($decreaseStock);
$this->assertEquals(-10, $decreaseStock->quantity);
// CLAIMED now creates two entries: DECREASE + CLAIMED (positive quantity)
$product->adjustStock(
type: \Blax\Shop\Enums\StockType::CLAIMED,
quantity: 5
);
$claimedStock = $product->stocks()->where('type', 'claimed')->where('quantity', 5)->first();
$this->assertNotNull($claimedStock);
$this->assertEquals(5, $claimedStock->quantity); // Now positive
// And also creates a DECREASE entry
$claimedDecrease = $product->stocks()->where('type', 'decrease')->where('quantity', -5)->first();
$this->assertNotNull($claimedDecrease);
$this->assertEquals(-5, $claimedDecrease->quantity);
}
/** @test */
public function it_returns_false_when_adjusting_stock_with_management_disabled()
{
$product = Product::factory()->create([
'manage_stock' => false,
]);
$result = $product->adjustStock(
type: \Blax\Shop\Enums\StockType::INCREASE,
quantity: 10
);
$this->assertFalse($result);
$this->assertCount(0, $product->stocks);
}
}