diff --git a/app/Domains/Bundle/Models/Bundle.php b/app/Domains/Bundle/Models/Bundle.php index 16ab605..54d657e 100644 --- a/app/Domains/Bundle/Models/Bundle.php +++ b/app/Domains/Bundle/Models/Bundle.php @@ -2,6 +2,7 @@ namespace App\Domains\Bundle\Models; +use App\Domains\Catalog\Models\GroupItem; use App\Domains\Shared\Contracts\Buyable; use App\Domains\Tenant\Models\Tenant; 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\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; #[Fillable([ 'tenant_codigo', @@ -48,6 +50,14 @@ class Bundle extends Model implements Buyable return $this->hasMany(BundleItem::class, 'bundle_id'); } + /** + * @return MorphMany + */ + public function groupItems(): MorphMany + { + return $this->morphMany(GroupItem::class, 'groupable'); + } + public function getPrice(): float { return (float) $this->precio; diff --git a/app/Domains/Catalog/Controllers/CatalogController.php b/app/Domains/Catalog/Controllers/CatalogController.php index 9f61e6a..ec24e4e 100644 --- a/app/Domains/Catalog/Controllers/CatalogController.php +++ b/app/Domains/Catalog/Controllers/CatalogController.php @@ -2,8 +2,11 @@ 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\ProductVariant; +use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Http\JsonResponse; use Illuminate\Routing\Controller; @@ -12,9 +15,15 @@ class CatalogController extends Controller public function index(string $tenant): JsonResponse { $featuredGroups = FeaturedGroup::where('tenant_codigo', $tenant) - ->with(['groupItems' => function ($query) { - $query->orderBy('order'); - }, 'groupItems.variant.product', 'groupItems.variant.attachments', 'groupItems.variant.product.attachments']) + ->with([ + 'groupItems' => fn ($query) => $query->orderBy('order'), + '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') ->get(); diff --git a/app/Domains/Catalog/Controllers/FeaturedVariantController.php b/app/Domains/Catalog/Controllers/FeaturedVariantController.php index 9c2ee8c..a195754 100644 --- a/app/Domains/Catalog/Controllers/FeaturedVariantController.php +++ b/app/Domains/Catalog/Controllers/FeaturedVariantController.php @@ -2,34 +2,53 @@ namespace App\Domains\Catalog\Controllers; +use App\Domains\Bundle\Models\Bundle; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\GroupItem; +use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Catalog\Requests\StoreFeaturedVariantRequest; use App\Domains\Catalog\Requests\UpdateFeaturedVariantRequest; use App\Domains\Catalog\Resources\FeaturedVariantResource; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Routing\Controller; -use Illuminate\Http\JsonResponse; class FeaturedVariantController extends Controller { public function index(string $tenant, FeaturedGroup $featuredGroup): AnonymousResourceCollection { abort_if($featuredGroup->tenant_codigo !== $tenant, 404); + return FeaturedVariantResource::collection($featuredGroup->groupItems); } public function store(StoreFeaturedVariantRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedVariantResource { 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 { abort_if($featuredGroup->tenant_codigo !== $tenant || $featuredVariant->featured_group_id !== $featuredGroup->id, 404); $featuredVariant->update($request->validated()); + 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); $featuredVariant->delete(); + return response()->json(null, 204); } } diff --git a/app/Domains/Catalog/Models/GroupItem.php b/app/Domains/Catalog/Models/GroupItem.php index 987a9c7..4baaee6 100644 --- a/app/Domains/Catalog/Models/GroupItem.php +++ b/app/Domains/Catalog/Models/GroupItem.php @@ -4,6 +4,7 @@ namespace App\Domains\Catalog\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; class GroupItem extends Model { @@ -11,7 +12,8 @@ class GroupItem extends Model protected $fillable = [ 'featured_group_id', - 'product_variant_id', + 'groupable_type', + 'groupable_id', 'order', ]; @@ -20,8 +22,8 @@ class GroupItem extends Model return $this->belongsTo(FeaturedGroup::class); } - public function variant(): BelongsTo + public function groupable(): MorphTo { - return $this->belongsTo(ProductVariant::class, 'product_variant_id'); + return $this->morphTo(); } } diff --git a/app/Domains/Catalog/Models/ProductVariant.php b/app/Domains/Catalog/Models/ProductVariant.php index 79a9b14..1657d59 100644 --- a/app/Domains/Catalog/Models/ProductVariant.php +++ b/app/Domains/Catalog/Models/ProductVariant.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; #[Fillable([ 'producto_id', @@ -217,4 +218,12 @@ class ProductVariant extends Model implements Buyable 'attachment_id' )->withTimestamps(); } + + /** + * @return MorphMany + */ + public function groupItems(): MorphMany + { + return $this->morphMany(GroupItem::class, 'groupable'); + } } diff --git a/app/Domains/Catalog/Requests/StoreFeaturedVariantRequest.php b/app/Domains/Catalog/Requests/StoreFeaturedVariantRequest.php index 02f8024..b29ac67 100644 --- a/app/Domains/Catalog/Requests/StoreFeaturedVariantRequest.php +++ b/app/Domains/Catalog/Requests/StoreFeaturedVariantRequest.php @@ -2,7 +2,10 @@ namespace App\Domains\Catalog\Requests; +use App\Domains\Bundle\Models\Bundle; +use App\Domains\Catalog\Models\ProductVariant; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class StoreFeaturedVariantRequest extends FormRequest { @@ -14,8 +17,25 @@ class StoreFeaturedVariantRequest extends FormRequest public function rules(): array { 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'], ]; } + + public function mappedGroupableType(): string + { + return match ($this->input('groupable_type')) { + 'variant' => ProductVariant::class, + 'bundle' => Bundle::class, + default => throw new \InvalidArgumentException('Invalid groupable type'), + }; + } } diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php index bdd75f3..5253eba 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedGroupResource.php @@ -2,9 +2,11 @@ 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\Resources\Json\JsonResource; -use App\Domains\Catalog\Models\GroupItem; class CatalogFeaturedGroupResource extends JsonResource { @@ -17,25 +19,49 @@ class CatalogFeaturedGroupResource extends JsonResource 'group_order' => $this->group_order, 'items' => $this->whenLoaded('groupItems', function () use ($request) { return $this->groupItems->map(function (GroupItem $groupItem) use ($request) { - $variant = $groupItem->variant; - $variantResource = (new ProductVariantResource($variant))->toArray($request); - + $groupable = $groupItem->groupable; + + 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') { // Devuelve el Product Variant Resource completo como pidio el usuario // "Que todo devuelva el product variant resource" return $variantResource; } - + // Para column_with_image o vertical_with_image if ($this->product_layout === 'column_with_image' || $this->product_layout === 'vertical_with_image') { if (isset($variantResource['images']) && count($variantResource['images']) > 0) { $variantResource['images'] = [$variantResource['images'][0]]; } + return $variantResource; } - + return $variantResource; - }); + })->filter()->values(); }), ]; } diff --git a/app/Domains/Catalog/Resources/FeaturedVariantResource.php b/app/Domains/Catalog/Resources/FeaturedVariantResource.php index 24373ac..8f2b5b5 100644 --- a/app/Domains/Catalog/Resources/FeaturedVariantResource.php +++ b/app/Domains/Catalog/Resources/FeaturedVariantResource.php @@ -2,6 +2,8 @@ namespace App\Domains\Catalog\Resources; +use App\Domains\Bundle\Models\Bundle; +use App\Domains\Catalog\Models\ProductVariant; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -12,7 +14,12 @@ class FeaturedVariantResource extends JsonResource return [ 'id' => $this->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, ]; } diff --git a/database/migrations/2026_07_17_000100_make_group_items_groupable.php b/database/migrations/2026_07_17_000100_make_group_items_groupable.php new file mode 100644 index 0000000..000c1b9 --- /dev/null +++ b/database/migrations/2026_07_17_000100_make_group_items_groupable.php @@ -0,0 +1,62 @@ +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(); + }); + } +}; diff --git a/tests/Feature/Catalog/GroupItemTest.php b/tests/Feature/Catalog/GroupItemTest.php new file mode 100644 index 0000000..80b7c42 --- /dev/null +++ b/tests/Feature/Catalog/GroupItemTest.php @@ -0,0 +1,114 @@ +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', + ]); + } +}