diff --git a/config/shop.php b/config/shop.php index 630431d..7fc1ef9 100644 --- a/config/shop.php +++ b/config/shop.php @@ -10,10 +10,13 @@ return [ 'product_action_runs' => 'product_action_runs', 'product_attributes' => 'product_attributes', 'product_categories' => 'product_categories', + 'product_relations' => 'product_relations', 'product_prices' => 'product_prices', 'product_purchases' => 'product_purchases', + 'product_actions' => 'product_actions', 'product_stocks' => 'product_stocks', 'products' => 'products', + 'cart_discounts' => 'cart_discounts', ], // Model classes (allow overriding in main instance) diff --git a/src/Enums/ProductRelationType.php b/src/Enums/ProductRelationType.php new file mode 100644 index 0000000..868dd89 --- /dev/null +++ b/src/Enums/ProductRelationType.php @@ -0,0 +1,30 @@ + 'Related', + self::UPSELL => 'Upsell', + self::CROSS_SELL => 'Cross-sell', + self::VARIATION => 'Variation', + self::DOWNSELL => 'Downsell', + self::ADD_ON => 'Add-on', + self::BUNDLE => 'Bundle', + self::SINGLE => 'Single', + }; + } +} diff --git a/src/Models/Product.php b/src/Models/Product.php index ec7a4c8..3867aaf 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -13,18 +13,18 @@ use Blax\Shop\Enums\StockStatus; use Blax\Shop\Enums\StockType; use Blax\Shop\Traits\HasCategories; use Blax\Shop\Traits\HasPrices; +use Blax\Shop\Traits\HasProductRelations; use Blax\Shop\Traits\HasStocks; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Facades\Cache; class Product extends Model implements Purchasable, Cartable { - use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasCategories; + use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasCategories, HasProductRelations; protected $fillable = [ 'slug', @@ -204,26 +204,6 @@ class Product extends Model implements Purchasable, Cartable ); } - public function relatedProducts(): BelongsToMany - { - return $this->belongsToMany( - self::class, - 'product_relations', - 'product_id', - 'related_product_id' - )->withPivot('type')->withTimestamps(); - } - - public function upsells(): BelongsToMany - { - return $this->relatedProducts()->wherePivot('type', 'upsell'); - } - - public function crossSells(): BelongsToMany - { - return $this->relatedProducts()->wherePivot('type', 'cross-sell'); - } - public function scopeVisible($query) { return $query->where('is_visible', true) diff --git a/src/Traits/HasCategories.php b/src/Traits/HasCategories.php index 050c35e..d937d44 100644 --- a/src/Traits/HasCategories.php +++ b/src/Traits/HasCategories.php @@ -3,10 +3,15 @@ namespace Blax\Shop\Traits; use Blax\Shop\Models\ProductCategory; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\BelongsToMany; trait HasCategories { + /** + * Categories assigned to the model. + */ public function categories(): BelongsToMany { return $this->belongsToMany( @@ -15,18 +20,30 @@ trait HasCategories ); } - public function scopeByCategory($query, ProductCategory|string $category_or_id) + /** + * Scope: filter by a single category (id or slug). + * + * @param Builder $query + */ + public function scopeByCategory(Builder $query, ProductCategory|string $category_or_id): Builder { $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); + return $query->whereHas('categories', function (Builder $q) use ($categoryId) { + $q->where('id', $categoryId) + ->orWhere('slug', $categoryId); }); } - public function scopeByCategories($query, array $category_ids) + /** + * Scope: filter by all provided categories. + * + * @param Builder $query + * @param array $category_ids + */ + public function scopeByCategories(Builder $query, array $category_ids): Builder { foreach ($category_ids as $category_id) { $query->byCategory($category_id); @@ -35,18 +52,30 @@ trait HasCategories return $query; } - public function scopeWithoutCategory($query, ProductCategory|string $category_or_id) + /** + * Scope: exclude a single category (id or slug). + * + * @param Builder $query + */ + public function scopeWithoutCategory(Builder $query, ProductCategory|string $category_or_id): Builder { $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); + return $query->whereDoesntHave('categories', function (Builder $q) use ($categoryId) { + $q->where('id', $categoryId) + ->orWhere('slug', $categoryId); }); } - public function scopeWithoutCategories($query, array $category_ids) + /** + * Scope: exclude any of the provided categories. + * + * @param Builder $query + * @param array $category_ids + */ + public function scopeWithoutCategories(Builder $query, array $category_ids): Builder { foreach ($category_ids as $category_id) { $query->withoutCategory($category_id); @@ -55,11 +84,19 @@ trait HasCategories return $query; } + /** + * Attach a single category model. + */ public function assignCategory(ProductCategory $category): void { $this->categories()->attach($category); } + /** + * Attach multiple category models. + * + * @param array $categories + */ public function assignCategories(array $categories): void { foreach ($categories as $category) { @@ -67,11 +104,19 @@ trait HasCategories } } + /** + * Detach a single category model. + */ public function removeCategory(ProductCategory $category): void { $this->categories()->detach($category); } + /** + * Detach multiple category models. + * + * @param array $categories + */ public function removeCategories(array $categories): void { foreach ($categories as $category) { @@ -79,17 +124,30 @@ trait HasCategories } } + /** + * Sync categories by ids or models. + * + * @param array $categories + */ public function syncCategories(array $categories): void { $this->categories()->sync($categories); } + /** + * Attach or create a category by name. + */ public function assignCategoryByName(string $name): void { $category = config('shop.models.product_category')::firstOrCreate(['name' => $name]); $this->assignCategory($category); } + /** + * Attach or create categories by names. + * + * @param array $names + */ public function assignCategoriesByNames(array $names): void { foreach ($names as $name) { @@ -97,16 +155,141 @@ trait HasCategories } } - public function asssignCategoryBySlug(string $slug): void + /** + * Attach or create a category by slug. + */ + public function assignCategoryBySlug(string $slug): void { $category = config('shop.models.product_category')::firstOrCreate(['slug' => $slug]); $this->assignCategory($category); } + /** + * Attach or create categories by slugs. + * + * @param array $slugs + */ public function assignCategoriesBySlugs(array $slugs): void { foreach ($slugs as $slug) { - $this->asssignCategoryBySlug($slug); + $this->assignCategoryBySlug($slug); } } + + /** + * Backward compatible alias with previous typo. + * + * @deprecated Use assignCategoryBySlug instead. + */ + public function asssignCategoryBySlug(string $slug): void + { + $this->assignCategoryBySlug($slug); + } + + /** + * Check if the model is linked to a category (id or slug). + */ + public function hasCategory(ProductCategory|string $category_or_id): bool + { + $categoryId = $category_or_id instanceof ProductCategory + ? $category_or_id->id + : $category_or_id; + + if ($this->relationLoaded('categories')) { + /** @var Collection $categories */ + $categories = $this->categories; + + return $categories->contains(function (ProductCategory $category) use ($categoryId) { + return $category->id === $categoryId || $category->slug === $categoryId; + }); + } + + return $this->categories() + ->where(function (Builder $q) use ($categoryId) { + $q->where('id', $categoryId) + ->orWhere('slug', $categoryId); + }) + ->exists(); + } + + /** + * Check if the model has any of the provided categories. + * + * @param array $category_ids + */ + public function hasAnyCategory(array $category_ids): bool + { + if ($this->relationLoaded('categories')) { + /** @var Collection $categories */ + $categories = $this->categories; + $ids = []; + $slugs = []; + + foreach ($category_ids as $category) { + $ids[] = $category instanceof ProductCategory ? $category->id : $category; + $slugs[] = $category instanceof ProductCategory ? $category->slug : $category; + } + + return $categories->contains(function (ProductCategory $category) use ($ids, $slugs) { + return in_array($category->id, $ids, true) || in_array($category->slug, $slugs, true); + }); + } + + $ids = []; + $slugs = []; + + foreach ($category_ids as $category) { + $ids[] = $category instanceof ProductCategory ? $category->id : $category; + $slugs[] = $category instanceof ProductCategory ? $category->slug : $category; + } + + return $this->categories() + ->where(function (Builder $q) use ($ids, $slugs) { + $q->whereIn('id', $ids) + ->orWhereIn('slug', $slugs); + }) + ->exists(); + } + + /** + * Check if the model has all of the provided categories. + * + * @param array $category_ids + */ + public function hasAllCategories(array $category_ids): bool + { + if ($category_ids === []) { + return false; + } + + if ($this->relationLoaded('categories')) { + /** @var Collection $categories */ + $categories = $this->categories; + + foreach ($category_ids as $category) { + if (! $this->hasCategory($category)) { + return false; + } + } + + return true; + } + + $ids = []; + $slugs = []; + + foreach ($category_ids as $category) { + $ids[] = $category instanceof ProductCategory ? $category->id : $category; + $slugs[] = $category instanceof ProductCategory ? $category->slug : $category; + } + + $count = $this->categories() + ->where(function (Builder $q) use ($ids, $slugs) { + $q->whereIn('id', $ids) + ->orWhereIn('slug', $slugs); + }) + ->count(); + + return $count >= count($category_ids); + } } diff --git a/src/Traits/HasProductRelations.php b/src/Traits/HasProductRelations.php new file mode 100644 index 0000000..517f77b --- /dev/null +++ b/src/Traits/HasProductRelations.php @@ -0,0 +1,66 @@ +belongsToMany( + self::class, + config('shop.tables.product_relations', 'product_relations'), + 'product_id', + 'related_product_id' + )->withPivot('type', 'sort_order')->withTimestamps(); + } + + public function relatedProducts(): BelongsToMany + { + return $this->relationsByType(ProductRelationType::RELATED); + } + + public function variantProducts(): BelongsToMany + { + return $this->relationsByType(ProductRelationType::VARIATION); + } + + public function upsellProducts(): BelongsToMany + { + return $this->relationsByType(ProductRelationType::UPSELL); + } + + public function crossSellProducts(): BelongsToMany + { + return $this->relationsByType(ProductRelationType::CROSS_SELL); + } + + public function downsellProducts(): BelongsToMany + { + return $this->relationsByType(ProductRelationType::DOWNSELL); + } + + public function addOnProducts(): BelongsToMany + { + return $this->relationsByType(ProductRelationType::ADD_ON); + } + + public function bundleProducts(): BelongsToMany + { + return $this->relationsByType(ProductRelationType::BUNDLE); + } + + public function singleProducts(): BelongsToMany + { + return $this->relationsByType(ProductRelationType::SINGLE); + } + + public function relationsByType(ProductRelationType|string $type): BelongsToMany + { + $typeValue = $type instanceof ProductRelationType ? $type->value : $type; + + return $this->productRelations()->wherePivot('type', $typeValue); + } +} diff --git a/tests/Feature/ProductManagementTest.php b/tests/Feature/ProductManagementTest.php index 7bcc667..da80b32 100644 --- a/tests/Feature/ProductManagementTest.php +++ b/tests/Feature/ProductManagementTest.php @@ -2,6 +2,7 @@ namespace Blax\Shop\Tests\Feature; +use Blax\Shop\Enums\ProductRelationType; use Blax\Shop\Enums\ProductStatus; use Blax\Shop\Enums\ProductType; use Blax\Shop\Models\Product; @@ -157,11 +158,11 @@ class ProductManagementTest extends TestCase $product = Product::factory()->create(); $relatedProduct = Product::factory()->create(); - $product->relatedProducts()->attach($relatedProduct->id, [ - 'type' => 'related', + $product->productRelations()->attach($relatedProduct->id, [ + 'type' => ProductRelationType::RELATED->value, ]); - $this->assertTrue($product->relatedProducts->contains($relatedProduct)); + $this->assertTrue($product->relatedProducts()->get()->contains($relatedProduct)); } /** @test */ @@ -170,11 +171,11 @@ class ProductManagementTest extends TestCase $product = Product::factory()->create(); $upsellProduct = Product::factory()->create(); - $product->relatedProducts()->attach($upsellProduct->id, [ - 'type' => 'upsell', + $product->productRelations()->attach($upsellProduct->id, [ + 'type' => ProductRelationType::UPSELL->value, ]); - $this->assertTrue($product->upsells->contains($upsellProduct)); + $this->assertTrue($product->upsellProducts()->get()->contains($upsellProduct)); } /** @test */ @@ -183,11 +184,11 @@ class ProductManagementTest extends TestCase $product = Product::factory()->create(); $crossSellProduct = Product::factory()->create(); - $product->relatedProducts()->attach($crossSellProduct->id, [ - 'type' => 'cross-sell', + $product->productRelations()->attach($crossSellProduct->id, [ + 'type' => ProductRelationType::CROSS_SELL->value, ]); - $this->assertTrue($product->crossSells->contains($crossSellProduct)); + $this->assertTrue($product->crossSellProducts()->get()->contains($crossSellProduct)); } /** @test */ diff --git a/tests/Unit/ProductRelationsTest.php b/tests/Unit/ProductRelationsTest.php new file mode 100644 index 0000000..6810da3 --- /dev/null +++ b/tests/Unit/ProductRelationsTest.php @@ -0,0 +1,93 @@ +create(); + $related = Product::factory()->create(); + $upsell = Product::factory()->create(); + $crossSell = Product::factory()->create(); + + $product->productRelations()->attach($related->id, [ + 'type' => ProductRelationType::RELATED->value, + 'sort_order' => 1, + ]); + + $product->productRelations()->attach($upsell->id, [ + 'type' => ProductRelationType::UPSELL->value, + 'sort_order' => 2, + ]); + + $product->productRelations()->attach($crossSell->id, [ + 'type' => ProductRelationType::CROSS_SELL->value, + 'sort_order' => 3, + ]); + + $this->assertTrue($product->relatedProducts()->get()->contains($related)); + $this->assertTrue($product->upsellProducts()->get()->contains($upsell)); + $this->assertTrue($product->crossSellProducts()->get()->contains($crossSell)); + + $this->assertSame(1, $product->relatedProducts()->first()->pivot->sort_order); + } + + /** @test */ + public function relations_by_type_accepts_enum_or_string() + { + $product = Product::factory()->create(); + $related = Product::factory()->create(); + + $product->productRelations()->attach($related->id, [ + 'type' => ProductRelationType::RELATED->value, + ]); + + $this->assertTrue($product->relationsByType(ProductRelationType::RELATED)->exists()); + $this->assertTrue($product->relationsByType('related')->exists()); + } + + /** @test */ + public function it_can_be_queried_by_specific_relation_types() + { + $withUpsell = Product::factory()->create(); + $upsell = Product::factory()->create(); + $withCrossSell = Product::factory()->create(); + $crossSell = Product::factory()->create(); + $unrelated = Product::factory()->create(); + + $withUpsell->productRelations()->attach($upsell->id, [ + 'type' => ProductRelationType::UPSELL->value, + 'sort_order' => 1, + ]); + + $withCrossSell->productRelations()->attach($crossSell->id, [ + 'type' => ProductRelationType::CROSS_SELL->value, + 'sort_order' => 2, + ]); + + $upsellMatch = Product::whereHas('upsellProducts', function ($query) use ($upsell) { + $query->whereKey($upsell->id); + })->get(); + + $crossSellMatch = Product::whereHas('crossSellProducts', function ($query) use ($crossSell) { + $query->whereKey($crossSell->id); + })->get(); + + $this->assertTrue($upsellMatch->contains($withUpsell)); + $this->assertFalse($upsellMatch->contains($withCrossSell)); + $this->assertFalse($upsellMatch->contains($unrelated)); + + $this->assertTrue($crossSellMatch->contains($withCrossSell)); + $this->assertFalse($crossSellMatch->contains($withUpsell)); + $this->assertFalse($crossSellMatch->contains($unrelated)); + } +}