diff --git a/src/Models/Product.php b/src/Models/Product.php index d540870..ec7a4c8 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -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) { diff --git a/src/Models/ProductStock.php b/src/Models/ProductStock.php index 7b77472..89b4cf1 100644 --- a/src/Models/ProductStock.php +++ b/src/Models/ProductStock.php @@ -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; }); } diff --git a/src/Traits/HasCategories.php b/src/Traits/HasCategories.php new file mode 100644 index 0000000..050c35e --- /dev/null +++ b/src/Traits/HasCategories.php @@ -0,0 +1,112 @@ +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); + } + } +} diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php index bf06ef9..4b400eb 100644 --- a/src/Traits/HasStocks.php +++ b/src/Traits/HasStocks.php @@ -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() ]); } diff --git a/tests/Feature/ProductCategoryTest.php b/tests/Feature/ProductCategoryTest.php index 93eb1ab..12e752a 100644 --- a/tests/Feature/ProductCategoryTest.php +++ b/tests/Feature/ProductCategoryTest.php @@ -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); + } } diff --git a/tests/Feature/ProductStockTest.php b/tests/Feature/ProductStockTest.php index 5736f26..6a96f6f 100644 --- a/tests/Feature/ProductStockTest.php +++ b/tests/Feature/ProductStockTest.php @@ -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()); + } } diff --git a/tests/Feature/StockManagementTest.php b/tests/Feature/StockManagementTest.php index a10feec..5818eb5 100644 --- a/tests/Feature/StockManagementTest.php +++ b/tests/Feature/StockManagementTest.php @@ -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); + } }