I category methods, IRA product relations & tests

This commit is contained in:
Fabian @ Blax Software 2025-12-05 09:21:07 +01:00
parent d28a63cd8a
commit 4a303c0f3f
7 changed files with 397 additions and 41 deletions

View File

@ -10,10 +10,13 @@ return [
'product_action_runs' => 'product_action_runs', 'product_action_runs' => 'product_action_runs',
'product_attributes' => 'product_attributes', 'product_attributes' => 'product_attributes',
'product_categories' => 'product_categories', 'product_categories' => 'product_categories',
'product_relations' => 'product_relations',
'product_prices' => 'product_prices', 'product_prices' => 'product_prices',
'product_purchases' => 'product_purchases', 'product_purchases' => 'product_purchases',
'product_actions' => 'product_actions',
'product_stocks' => 'product_stocks', 'product_stocks' => 'product_stocks',
'products' => 'products', 'products' => 'products',
'cart_discounts' => 'cart_discounts',
], ],
// Model classes (allow overriding in main instance) // Model classes (allow overriding in main instance)

View File

@ -0,0 +1,30 @@
<?php
namespace Blax\Shop\Enums;
enum ProductRelationType: string
{
case RELATED = 'related';
case UPSELL = 'upsell';
case CROSS_SELL = 'cross-sell';
case VARIATION = 'variation';
case DOWNSELL = 'downsell';
case ADD_ON = 'add-on';
case BUNDLE = 'bundle';
case SINGLE = 'single';
public function label(): string
{
return match ($this) {
self::RELATED => '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',
};
}
}

View File

@ -13,18 +13,18 @@ use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType; use Blax\Shop\Enums\StockType;
use Blax\Shop\Traits\HasCategories; use Blax\Shop\Traits\HasCategories;
use Blax\Shop\Traits\HasPrices; use Blax\Shop\Traits\HasPrices;
use Blax\Shop\Traits\HasProductRelations;
use Blax\Shop\Traits\HasStocks; use Blax\Shop\Traits\HasStocks;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
class Product extends Model implements Purchasable, Cartable class Product extends Model implements Purchasable, Cartable
{ {
use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasCategories; use HasFactory, HasUuids, HasMetaTranslation, HasStocks, HasPrices, HasCategories, HasProductRelations;
protected $fillable = [ protected $fillable = [
'slug', '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) public function scopeVisible($query)
{ {
return $query->where('is_visible', true) return $query->where('is_visible', true)

View File

@ -3,10 +3,15 @@
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Models\ProductCategory; use Blax\Shop\Models\ProductCategory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
trait HasCategories trait HasCategories
{ {
/**
* Categories assigned to the model.
*/
public function categories(): BelongsToMany public function categories(): BelongsToMany
{ {
return $this->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<static> $query
*/
public function scopeByCategory(Builder $query, ProductCategory|string $category_or_id): Builder
{ {
$categoryId = $category_or_id instanceof ProductCategory $categoryId = $category_or_id instanceof ProductCategory
? $category_or_id->id ? $category_or_id->id
: $category_or_id; : $category_or_id;
return $query->whereHas('categories', function ($q) use ($categoryId) { return $query->whereHas('categories', function (Builder $q) use ($categoryId) {
$q->where('id', $categoryId); $q->where('id', $categoryId)
->orWhere('slug', $categoryId);
}); });
} }
public function scopeByCategories($query, array $category_ids) /**
* Scope: filter by all provided categories.
*
* @param Builder<static> $query
* @param array<int, ProductCategory|string> $category_ids
*/
public function scopeByCategories(Builder $query, array $category_ids): Builder
{ {
foreach ($category_ids as $category_id) { foreach ($category_ids as $category_id) {
$query->byCategory($category_id); $query->byCategory($category_id);
@ -35,18 +52,30 @@ trait HasCategories
return $query; return $query;
} }
public function scopeWithoutCategory($query, ProductCategory|string $category_or_id) /**
* Scope: exclude a single category (id or slug).
*
* @param Builder<static> $query
*/
public function scopeWithoutCategory(Builder $query, ProductCategory|string $category_or_id): Builder
{ {
$categoryId = $category_or_id instanceof ProductCategory $categoryId = $category_or_id instanceof ProductCategory
? $category_or_id->id ? $category_or_id->id
: $category_or_id; : $category_or_id;
return $query->whereDoesntHave('categories', function ($q) use ($categoryId) { return $query->whereDoesntHave('categories', function (Builder $q) use ($categoryId) {
$q->where('id', $categoryId); $q->where('id', $categoryId)
->orWhere('slug', $categoryId);
}); });
} }
public function scopeWithoutCategories($query, array $category_ids) /**
* Scope: exclude any of the provided categories.
*
* @param Builder<static> $query
* @param array<int, ProductCategory|string> $category_ids
*/
public function scopeWithoutCategories(Builder $query, array $category_ids): Builder
{ {
foreach ($category_ids as $category_id) { foreach ($category_ids as $category_id) {
$query->withoutCategory($category_id); $query->withoutCategory($category_id);
@ -55,11 +84,19 @@ trait HasCategories
return $query; return $query;
} }
/**
* Attach a single category model.
*/
public function assignCategory(ProductCategory $category): void public function assignCategory(ProductCategory $category): void
{ {
$this->categories()->attach($category); $this->categories()->attach($category);
} }
/**
* Attach multiple category models.
*
* @param array<int, ProductCategory> $categories
*/
public function assignCategories(array $categories): void public function assignCategories(array $categories): void
{ {
foreach ($categories as $category) { foreach ($categories as $category) {
@ -67,11 +104,19 @@ trait HasCategories
} }
} }
/**
* Detach a single category model.
*/
public function removeCategory(ProductCategory $category): void public function removeCategory(ProductCategory $category): void
{ {
$this->categories()->detach($category); $this->categories()->detach($category);
} }
/**
* Detach multiple category models.
*
* @param array<int, ProductCategory> $categories
*/
public function removeCategories(array $categories): void public function removeCategories(array $categories): void
{ {
foreach ($categories as $category) { foreach ($categories as $category) {
@ -79,17 +124,30 @@ trait HasCategories
} }
} }
/**
* Sync categories by ids or models.
*
* @param array<int, ProductCategory|int|string> $categories
*/
public function syncCategories(array $categories): void public function syncCategories(array $categories): void
{ {
$this->categories()->sync($categories); $this->categories()->sync($categories);
} }
/**
* Attach or create a category by name.
*/
public function assignCategoryByName(string $name): void public function assignCategoryByName(string $name): void
{ {
$category = config('shop.models.product_category')::firstOrCreate(['name' => $name]); $category = config('shop.models.product_category')::firstOrCreate(['name' => $name]);
$this->assignCategory($category); $this->assignCategory($category);
} }
/**
* Attach or create categories by names.
*
* @param array<int, string> $names
*/
public function assignCategoriesByNames(array $names): void public function assignCategoriesByNames(array $names): void
{ {
foreach ($names as $name) { 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]); $category = config('shop.models.product_category')::firstOrCreate(['slug' => $slug]);
$this->assignCategory($category); $this->assignCategory($category);
} }
/**
* Attach or create categories by slugs.
*
* @param array<int, string> $slugs
*/
public function assignCategoriesBySlugs(array $slugs): void public function assignCategoriesBySlugs(array $slugs): void
{ {
foreach ($slugs as $slug) { 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<int, ProductCategory> $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<int, ProductCategory|string> $category_ids
*/
public function hasAnyCategory(array $category_ids): bool
{
if ($this->relationLoaded('categories')) {
/** @var Collection<int, ProductCategory> $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<int, ProductCategory|string> $category_ids
*/
public function hasAllCategories(array $category_ids): bool
{
if ($category_ids === []) {
return false;
}
if ($this->relationLoaded('categories')) {
/** @var Collection<int, ProductCategory> $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);
}
} }

View File

@ -0,0 +1,66 @@
<?php
namespace Blax\Shop\Traits;
use Blax\Shop\Enums\ProductRelationType;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
trait HasProductRelations
{
public function productRelations(): BelongsToMany
{
return $this->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);
}
}

View File

@ -2,6 +2,7 @@
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductStatus; use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
@ -157,11 +158,11 @@ class ProductManagementTest extends TestCase
$product = Product::factory()->create(); $product = Product::factory()->create();
$relatedProduct = Product::factory()->create(); $relatedProduct = Product::factory()->create();
$product->relatedProducts()->attach($relatedProduct->id, [ $product->productRelations()->attach($relatedProduct->id, [
'type' => 'related', 'type' => ProductRelationType::RELATED->value,
]); ]);
$this->assertTrue($product->relatedProducts->contains($relatedProduct)); $this->assertTrue($product->relatedProducts()->get()->contains($relatedProduct));
} }
/** @test */ /** @test */
@ -170,11 +171,11 @@ class ProductManagementTest extends TestCase
$product = Product::factory()->create(); $product = Product::factory()->create();
$upsellProduct = Product::factory()->create(); $upsellProduct = Product::factory()->create();
$product->relatedProducts()->attach($upsellProduct->id, [ $product->productRelations()->attach($upsellProduct->id, [
'type' => 'upsell', 'type' => ProductRelationType::UPSELL->value,
]); ]);
$this->assertTrue($product->upsells->contains($upsellProduct)); $this->assertTrue($product->upsellProducts()->get()->contains($upsellProduct));
} }
/** @test */ /** @test */
@ -183,11 +184,11 @@ class ProductManagementTest extends TestCase
$product = Product::factory()->create(); $product = Product::factory()->create();
$crossSellProduct = Product::factory()->create(); $crossSellProduct = Product::factory()->create();
$product->relatedProducts()->attach($crossSellProduct->id, [ $product->productRelations()->attach($crossSellProduct->id, [
'type' => 'cross-sell', 'type' => ProductRelationType::CROSS_SELL->value,
]); ]);
$this->assertTrue($product->crossSells->contains($crossSellProduct)); $this->assertTrue($product->crossSellProducts()->get()->contains($crossSellProduct));
} }
/** @test */ /** @test */

View File

@ -0,0 +1,93 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProductRelationsTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_filters_relations_by_relation_type()
{
$product = Product::factory()->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));
}
}