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_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)

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\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)

View File

@ -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<static> $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<static> $query
* @param array<int, ProductCategory|string> $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<static> $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<static> $query
* @param array<int, ProductCategory|string> $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<int, ProductCategory> $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<int, ProductCategory> $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<int, ProductCategory|int|string> $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<int, string> $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<int, string> $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<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;
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 */

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));
}
}