I category methods, IRA product relations & tests
This commit is contained in:
parent
d28a63cd8a
commit
4a303c0f3f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue