feat: implement polymorphic relationships for group items to support bundles and product variants

This commit is contained in:
2026-07-17 14:42:44 -03:00
parent 615eacea91
commit cb0fb2899c
10 changed files with 298 additions and 19 deletions

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Bundle\Models; namespace App\Domains\Bundle\Models;
use App\Domains\Catalog\Models\GroupItem;
use App\Domains\Shared\Contracts\Buyable; use App\Domains\Shared\Contracts\Buyable;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -10,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
#[Fillable([ #[Fillable([
'tenant_codigo', 'tenant_codigo',
@@ -48,6 +50,14 @@ class Bundle extends Model implements Buyable
return $this->hasMany(BundleItem::class, 'bundle_id'); return $this->hasMany(BundleItem::class, 'bundle_id');
} }
/**
* @return MorphMany<GroupItem, $this>
*/
public function groupItems(): MorphMany
{
return $this->morphMany(GroupItem::class, 'groupable');
}
public function getPrice(): float public function getPrice(): float
{ {
return (float) $this->precio; return (float) $this->precio;

View File

@@ -2,8 +2,11 @@
namespace App\Domains\Catalog\Controllers; namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource; use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
@@ -12,9 +15,15 @@ class CatalogController extends Controller
public function index(string $tenant): JsonResponse public function index(string $tenant): JsonResponse
{ {
$featuredGroups = FeaturedGroup::where('tenant_codigo', $tenant) $featuredGroups = FeaturedGroup::where('tenant_codigo', $tenant)
->with(['groupItems' => function ($query) { ->with([
$query->orderBy('order'); 'groupItems' => fn ($query) => $query->orderBy('order'),
}, 'groupItems.variant.product', 'groupItems.variant.attachments', 'groupItems.variant.product.attachments']) 'groupItems.groupable' => function (MorphTo $morphTo): void {
$morphTo->morphWith([
ProductVariant::class => ['product', 'attachments', 'product.attachments'],
Bundle::class => ['items.variant.product', 'items.variant.attachments', 'items.variant.product.attachments'],
]);
},
])
->orderBy('group_order') ->orderBy('group_order')
->get(); ->get();

View File

@@ -2,34 +2,53 @@
namespace App\Domains\Catalog\Controllers; namespace App\Domains\Catalog\Controllers;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\GroupItem; use App\Domains\Catalog\Models\GroupItem;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Requests\StoreFeaturedVariantRequest; use App\Domains\Catalog\Requests\StoreFeaturedVariantRequest;
use App\Domains\Catalog\Requests\UpdateFeaturedVariantRequest; use App\Domains\Catalog\Requests\UpdateFeaturedVariantRequest;
use App\Domains\Catalog\Resources\FeaturedVariantResource; use App\Domains\Catalog\Resources\FeaturedVariantResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Illuminate\Http\JsonResponse;
class FeaturedVariantController extends Controller class FeaturedVariantController extends Controller
{ {
public function index(string $tenant, FeaturedGroup $featuredGroup): AnonymousResourceCollection public function index(string $tenant, FeaturedGroup $featuredGroup): AnonymousResourceCollection
{ {
abort_if($featuredGroup->tenant_codigo !== $tenant, 404); abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
return FeaturedVariantResource::collection($featuredGroup->groupItems); return FeaturedVariantResource::collection($featuredGroup->groupItems);
} }
public function store(StoreFeaturedVariantRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedVariantResource public function store(StoreFeaturedVariantRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedVariantResource
{ {
abort_if($featuredGroup->tenant_codigo !== $tenant, 404); abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$variant = $featuredGroup->groupItems()->create($request->validated());
return new FeaturedVariantResource($variant); $groupableType = $request->mappedGroupableType();
$groupable = $groupableType::query()->findOrFail($request->integer('groupable_id'));
abort_if(
($groupable instanceof ProductVariant && $groupable->product()->where('tenant_codigo', $tenant)->doesntExist())
|| ($groupable instanceof Bundle && $groupable->tenant_codigo !== $tenant),
404
);
$groupItem = $featuredGroup->groupItems()->create([
'groupable_type' => $groupableType,
'groupable_id' => $groupable->getKey(),
'order' => $request->validated('order'),
]);
return new FeaturedVariantResource($groupItem);
} }
public function update(UpdateFeaturedVariantRequest $request, string $tenant, FeaturedGroup $featuredGroup, GroupItem $featuredVariant): FeaturedVariantResource public function update(UpdateFeaturedVariantRequest $request, string $tenant, FeaturedGroup $featuredGroup, GroupItem $featuredVariant): FeaturedVariantResource
{ {
abort_if($featuredGroup->tenant_codigo !== $tenant || $featuredVariant->featured_group_id !== $featuredGroup->id, 404); abort_if($featuredGroup->tenant_codigo !== $tenant || $featuredVariant->featured_group_id !== $featuredGroup->id, 404);
$featuredVariant->update($request->validated()); $featuredVariant->update($request->validated());
return new FeaturedVariantResource($featuredVariant); return new FeaturedVariantResource($featuredVariant);
} }
@@ -37,6 +56,7 @@ class FeaturedVariantController extends Controller
{ {
abort_if($featuredGroup->tenant_codigo !== $tenant || $featuredVariant->featured_group_id !== $featuredGroup->id, 404); abort_if($featuredGroup->tenant_codigo !== $tenant || $featuredVariant->featured_group_id !== $featuredGroup->id, 404);
$featuredVariant->delete(); $featuredVariant->delete();
return response()->json(null, 204); return response()->json(null, 204);
} }
} }

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class GroupItem extends Model class GroupItem extends Model
{ {
@@ -11,7 +12,8 @@ class GroupItem extends Model
protected $fillable = [ protected $fillable = [
'featured_group_id', 'featured_group_id',
'product_variant_id', 'groupable_type',
'groupable_id',
'order', 'order',
]; ];
@@ -20,8 +22,8 @@ class GroupItem extends Model
return $this->belongsTo(FeaturedGroup::class); return $this->belongsTo(FeaturedGroup::class);
} }
public function variant(): BelongsTo public function groupable(): MorphTo
{ {
return $this->belongsTo(ProductVariant::class, 'product_variant_id'); return $this->morphTo();
} }
} }

View File

@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
#[Fillable([ #[Fillable([
'producto_id', 'producto_id',
@@ -217,4 +218,12 @@ class ProductVariant extends Model implements Buyable
'attachment_id' 'attachment_id'
)->withTimestamps(); )->withTimestamps();
} }
/**
* @return MorphMany<GroupItem, $this>
*/
public function groupItems(): MorphMany
{
return $this->morphMany(GroupItem::class, 'groupable');
}
} }

View File

@@ -2,7 +2,10 @@
namespace App\Domains\Catalog\Requests; namespace App\Domains\Catalog\Requests;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreFeaturedVariantRequest extends FormRequest class StoreFeaturedVariantRequest extends FormRequest
{ {
@@ -14,8 +17,25 @@ class StoreFeaturedVariantRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'product_variant_id' => ['required', 'integer'], 'groupable_type' => ['required', 'string', Rule::in(['variant', 'bundle'])],
'groupable_id' => [
'required',
'integer',
Rule::exists(match ($this->input('groupable_type')) {
'bundle' => 'bundles',
default => 'productos_variantes',
}, 'id'),
],
'order' => ['nullable', 'integer'], 'order' => ['nullable', 'integer'],
]; ];
} }
public function mappedGroupableType(): string
{
return match ($this->input('groupable_type')) {
'variant' => ProductVariant::class,
'bundle' => Bundle::class,
default => throw new \InvalidArgumentException('Invalid groupable type'),
};
}
} }

View File

@@ -2,9 +2,11 @@
namespace App\Domains\Catalog\Resources; namespace App\Domains\Catalog\Resources;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\GroupItem;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use App\Domains\Catalog\Models\GroupItem;
class CatalogFeaturedGroupResource extends JsonResource class CatalogFeaturedGroupResource extends JsonResource
{ {
@@ -17,25 +19,49 @@ class CatalogFeaturedGroupResource extends JsonResource
'group_order' => $this->group_order, 'group_order' => $this->group_order,
'items' => $this->whenLoaded('groupItems', function () use ($request) { 'items' => $this->whenLoaded('groupItems', function () use ($request) {
return $this->groupItems->map(function (GroupItem $groupItem) use ($request) { return $this->groupItems->map(function (GroupItem $groupItem) use ($request) {
$variant = $groupItem->variant; $groupable = $groupItem->groupable;
$variantResource = (new ProductVariantResource($variant))->toArray($request);
if ($groupable instanceof Bundle) {
return [
'id' => $groupable->id,
'groupable_type' => 'bundle',
'groupable_id' => $groupable->id,
'nombre' => $groupable->nombre,
'descripcion' => $groupable->descripcion,
'precio' => $groupable->precio,
'cantidad_maxima' => $groupable->stock_tecnico,
'items' => $groupable->items->map(fn ($item) => [
'cantidad' => $item->cantidad,
'variant' => (new ProductVariantResource($item->variant))->toArray($request),
])->values(),
];
}
if (! $groupable instanceof ProductVariant) {
return null;
}
$variantResource = (new ProductVariantResource($groupable))->toArray($request);
$variantResource['groupable_type'] = 'variant';
$variantResource['groupable_id'] = $groupable->id;
if ($this->product_layout === 'row' || $this->product_layout === 'column_with_cart' || $this->product_layout === 'vertical_with_cart') { if ($this->product_layout === 'row' || $this->product_layout === 'column_with_cart' || $this->product_layout === 'vertical_with_cart') {
// Devuelve el Product Variant Resource completo como pidio el usuario // Devuelve el Product Variant Resource completo como pidio el usuario
// "Que todo devuelva el product variant resource" // "Que todo devuelva el product variant resource"
return $variantResource; return $variantResource;
} }
// Para column_with_image o vertical_with_image // Para column_with_image o vertical_with_image
if ($this->product_layout === 'column_with_image' || $this->product_layout === 'vertical_with_image') { if ($this->product_layout === 'column_with_image' || $this->product_layout === 'vertical_with_image') {
if (isset($variantResource['images']) && count($variantResource['images']) > 0) { if (isset($variantResource['images']) && count($variantResource['images']) > 0) {
$variantResource['images'] = [$variantResource['images'][0]]; $variantResource['images'] = [$variantResource['images'][0]];
} }
return $variantResource; return $variantResource;
} }
return $variantResource; return $variantResource;
}); })->filter()->values();
}), }),
]; ];
} }

View File

@@ -2,6 +2,8 @@
namespace App\Domains\Catalog\Resources; namespace App\Domains\Catalog\Resources;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -12,7 +14,12 @@ class FeaturedVariantResource extends JsonResource
return [ return [
'id' => $this->id, 'id' => $this->id,
'featured_group_id' => $this->featured_group_id, 'featured_group_id' => $this->featured_group_id,
'product_variant_id' => $this->product_variant_id, 'groupable_type' => match ($this->groupable_type) {
ProductVariant::class => 'variant',
Bundle::class => 'bundle',
default => 'unknown',
},
'groupable_id' => $this->groupable_id,
'order' => $this->order, 'order' => $this->order,
]; ];
} }

View File

@@ -0,0 +1,62 @@
<?php
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('group_items', function (Blueprint $table) {
$table->nullableMorphs('groupable');
});
DB::table('group_items')->update([
'groupable_type' => ProductVariant::class,
'groupable_id' => DB::raw('product_variant_id'),
]);
$productVariantForeignKey = collect(Schema::getForeignKeys('group_items'))
->first(fn (array $foreignKey): bool => $foreignKey['columns'] === ['product_variant_id']);
Schema::table('group_items', function (Blueprint $table) use ($productVariantForeignKey) {
if ($productVariantForeignKey !== null) {
$table->dropForeign($productVariantForeignKey['name']);
}
$table->dropColumn('product_variant_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (DB::table('group_items')->where('groupable_type', '!=', ProductVariant::class)->exists()) {
throw new RuntimeException('Cannot roll back groupable group items while bundle items exist.');
}
Schema::table('group_items', function (Blueprint $table) {
$table->unsignedBigInteger('product_variant_id')->nullable();
});
DB::table('group_items')->update([
'product_variant_id' => DB::raw('groupable_id'),
]);
Schema::table('group_items', function (Blueprint $table) {
$table->dropMorphs('groupable');
$table->foreign('product_variant_id')
->references('id')
->on('productos_variantes')
->cascadeOnDelete();
});
}
};

View File

@@ -0,0 +1,114 @@
<?php
namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Bundle\Models\Bundle;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class GroupItemTest extends TestCase
{
use RefreshDatabase;
private FeaturedGroup $featuredGroup;
private ProductVariant $variant;
private Bundle $bundle;
protected function setUp(): void
{
parent::setUp();
$headerAttachment = $this->createAttachment('header.png');
$footerAttachment = $this->createAttachment('footer.png');
$tenant = Tenant::query()->create([
'codigo' => 'group-test',
'nombre' => 'Group Test',
'dominio' => 'group.test',
'primary_color' => '#111111',
'secondary_color' => '#222222',
'danger_color' => '#333333',
'success_color' => '#28a745',
'header_bg_color' => '#444444',
'footer_bg_color' => '#555555',
'header_logo_id' => $headerAttachment->id,
'footer_logo_id' => $footerAttachment->id,
]);
$category = Category::query()->create([
'tenant_code' => $tenant->codigo,
'nombre' => 'Group items',
]);
$product = Product::query()->create([
'tenant_codigo' => $tenant->codigo,
'categoria_id' => $category->id,
'slug' => 'group-item-product',
'nombre' => 'Group Item Product',
'precio' => 100,
]);
$this->variant = ProductVariant::query()->create([
'producto_id' => $product->id,
'stock' => 10,
]);
$this->bundle = Bundle::query()->create([
'tenant_codigo' => $tenant->codigo,
'nombre' => 'Group Item Bundle',
'precio' => 150,
]);
$this->featuredGroup = FeaturedGroup::query()->create([
'tenant_codigo' => $tenant->codigo,
'group_name' => 'Featured',
'product_layout' => 'row',
'group_order' => 0,
]);
}
public function test_a_group_item_can_reference_a_product_variant(): void
{
$groupItem = $this->variant->groupItems()->create([
'featured_group_id' => $this->featuredGroup->id,
'order' => 1,
]);
$this->assertInstanceOf(ProductVariant::class, $groupItem->groupable);
$this->assertTrue($groupItem->groupable->is($this->variant));
$this->assertTrue($this->variant->groupItems->first()->is($groupItem));
}
public function test_a_group_item_can_reference_a_bundle(): void
{
$groupItem = $this->bundle->groupItems()->create([
'featured_group_id' => $this->featuredGroup->id,
'order' => 2,
]);
$this->assertInstanceOf(Bundle::class, $groupItem->groupable);
$this->assertTrue($groupItem->groupable->is($this->bundle));
$this->assertTrue($this->bundle->groupItems->first()->is($groupItem));
}
private function createAttachment(string $filename): Attachment
{
return Attachment::query()->create([
'key' => (string) Str::uuid(),
'path' => 'tests/'.$filename,
'filename' => $filename,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
}
}