diff --git a/app/Domains/Catalog/Controllers/ProductController.php b/app/Domains/Catalog/Controllers/ProductController.php index b1a1c9f..a1afd51 100644 --- a/app/Domains/Catalog/Controllers/ProductController.php +++ b/app/Domains/Catalog/Controllers/ProductController.php @@ -10,7 +10,6 @@ use App\Domains\Catalog\Services\ProductService; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Http\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -30,12 +29,10 @@ class ProductController extends Controller return ProductResource::make($product)->response()->setStatusCode(201); } - public function show(Request $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource + public function show(Tenant $tenant, Product $producto, ProductService $productService): ProductResource { $producto = $this->resolveScopedProduct($tenant, $producto); - $variantId = $request->query('variant_id'); - $variantId = is_numeric($variantId) ? (int) $variantId : null; - $producto = $productService->getProductDetail($tenant, $producto, $variantId); + $producto = $productService->getProductDetail($tenant, $producto); return ProductResource::make($producto); } diff --git a/app/Domains/Catalog/Resources/ProductResource.php b/app/Domains/Catalog/Resources/ProductResource.php index 2305942..3ca61af 100644 --- a/app/Domains/Catalog/Resources/ProductResource.php +++ b/app/Domains/Catalog/Resources/ProductResource.php @@ -2,11 +2,12 @@ namespace App\Domains\Catalog\Resources; +use App\Domains\Catalog\Models\Product; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Domains\Catalog\Models\Product + * @mixin Product */ class ProductResource extends JsonResource { @@ -23,15 +24,26 @@ class ProductResource extends JsonResource 'nombre' => $this->nombre, 'descripcion' => $this->descripcion, 'precio' => $this->precio, - 'category' => $this->whenLoaded('category', fn () => $this->category?->nombre), + 'category' => $this->whenLoaded('category', fn () => $this->category?->nombre), 'brand' => $this->whenLoaded('brand', fn () => $this->brand?->nombre), - 'images' => $this->whenLoaded('attachments', fn () => - $this->attachments - ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) - ->values() + 'images' => $this->whenLoaded('attachments', fn () => $this->attachments + ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) + ->values() ), 'attributes' => AttributeResource::collection($this->whenLoaded('attributes')), - 'variants' => ProductVariantResource::collection($this->whenLoaded('variants')), + 'variants_map' => $this->whenLoaded('variants', fn () => $this->variants + ->map(fn ($variant) => [ + 'variant_id' => $variant->id, + 'stock' => $variant->stock, + 'attributes' => $variant->definitions + ->mapWithKeys(fn ($definition) => [ + $definition->productAttribute?->attribute?->codigo => $definition->value, + ]) + ->filter(fn ($value, $key) => $key !== null) + ->toArray(), + ]) + ->values() + ), 'variant' => $this->when( $this->getSelectedVariant() !== null, fn () => ProductVariantResource::make($this->getSelectedVariant()) diff --git a/app/Domains/Catalog/Services/ProductService.php b/app/Domains/Catalog/Services/ProductService.php index 3116eb5..27729f3 100644 --- a/app/Domains/Catalog/Services/ProductService.php +++ b/app/Domains/Catalog/Services/ProductService.php @@ -7,16 +7,18 @@ use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Tenant\Models\Tenant; +use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; class ProductService { public function __construct(protected AttachmentService $attachmentService) {} + /** * Create a product. * - * @param array $data + * @param array $data */ public function create(Tenant $tenant, array $data): Product { @@ -44,7 +46,7 @@ class ProductService /** * Update a product. * - * @param array $data + * @param array $data */ public function update(Product $product, array $data): Product { @@ -98,7 +100,7 @@ class ProductService /** * Create a product variant. * - * @param array $data + * @param array $data */ public function createVariant(Product $product, array $data): ProductVariant { @@ -119,7 +121,7 @@ class ProductService /** * Update a product variant. * - * @param array $data + * @param array $data */ public function updateVariant(ProductVariant $variant, array $data): ProductVariant { @@ -156,7 +158,7 @@ class ProductService /** * Create an attribute. * - * @param array $data + * @param array $data */ public static function createAttribute(Tenant $tenant, array $data): Attribute { @@ -168,7 +170,7 @@ class ProductService /** * Update an attribute. * - * @param array $data + * @param array $data */ public static function updateAttribute(Attribute $attribute, array $data): Attribute { @@ -183,7 +185,7 @@ class ProductService * When called on update, the existing attachments are detached first so the * final set always matches exactly what was sent in the request. * - * @param array $images + * @param array $images */ protected function syncVariantImages(ProductVariant $variant, array $images): void { @@ -204,7 +206,7 @@ class ProductService * * Same logic as syncVariantImages but for products without variants. * - * @param array $images + * @param array $images */ protected function syncProductImages(Product $product, array $images): void { @@ -248,7 +250,7 @@ class ProductService /** * Get products for a tenant with resolved first image (with fallback to first variant's first image). */ - public function getProductos(Tenant $tenant): \Illuminate\Contracts\Pagination\LengthAwarePaginator + public function getProductos(Tenant $tenant): LengthAwarePaginator { $products = Product::query() ->where('tenant_codigo', $tenant->codigo) @@ -279,39 +281,16 @@ class ProductService return $products; } - public function getProductDetail(Tenant $tenant, Product $product, ?int $variantId = null): Product + public function getProductDetail(Tenant $tenant, Product $product): Product { $product->load([ 'attachments', 'brand', 'category', - 'attributes.options', + 'variants' => fn ($query) => $query->orderBy('id'), 'variants.definitions.productAttribute.attribute.options', ]); - $selectedVariantQuery = $product->variants() - ->with([ - 'attachments', - 'definitions.productAttribute.attribute.options', - ]); - - $selectedVariant = $variantId !== null - ? $selectedVariantQuery->whereKey($variantId)->first() - : null; - - if ($selectedVariant === null) { - $selectedVariant = $product->variants() - ->with([ - 'attachments', - 'definitions.productAttribute.attribute.options', - ]) - ->first(); - } - - if ($selectedVariant) { - $product->setSelectedVariant($selectedVariant); - } - return $product; } } diff --git a/database/migrations/2026_06_29_102114_refactor_product_attributes_table.php b/database/migrations/2026_06_29_102114_refactor_product_attributes_table.php index f31a6d2..3c449b8 100644 --- a/database/migrations/2026_06_29_102114_refactor_product_attributes_table.php +++ b/database/migrations/2026_06_29_102114_refactor_product_attributes_table.php @@ -38,6 +38,10 @@ return new class extends Migration }); // 4. Point variant values to the product-attribute pivot instead of the base attribute + Schema::table('productos_variantes_values', function (Blueprint $table) { + $table->index('producto_variante_id', 'prod_var_values_variant_id_index'); + }); + Schema::table('productos_variantes_values', function (Blueprint $table) { $table->dropForeign('productos_variantes_definiciones_attribute_id_foreign'); $table->dropUnique('prod_var_def_variant_attr_unique'); @@ -85,6 +89,10 @@ return new class extends Migration $table->unique(['producto_variante_id', 'attribute_id'], 'prod_var_def_variant_attr_unique'); }); + Schema::table('productos_variantes_values', function (Blueprint $table) { + $table->dropIndex('prod_var_values_variant_id_index'); + }); + Schema::dropIfExists('products_attributes'); Schema::rename('productos_variantes_values', 'productos_variantes_definiciones'); Schema::rename('attribute', 'productos_attributes'); diff --git a/tests/Feature/Catalog/ProductControllerTest.php b/tests/Feature/Catalog/ProductControllerTest.php index 8098a62..5fb8ee9 100644 --- a/tests/Feature/Catalog/ProductControllerTest.php +++ b/tests/Feature/Catalog/ProductControllerTest.php @@ -2,6 +2,8 @@ namespace Tests\Feature\Catalog; +use App\Domains\Attachable\Enums\AttachmentType; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\ProductVariant; @@ -15,8 +17,11 @@ class ProductControllerTest extends TestCase use RefreshDatabase; private Tenant $tenant; + private Attribute $sizeAttr; + private Attribute $colorAttr; + private Attribute $extraAttr; protected function setUp(): void @@ -358,7 +363,7 @@ class ProductControllerTest extends TestCase 'options' => [ ['value' => 'P', 'label' => 'Piqueno'], ['value' => 'M', 'label' => 'Medio'], - ] + ], ]); // 2. Create product and associate attribute @@ -384,12 +389,12 @@ class ProductControllerTest extends TestCase [ 'attribute_id' => $selectAttr->id, 'value' => 'G', // Invalid value - ] - ] + ], + ], ]); } - public function test_it_returns_product_detail_with_variants_and_selected_variant_resource(): void + public function test_it_returns_product_detail_with_variant_mapping(): void { // 1. Create a product $product = Product::create([ @@ -402,70 +407,77 @@ class ProductControllerTest extends TestCase // Associate attributes to product $product->attributes()->sync([$this->sizeAttr->id, $this->colorAttr->id]); + $sizeProductAttribute = $product->productAttributes() + ->where('attribute_id', $this->sizeAttr->id) + ->firstOrFail(); + $colorProductAttribute = $product->productAttributes() + ->where('attribute_id', $this->colorAttr->id) + ->firstOrFail(); - // 2. Create variant + // 2. Create variants $variant = $product->createVariant([ - 'slug' => 'test-product-show-s-azul', - 'nombre' => 'Test Product Show S Azul', 'stock' => 10, - 'precio' => 105.00, 'definitions' => [ [ - 'attribute_id' => $this->sizeAttr->id, + 'products_attribute_id' => $sizeProductAttribute->id, 'value' => 'S', ], [ - 'attribute_id' => $this->colorAttr->id, + 'products_attribute_id' => $colorProductAttribute->id, 'value' => 'Azul', ], ], ]); - // Attach an image to the variant - $attachment = \App\Domains\Attachable\Models\Attachment::create([ - 'key' => (string) Str::uuid(), - 'path' => 'attachments/variant-img.png', - 'filename' => 'variant-img.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, - 'mime_type' => 'image/png', + $secondVariant = $product->createVariant([ + 'stock' => 4, + 'definitions' => [ + [ + 'products_attribute_id' => $sizeProductAttribute->id, + 'value' => '38', + ], + [ + 'products_attribute_id' => $colorProductAttribute->id, + 'value' => 'Rojo', + ], + ], ]); - $variant->attachments()->attach($attachment->id); // 3. Request product show detail $response = $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}"); $response->assertOk(); - // 4. Assert selected variant resource structure and data + // 4. Assert variant mapping structure and data $response->assertJsonStructure([ 'data' => [ 'id', 'nombre', - 'variant' => [ - 'id', - 'producto_id', - 'stock', - 'images', - 'definitions' => [ - '*' => [ - 'id', - 'producto_variante_id', - 'products_attribute_id', - 'attribute_id', - 'value', - 'attribute', - 'metadata', + 'variants_map' => [ + '*' => [ + 'variant_id', + 'stock', + 'attributes' => [ + 'talle', + 'color', ], ], ], ], ]); - $response->assertJsonPath('data.variant.id', $variant->id); - $response->assertJsonPath('data.variant.producto_id', $product->id); - $response->assertJsonPath('data.variant.stock', 10); - $response->assertJsonCount(1, 'data.variant.images'); - $response->assertJsonCount(2, 'data.variant.definitions'); + $response->assertJsonMissingPath('data.attributes'); + $response->assertJsonMissingPath('data.variants'); + $response->assertJsonMissingPath('data.variant'); + $response->assertJsonPath('data.variants_map.0.variant_id', $variant->id); + $response->assertJsonPath('data.variants_map.0.stock', 10); + $response->assertJsonPath('data.variants_map.0.attributes.talle', 'S'); + $response->assertJsonPath('data.variants_map.0.attributes.color', 'Azul'); + $response->assertJsonPath('data.variants_map.1.variant_id', $secondVariant->id); + $response->assertJsonPath('data.variants_map.1.stock', 4); + $response->assertJsonPath('data.variants_map.1.attributes.talle', '38'); + $response->assertJsonPath('data.variants_map.1.attributes.color', 'Rojo'); + $response->assertJsonCount(2, 'data.variants_map'); } protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant @@ -473,18 +485,18 @@ class ProductControllerTest extends TestCase $hdrKey = (string) Str::uuid(); $ftrKey = (string) Str::uuid(); - $headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + $headerAttachment = Attachment::create([ 'key' => $hdrKey, - 'path' => 'tenants/' . $hdrKey . '.png', + 'path' => 'tenants/'.$hdrKey.'.png', 'filename' => 'logo_header.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); - $footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + $footerAttachment = Attachment::create([ 'key' => $ftrKey, - 'path' => 'tenants/' . $ftrKey . '.png', + 'path' => 'tenants/'.$ftrKey.'.png', 'filename' => 'logo_footer.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]);