diff --git a/database/factories/ProductPriceFactory.php b/database/factories/ProductPriceFactory.php index c6fa51b..41cfdc8 100644 --- a/database/factories/ProductPriceFactory.php +++ b/database/factories/ProductPriceFactory.php @@ -4,7 +4,6 @@ namespace Blax\Shop\Database\Factories; use Blax\Shop\Models\ProductPrice; use Illuminate\Database\Eloquent\Factories\Factory; -use Illuminate\Support\Str; class ProductPriceFactory extends Factory { @@ -12,10 +11,21 @@ class ProductPriceFactory extends Factory public function definition() { + $type = $this->faker->randomElement(['one_time', 'recurring']); + $unit_amount = $this->faker->randomFloat(2, 100, 40000); + $sale_unit_amount = $this->faker->randomFloat(2, $unit_amount * 0.5, $unit_amount * 0.80); + return [ + 'type' => $type, + 'billing_scheme' => $this->faker->randomElement(['per_unit', 'tiered']), 'unit_amount' => $this->faker->randomFloat(2, 1, 1000), 'currency' => 'EUR', 'is_default' => false, + 'unit_amount' => $unit_amount, + 'sale_unit_amount' => $sale_unit_amount, + 'interval' => $type === 'recurring' ? $this->faker->randomElement(['day', 'week', 'month', 'quarter', 'year']) : null, + 'interval_count' => $type === 'recurring' ? $this->faker->numberBetween(1, 12) : null, + 'trial_period_days' => $type === 'recurring' ? $this->faker->numberBetween(0, 30) : null, ]; } } diff --git a/src/Models/Product.php b/src/Models/Product.php index 9d7ba42..ef56ac7 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -2,13 +2,13 @@ namespace Blax\Shop\Models; -use App\Services\StripeService; use Blax\Shop\Contracts\Cartable; use Blax\Workkit\Traits\HasMetaTranslation; use Blax\Shop\Events\ProductCreated; use Blax\Shop\Events\ProductUpdated; use Blax\Shop\Contracts\Purchasable; -use Blax\Shop\Exceptions\NotEnoughStockException; +use Blax\Shop\Traits\HasPrices; +use Blax\Shop\Traits\HasStocks; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -16,11 +16,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; class Product extends Model implements Purchasable, Cartable { - use HasFactory, HasUuids, HasMetaTranslation; + use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices; protected $fillable = [ 'slug', @@ -126,14 +125,6 @@ class Product extends Model implements Purchasable, Cartable }); } - public function prices(): MorphMany - { - return $this->morphMany( - config('shop.models.product_price', ProductPrice::class), - 'purchasable' - ); - } - public function parent() { return $this->belongsTo(self::class, 'parent_id'); @@ -157,27 +148,11 @@ class Product extends Model implements Purchasable, Cartable return $this->hasMany(config('shop.models.product_attribute', 'Blax\Shop\Models\ProductAttribute')); } - public function stocks(): HasMany - { - return $this->hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock')); - } - public function actions(): HasMany { return $this->hasMany(config('shop.models.product_action', ProductAction::class)); } - public function getAvailableStocksAttribute(): int - { - return $this->stocks() - ->available() - ->where(function ($query) { - $query->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }) - ->sum('quantity') ?? 0; - } - public function purchases(): MorphMany { return $this->morphMany( @@ -215,110 +190,6 @@ class Product extends Model implements Purchasable, Cartable return true; } - public function getCurrentPrice(bool|null $sales_price = null): ?float - { - return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); - } - - public function isInStock(): bool - { - if (!$this->manage_stock) { - return true; - } - - return $this->getAvailableStock() > 0; - } - - public function decreaseStock(int $quantity = 1): bool - { - if (!$this->manage_stock) { - return true; - } - - if ($this->AvailableStocks < $quantity) { - return throw new NotEnoughStockException("Not enough stock available for product ID {$this->id}"); - } - - $this->stocks()->create([ - 'quantity' => -$quantity, - 'type' => 'decrease', - 'status' => 'completed', - ]); - - $this->logStockChange(-$quantity, 'decrease'); - - $this->save(); - - return true; - } - - public function increaseStock(int $quantity = 1): bool - { - if (!$this->manage_stock) { - return false; - } - - $this->stocks()->create([ - 'quantity' => $quantity, - 'type' => 'increase', - 'status' => 'completed', - ]); - - $this->logStockChange($quantity, 'increase'); - - $this->save(); - - return true; - } - - public function reserveStock( - int $quantity, - $reference = null, - ?\DateTimeInterface $until = null, - ?string $note = null - ): ?\Blax\Shop\Models\ProductStock { - - if (!$this->manage_stock) { - return null; - } - - $stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); - - return $stockModel::reserve( - $this, - $quantity, - $reference, - $until, - $note - ); - } - - public function getAvailableStock(): int - { - if (!$this->manage_stock) { - return PHP_INT_MAX; - } - - return max(0, $this->AvailableStocks); - } - - public function getReservedStock(): int - { - return $this->activeStocks()->sum('quantity'); - } - - protected function logStockChange(int $quantityChange, string $type): void - { - DB::table('product_stock_logs')->insert([ - 'product_id' => $this->id, - 'quantity_change' => $quantityChange, - 'quantity_after' => $this->stock_quantity, - 'type' => $type, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - public static function getAvailableActions(): array { return ProductAction::getAvailableActions(); @@ -354,17 +225,6 @@ class Product extends Model implements Purchasable, Cartable return $this->relatedProducts()->wherePivot('type', 'cross-sell'); } - public function scopeInStock($query) - { - return $query->where(function ($q) { - $q->where('manage_stock', false) - ->orWhere(function ($q2) { - $q2->where('manage_stock', true) - ->whereRaw("(SELECT SUM(quantity) FROM " . config('shop.tables.product_stocks', 'product_stocks') . " WHERE product_id = " . config('shop.tables.products', 'products') . ".id) > 0"); - }); - }); - } - public function scopeVisible($query) { return $query->where('is_visible', true) @@ -391,41 +251,6 @@ class Product extends Model implements Purchasable, Cartable }); } - public function scopePriceRange($query, ?float $min = null, ?float $max = null) - { - if ($min !== null) { - $query->where('price', '>=', $min); - } - if ($max !== null) { - $query->where('price', '<=', $max); - } - return $query; - } - - public function scopeOrderByPrice($query, string $direction = 'asc') - { - return $query->orderBy('price', $direction); - } - - public function scopeLowStock($query) - { - $stockTable = config('shop.tables.product_stocks', 'product_stocks'); - $productTable = config('shop.tables.products', 'products'); - - return $query->where('manage_stock', true) - ->whereNotNull('low_stock_threshold') - ->whereRaw("(\n SELECT COALESCE(SUM(quantity), 0)\n FROM {$stockTable}\n WHERE {$stockTable}.product_id = {$productTable}.id\n AND {$stockTable}.status IN ('completed', 'pending')\n AND ({$stockTable}.expires_at IS NULL OR {$stockTable}.expires_at > ?)\n ) <= {$productTable}.low_stock_threshold", [now()]); - } - - public function isLowStock(): bool - { - if (!$this->manage_stock || !$this->low_stock_threshold) { - return false; - } - - return $this->getAvailableStock() <= $this->low_stock_threshold; - } - public function isVisible(): bool { if (!$this->is_visible || $this->status !== 'published') { @@ -506,31 +331,4 @@ class Product extends Model implements Purchasable, Cartable return parent::newInstance($attributes, $exists); } - - public function defaultPrice() - { - return $this->prices()->where('is_default', true); - } - - public function getPriceAttribute(): ?float - { - return $this->getCurrentPrice(); - } - - public function reservations() - { - $stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); - - return $stockModel::reservations() - ->where(function ($query) { - $query->whereNull('expires_at') - ->orWhere('expires_at', '>', now()); - }) - ->where('product_id', $this->id); - } - - public function hasPrice(): bool - { - return $this->prices()->exists(); - } } diff --git a/src/Traits/HasPrices.php b/src/Traits/HasPrices.php new file mode 100644 index 0000000..0c9bdc2 --- /dev/null +++ b/src/Traits/HasPrices.php @@ -0,0 +1,63 @@ +morphMany( + config('shop.models.product_price', ProductPrice::class), + 'purchasable' + ); + } + + public function getCurrentPrice(bool|null $sales_price = null): ?float + { + return $this->defaultPrice()->first()?->getCurrentPrice($sales_price ?? $this->isOnSale()); + } + + public function scopePriceRange($query, ?float $min = null, ?float $max = null) + { + return $query->whereHas('prices', function ($q) use ($min, $max) { + if ($min !== null) { + $q->where('unit_amount', '>=', $min); + } + if ($max !== null) { + $q->where('unit_amount', '<=', $max); + } + }); + } + + public function scopeOrderByPrice($query, string $direction = 'asc') + { + return $query->join('product_prices', function ($join) use ($query) { + $join->on($query->getModel()->getTable() . '.id', '=', 'product_prices.purchasable_id') + ->where('product_prices.purchasable_type', '=', get_class($query->getModel())) + ->where('product_prices.is_default', '=', true); + })->orderBy('product_prices.unit_amount', $direction) + ->select($query->getModel()->getTable() . '.*'); + } + + + public function defaultPrice() + { + return $this->prices()->where('is_default', true); + } + + public function getPriceAttribute(): ?float + { + return $this->getCurrentPrice(); + } + + public function hasPrice(): bool + { + return $this->prices()->exists(); + } +} diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php new file mode 100644 index 0000000..b607f0b --- /dev/null +++ b/src/Traits/HasStocks.php @@ -0,0 +1,171 @@ +hasMany(config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock')); + } + + public function getAvailableStocksAttribute(): int + { + return $this->stocks() + ->available() + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->sum('quantity') ?? 0; + } + + public function isInStock(): bool + { + if (!$this->manage_stock) { + return true; + } + + return $this->getAvailableStock() > 0; + } + + public function decreaseStock(int $quantity = 1, Carbon|null $until = null): bool + { + if (!$this->manage_stock) { + return true; + } + + if ($this->AvailableStocks < $quantity) { + return throw new NotEnoughStockException("Not enough stock available for product ID {$this->id}"); + } + + $this->stocks()->create([ + 'quantity' => -$quantity, + 'type' => 'decrease', + 'status' => 'completed', + 'expires_at' => $until, + ]); + + $this->logStockChange(-$quantity, 'decrease'); + + $this->save(); + + return true; + } + + public function increaseStock(int $quantity = 1): bool + { + if (!$this->manage_stock) { + return false; + } + + $this->stocks()->create([ + 'quantity' => $quantity, + 'type' => 'increase', + 'status' => 'completed', + ]); + + $this->logStockChange($quantity, 'increase'); + + $this->save(); + + return true; + } + + public function reserveStock( + int $quantity, + $reference = null, + ?\DateTimeInterface $until = null, + ?string $note = null + ): ?\Blax\Shop\Models\ProductStock { + + if (!$this->manage_stock) { + return null; + } + + $stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); + + return $stockModel::reserve( + $this, + $quantity, + $reference, + $until, + $note + ); + } + + public function getAvailableStock(): int + { + if (!$this->manage_stock) { + return PHP_INT_MAX; + } + + return max(0, $this->AvailableStocks); + } + + public function getReservedStock(): int + { + return $this->activeStocks()->sum('quantity'); + } + + protected function logStockChange(int $quantityChange, string $type): void + { + DB::table('product_stock_logs')->insert([ + 'product_id' => $this->id, + 'quantity_change' => $quantityChange, + 'quantity_after' => $this->stock_quantity, + 'type' => $type, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function scopeInStock($query) + { + return $query->where(function ($q) { + $q->where('manage_stock', false) + ->orWhere(function ($q2) { + $q2->where('manage_stock', true) + ->whereRaw("(SELECT SUM(quantity) FROM " . config('shop.tables.product_stocks', 'product_stocks') . " WHERE product_id = " . config('shop.tables.products', 'products') . ".id) > 0"); + }); + }); + } + + public function scopeLowStock($query) + { + $stockTable = config('shop.tables.product_stocks', 'product_stocks'); + $productTable = config('shop.tables.products', 'products'); + + return $query->where('manage_stock', true) + ->whereNotNull('low_stock_threshold') + ->whereRaw("(SELECT COALESCE(SUM(quantity), 0) FROM {$stockTable} WHERE {$stockTable}.product_id = {$productTable}.id AND {$stockTable}.status IN ('completed', 'pending') AND ({$stockTable}.expires_at IS NULL OR {$stockTable}.expires_at > ?)) <= {$productTable}.low_stock_threshold", [ + now() + ]); + } + + public function isLowStock(): bool + { + if (!$this->manage_stock || !$this->low_stock_threshold) { + return false; + } + + return $this->getAvailableStock() <= $this->low_stock_threshold; + } + + public function reservations() + { + $stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); + + return $stockModel::reservations() + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->where('product_id', $this->id); + } +} diff --git a/tests/Feature/ProductScopeTest.php b/tests/Feature/ProductScopeTest.php index b9e2e14..9b832cd 100644 --- a/tests/Feature/ProductScopeTest.php +++ b/tests/Feature/ProductScopeTest.php @@ -59,15 +59,83 @@ class ProductScopeTest extends TestCase /** @test */ public function it_can_filter_by_price_range() { - Product::factory()->create(['meta' => json_encode(['price' => 50])]); - Product::factory()->create(['meta' => json_encode(['price' => 100])]); - Product::factory()->create(['meta' => json_encode(['price' => 150])]); + $product1 = Product::factory()->withPrices(1, 50)->create(); + $product2 = Product::factory()->withPrices(1, 100)->create(); + $product3 = Product::factory()->withPrices(1, 150)->create(); - // Note: This test assumes the scope uses a 'price' column - // which may need adjustment based on actual implementation - $products = Product::all(); + $inRange = Product::priceRange(75, 125)->get(); - $this->assertCount(3, $products); + $this->assertCount(1, $inRange); + $this->assertTrue($inRange->contains($product2)); + } + + /** @test */ + public function it_can_filter_by_minimum_price_only() + { + $product1 = Product::factory()->withPrices(1, 50)->create(); + $product2 = Product::factory()->withPrices(1, 100)->create(); + $product3 = Product::factory()->withPrices(1, 150)->create(); + + $minPrice = Product::priceRange(100)->get(); + + $this->assertCount(2, $minPrice); + $this->assertTrue($minPrice->contains($product2)); + $this->assertTrue($minPrice->contains($product3)); + } + + /** @test */ + public function it_can_filter_by_maximum_price_only() + { + $product1 = Product::factory()->withPrices(1, 50)->create(); + $product2 = Product::factory()->withPrices(1, 100)->create(); + $product3 = Product::factory()->withPrices(1, 150)->create(); + + $maxPrice = Product::priceRange(null, 100)->get(); + + $this->assertCount(2, $maxPrice); + $this->assertTrue($maxPrice->contains($product1)); + $this->assertTrue($maxPrice->contains($product2)); + } + + /** @test */ + public function it_can_order_products_by_price_ascending() + { + $product1 = Product::factory()->withPrices(1, 150)->create(['name' => 'Expensive']); + $product2 = Product::factory()->withPrices(1, 50)->create(['name' => 'Cheap']); + $product3 = Product::factory()->withPrices(1, 100)->create(['name' => 'Medium']); + + $ordered = Product::orderByPrice('asc')->get(); + + $this->assertEquals($product2->id, $ordered->first()->id); + $this->assertEquals($product1->id, $ordered->last()->id); + } + + /** @test */ + public function it_can_order_products_by_price_descending() + { + $product1 = Product::factory()->withPrices(1, 150)->create(['name' => 'Expensive']); + $product2 = Product::factory()->withPrices(1, 50)->create(['name' => 'Cheap']); + $product3 = Product::factory()->withPrices(1, 100)->create(['name' => 'Medium']); + + $ordered = Product::orderByPrice('desc')->get(); + + $this->assertEquals($product1->id, $ordered->first()->id); + $this->assertEquals($product2->id, $ordered->last()->id); + } + + /** @test */ + public function it_can_combine_price_range_and_order_by_price() + { + $product1 = Product::factory()->withPrices(1, 50)->create(); + $product2 = Product::factory()->withPrices(1, 100)->create(); + $product3 = Product::factory()->withPrices(1, 150)->create(); + $product4 = Product::factory()->withPrices(1, 200)->create(); + + $filtered = Product::priceRange(75, 175)->orderByPrice('asc')->get(); + + $this->assertCount(2, $filtered); + $this->assertEquals($product2->id, $filtered->first()->id); + $this->assertEquals($product3->id, $filtered->last()->id); } /** @test */ @@ -151,7 +219,7 @@ class ProductScopeTest extends TestCase public function in_stock_scope_includes_products_without_stock_management() { Product::factory()->create(['manage_stock' => false]); - + $managedProduct = Product::factory()->create(['manage_stock' => true]); $managedProduct->increaseStock(10); @@ -164,7 +232,7 @@ class ProductScopeTest extends TestCase public function in_stock_scope_excludes_out_of_stock_products() { $outOfStock = Product::factory()->create(['manage_stock' => true]); - + $inStock = Product::factory()->create(['manage_stock' => true]); $inStock->increaseStock(10);