From cb15905c464f0e96bb85b0f11803e7ada4ed998b Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 30 Jun 2026 16:35:56 -0300 Subject: [PATCH] feat: refactor product attributes handling to use product_attribute table and update related models and requests --- ShopIt_API_Postman_Collection.json | 14 +++++- app/Domains/Cart/Services/CartService.php | 2 +- .../Controllers/ProductVariantController.php | 4 +- app/Domains/Catalog/Models/Attribute.php | 22 ++++++++-- app/Domains/Catalog/Models/Product.php | 28 +++++++++--- .../Catalog/Models/ProductAttribute.php | 44 +++++++++++++++++++ .../Models/ProductVariantDefinition.php | 8 ++-- .../Requests/StoreProductVariantRequest.php | 18 ++------ .../Requests/UpdateProductVariantRequest.php | 18 ++------ .../Catalog/Resources/ProductResource.php | 2 +- .../ProductVariantDefinitionResource.php | 15 +++++-- .../Catalog/Services/ProductService.php | 8 ++-- .../Controllers/PurchaseController.php | 6 +-- ...2114_refactor_product_attributes_table.php | 42 ++++++++++++++++++ .../ProductCatalogFromImagesSeeder.php | 21 ++++++++- 15 files changed, 189 insertions(+), 63 deletions(-) create mode 100644 app/Domains/Catalog/Models/ProductAttribute.php diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index e42142b..abb0928 100644 --- a/ShopIt_API_Postman_Collection.json +++ b/ShopIt_API_Postman_Collection.json @@ -925,7 +925,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"producto_id\": {{producto_id}},\n \"slug\": \"remera-basica-blanca-m\",\n \"nombre\": \"Remera Basica Blanca - Talle M\",\n \"stock\": 8,\n \"descripcion\": \"Variante de prueba\",\n \"precio\": 26999,\n \"definitions\": [\n {\n \"attribute_id\": {{color_attribute_id}},\n \"value\": \"Blanco\"\n },\n {\n \"attribute_id\": {{talle_attribute_id}},\n \"value\": \"M\"\n }\n ],\n \"images\": []\n}" + "raw": "{\n \"producto_id\": {{producto_id}},\n \"slug\": \"remera-basica-blanca-m\",\n \"nombre\": \"Remera Basica Blanca - Talle M\",\n \"stock\": 8,\n \"descripcion\": \"Variante de prueba\",\n \"precio\": 26999,\n \"definitions\": [\n {\n \"products_attribute_id\": {{color_product_attribute_id}},\n \"value\": \"Blanco\"\n },\n {\n \"products_attribute_id\": {{talle_product_attribute_id}},\n \"value\": \"M\"\n }\n ],\n \"images\": []\n}" }, "url": { "raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/product-variants", @@ -987,7 +987,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"slug\": \"remera-basica-blanca-m\",\n \"nombre\": \"Remera Basica Blanca - Talle M Updated\",\n \"stock\": 12,\n \"descripcion\": \"Variante actualizada\",\n \"precio\": 27999,\n \"definitions\": [\n {\n \"attribute_id\": {{color_attribute_id}},\n \"value\": \"Blanco\"\n },\n {\n \"attribute_id\": {{talle_attribute_id}},\n \"value\": \"M\"\n }\n ],\n \"images\": []\n}" + "raw": "{\n \"slug\": \"remera-basica-blanca-m\",\n \"nombre\": \"Remera Basica Blanca - Talle M Updated\",\n \"stock\": 12,\n \"descripcion\": \"Variante actualizada\",\n \"precio\": 27999,\n \"definitions\": [\n {\n \"products_attribute_id\": {{color_product_attribute_id}},\n \"value\": \"Blanco\"\n },\n {\n \"products_attribute_id\": {{talle_product_attribute_id}},\n \"value\": \"M\"\n }\n ],\n \"images\": []\n}" }, "url": { "raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/product-variants/{{product_variant_id}}", @@ -1499,6 +1499,16 @@ "value": "2", "type": "string" }, + { + "key": "color_product_attribute_id", + "value": "1", + "type": "string" + }, + { + "key": "talle_product_attribute_id", + "value": "2", + "type": "string" + }, { "key": "producto_id", "value": "1", diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index 648d9e9..4e05395 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -94,7 +94,7 @@ class CartService { return $cart->fresh()->load([ 'items.variant.product', - 'items.variant.definitions.attribute', + 'items.variant.definitions.productAttribute.attribute', ]); } diff --git a/app/Domains/Catalog/Controllers/ProductVariantController.php b/app/Domains/Catalog/Controllers/ProductVariantController.php index a27dbb2..eeb4b33 100644 --- a/app/Domains/Catalog/Controllers/ProductVariantController.php +++ b/app/Domains/Catalog/Controllers/ProductVariantController.php @@ -21,7 +21,7 @@ class ProductVariantController extends Controller $query = ProductVariant::query() ->where('producto_id', $producto->id) - ->with(['product', 'definitions.attribute.options', 'attachments']) + ->with(['product', 'definitions.productAttribute.attribute.options', 'attachments']) ->latest(); return ProductVariantResource::collection($query->paginateFromRequest())->response(); @@ -41,7 +41,7 @@ class ProductVariantController extends Controller $producto = $this->resolveScopedProduct($tenant, $producto); $productVariant = $this->resolveScopedVariant($producto, $productVariant); - return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute.options', 'attachments'])); + return ProductVariantResource::make($productVariant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments'])); } public function update(UpdateProductVariantRequest $request, Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): ProductVariantResource diff --git a/app/Domains/Catalog/Models/Attribute.php b/app/Domains/Catalog/Models/Attribute.php index ee36dfc..f516556 100644 --- a/app/Domains/Catalog/Models/Attribute.php +++ b/app/Domains/Catalog/Models/Attribute.php @@ -9,6 +9,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\HasManyThrough; #[Fillable([ 'tenant_codigo', @@ -53,10 +54,25 @@ class Attribute extends Model } /** - * @return HasMany + * @return HasMany */ - public function variantDefinitions(): HasMany + public function productAttributes(): HasMany { - return $this->hasMany(ProductVariantDefinition::class, 'attribute_id'); + return $this->hasMany(ProductAttribute::class, 'attribute_id'); + } + + /** + * @return HasManyThrough + */ + public function variantDefinitions(): HasManyThrough + { + return $this->hasManyThrough( + ProductVariantDefinition::class, + ProductAttribute::class, + 'attribute_id', + 'products_attribute_id', + 'id', + 'id' + ); } } diff --git a/app/Domains/Catalog/Models/Product.php b/app/Domains/Catalog/Models/Product.php index 34dd137..eb2895c 100644 --- a/app/Domains/Catalog/Models/Product.php +++ b/app/Domains/Catalog/Models/Product.php @@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Models; use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Models\Brand; use App\Domains\Catalog\Models\Category; +use App\Domains\Catalog\Models\ProductAttribute; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -83,6 +84,14 @@ class Product extends Model )->withTimestamps(); } + /** + * @return HasMany + */ + public function productAttributes(): HasMany + { + return $this->hasMany(ProductAttribute::class, 'product_id'); + } + /** * @return BelongsToMany */ @@ -170,20 +179,25 @@ class Product extends Model protected function validateVariantDefinitions(array $definitions): void { foreach ($definitions as $definition) { - $attributeId = $definition['attribute_id'] ?? null; - if (!$attributeId) { + $productAttributeId = $definition['products_attribute_id'] ?? null; + if (! $productAttributeId) { continue; } - $attribute = Attribute::find($attributeId); - if (!$attribute) { - throw new \InvalidArgumentException("Attribute with ID {$attributeId} not found."); + $productAttribute = $this->productAttributes() + ->with('attribute.options') + ->find($productAttributeId); + + $attribute = $productAttribute?->attribute; + + if (! $productAttribute || ! $attribute) { + throw new \InvalidArgumentException("Product attribute with ID {$productAttributeId} not found for product {$this->id}."); } if ($attribute->type === \App\Domains\Shared\Enums\FieldType::Select) { $allowedValues = $attribute->options()->pluck('value')->toArray(); $val = $definition['value'] ?? null; - if ($val !== null && !in_array($val, $allowedValues, true)) { + if ($val !== null && ! in_array($val, $allowedValues, true)) { throw new \InvalidArgumentException("The value '{$val}' is not a valid option for the select attribute '{$attribute->nombre}'."); } } elseif ($attribute->type === \App\Domains\Shared\Enums\FieldType::Multiselect) { @@ -202,7 +216,7 @@ class Product extends Model } } foreach ($values as $v) { - if (!in_array($v, $allowedValues, true)) { + if (! in_array($v, $allowedValues, true)) { throw new \InvalidArgumentException("The value '{$v}' is not a valid option for the multiselect attribute '{$attribute->nombre}'."); } } diff --git a/app/Domains/Catalog/Models/ProductAttribute.php b/app/Domains/Catalog/Models/ProductAttribute.php new file mode 100644 index 0000000..235df45 --- /dev/null +++ b/app/Domains/Catalog/Models/ProductAttribute.php @@ -0,0 +1,44 @@ + + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class, 'product_id'); + } + + /** + * @return BelongsTo + */ + public function attribute(): BelongsTo + { + return $this->belongsTo(Attribute::class, 'attribute_id'); + } + + /** + * @return HasMany + */ + public function variantDefinitions(): HasMany + { + return $this->hasMany(ProductVariantDefinition::class, 'products_attribute_id'); + } +} diff --git a/app/Domains/Catalog/Models/ProductVariantDefinition.php b/app/Domains/Catalog/Models/ProductVariantDefinition.php index 1513dbc..212c781 100644 --- a/app/Domains/Catalog/Models/ProductVariantDefinition.php +++ b/app/Domains/Catalog/Models/ProductVariantDefinition.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; #[Fillable([ 'producto_variante_id', - 'attribute_id', + 'products_attribute_id', 'value', ])] class ProductVariantDefinition extends Model @@ -27,10 +27,10 @@ class ProductVariantDefinition extends Model } /** - * @return BelongsTo + * @return BelongsTo */ - public function attribute(): BelongsTo + public function productAttribute(): BelongsTo { - return $this->belongsTo(Attribute::class, 'attribute_id'); + return $this->belongsTo(ProductAttribute::class, 'products_attribute_id'); } } diff --git a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php b/app/Domains/Catalog/Requests/StoreProductVariantRequest.php index 72fead8..c2882c6 100644 --- a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductVariantRequest.php @@ -21,25 +21,13 @@ class StoreProductVariantRequest extends FormRequest return [ 'stock' => ['sometimes', 'integer', 'min:0'], 'definitions' => ['sometimes', 'array'], - 'definitions.*.attribute_id' => [ + 'definitions.*.products_attribute_id' => [ 'required', 'integer', 'distinct', - Rule::exists('attribute', 'id')->where( - fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) + Rule::exists('products_attributes', 'id')->where( + fn ($query) => $query->where('product_id', $this->route('producto')?->id) ), - function ($attribute, $value, $fail) { - $productId = $this->route('producto')?->id; - if ($productId) { - $exists = \Illuminate\Support\Facades\DB::table('products_attributes') - ->where('product_id', $productId) - ->where('attribute_id', $value) - ->exists(); - if (!$exists) { - $fail('The selected attribute is not associated with the product.'); - } - } - }, ], 'definitions.*.value' => ['nullable', 'string'], 'images' => ['sometimes', 'nullable', 'array'], diff --git a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php b/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php index 190fa51..78bfb2f 100644 --- a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php +++ b/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php @@ -21,25 +21,13 @@ class UpdateProductVariantRequest extends FormRequest return [ 'stock' => ['sometimes', 'integer', 'min:0'], 'definitions' => ['sometimes', 'array'], - 'definitions.*.attribute_id' => [ + 'definitions.*.products_attribute_id' => [ 'required', 'integer', 'distinct', - Rule::exists('attribute', 'id')->where( - fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) + Rule::exists('products_attributes', 'id')->where( + fn ($query) => $query->where('product_id', $this->route('producto')?->id) ), - function ($attribute, $value, $fail) { - $productId = $this->route('producto')?->id; - if ($productId) { - $exists = \Illuminate\Support\Facades\DB::table('products_attributes') - ->where('product_id', $productId) - ->where('attribute_id', $value) - ->exists(); - if (!$exists) { - $fail('The selected attribute is not associated with the product.'); - } - } - }, ], 'definitions.*.value' => ['nullable', 'string'], 'images' => ['sometimes', 'nullable', 'array'], diff --git a/app/Domains/Catalog/Resources/ProductResource.php b/app/Domains/Catalog/Resources/ProductResource.php index 7cd36fc..cba0210 100644 --- a/app/Domains/Catalog/Resources/ProductResource.php +++ b/app/Domains/Catalog/Resources/ProductResource.php @@ -43,7 +43,7 @@ class ProductResource extends JsonResource ? $selectedVariant->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values() : $this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values(), 'attributes' => $selectedVariant->definitions->mapWithKeys(function ($definition) { - return [$definition->attribute?->codigo => $definition->value]; + return [$definition->productAttribute?->attribute?->codigo => $definition->value]; })->toArray(), ]; } diff --git a/app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php b/app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php index 3aff036..085ab4b 100644 --- a/app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php +++ b/app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php @@ -18,9 +18,10 @@ class ProductVariantDefinitionResource extends JsonResource return [ 'id' => $this->id, 'producto_variante_id' => $this->producto_variante_id, - 'attribute_id' => $this->attribute_id, + 'products_attribute_id' => $this->products_attribute_id, + 'attribute_id' => $this->whenLoaded('productAttribute', fn () => $this->productAttribute?->attribute_id), 'value' => $this->value, - 'attribute' => $this->whenLoaded('attribute', fn () => $this->attribute?->nombre), + 'attribute' => $this->whenLoaded('productAttribute', fn () => $this->productAttribute?->attribute?->nombre), 'metadata' => $this->resolveMetadata(), ]; } @@ -30,11 +31,17 @@ class ProductVariantDefinitionResource extends JsonResource */ protected function resolveMetadata(): ?array { - if (! $this->relationLoaded('attribute') || ! $this->attribute?->relationLoaded('options')) { + if (! $this->relationLoaded('productAttribute')) { return null; } - $option = $this->attribute->options->firstWhere('value', $this->value); + $attribute = $this->productAttribute?->attribute; + + if (! $attribute?->relationLoaded('options')) { + return null; + } + + $option = $attribute->options->firstWhere('value', $this->value); if ($option === null || $option->metadata === null) { return null; diff --git a/app/Domains/Catalog/Services/ProductService.php b/app/Domains/Catalog/Services/ProductService.php index 65bf7ea..48a70a6 100644 --- a/app/Domains/Catalog/Services/ProductService.php +++ b/app/Domains/Catalog/Services/ProductService.php @@ -112,7 +112,7 @@ class ProductService $this->syncVariantImages($variant, $images); } - return $variant->load(['product', 'definitions.attribute.options', 'attachments']); + return $variant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments']); }); } @@ -136,7 +136,7 @@ class ProductService $this->syncVariantImages($updatedVariant, $images); } - return $updatedVariant->load(['product', 'definitions.attribute.options', 'attachments']); + return $updatedVariant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments']); }); } @@ -291,7 +291,7 @@ class ProductService $selectedVariantQuery = $product->variants() ->with([ 'attachments', - 'definitions.attribute.options', + 'definitions.productAttribute.attribute.options', ]); $selectedVariant = $variantId !== null @@ -302,7 +302,7 @@ class ProductService $selectedVariant = $product->variants() ->with([ 'attachments', - 'definitions.attribute.options', + 'definitions.productAttribute.attribute.options', ]) ->first(); } diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 53b38f3..6a8c422 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -20,7 +20,7 @@ class PurchaseController extends Controller { return PurchaseResource::collection( Purchase::query() - ->with(['items.variant.product', 'items.variant.definitions']) + ->with(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']) ->where('tenant_codigo', $tenant->codigo) ->where('user_id', $request->user()->id) ->latest() @@ -48,7 +48,7 @@ class PurchaseController extends Controller $purchase->items()->createMany($purchaseItems); - return $purchase->load(['items.variant.product', 'items.variant.definitions']); + return $purchase->load(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']); }); return PurchaseResource::make($purchase)->response()->setStatusCode(201); @@ -59,7 +59,7 @@ class PurchaseController extends Controller $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); return PurchaseResource::make( - $compra->loadMissing(['items.variant.product', 'items.variant.definitions']) + $compra->loadMissing(['items.variant.product', 'items.variant.definitions.productAttribute.attribute']) ); } 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 07df76d..f31a6d2 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 @@ -36,6 +36,29 @@ return new class extends Migration $table->unique(['product_id', 'attribute_id']); }); + + // 4. Point variant values to the product-attribute pivot instead of the base attribute + Schema::table('productos_variantes_values', function (Blueprint $table) { + $table->dropForeign('productos_variantes_definiciones_attribute_id_foreign'); + $table->dropUnique('prod_var_def_variant_attr_unique'); + }); + + Schema::table('productos_variantes_values', function (Blueprint $table) { + $table->renameColumn('attribute_id', 'products_attribute_id'); + }); + + Schema::table('productos_variantes_values', function (Blueprint $table) { + $table->foreign('products_attribute_id') + ->references('id') + ->on('products_attributes') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + + $table->unique( + ['producto_variante_id', 'products_attribute_id'], + 'prod_var_values_variant_product_attr_unique' + ); + }); } /** @@ -43,6 +66,25 @@ return new class extends Migration */ public function down(): void { + Schema::table('productos_variantes_values', function (Blueprint $table) { + $table->dropForeign(['products_attribute_id']); + $table->dropUnique('prod_var_values_variant_product_attr_unique'); + }); + + Schema::table('productos_variantes_values', function (Blueprint $table) { + $table->renameColumn('products_attribute_id', 'attribute_id'); + }); + + Schema::table('productos_variantes_values', function (Blueprint $table) { + $table->foreign('attribute_id') + ->references('id') + ->on('attribute') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + + $table->unique(['producto_variante_id', 'attribute_id'], 'prod_var_def_variant_attr_unique'); + }); + Schema::dropIfExists('products_attributes'); Schema::rename('productos_variantes_values', 'productos_variantes_definiciones'); Schema::rename('attribute', 'productos_attributes'); diff --git a/database/seeders/ProductCatalogFromImagesSeeder.php b/database/seeders/ProductCatalogFromImagesSeeder.php index ae4a1f2..6a11593 100644 --- a/database/seeders/ProductCatalogFromImagesSeeder.php +++ b/database/seeders/ProductCatalogFromImagesSeeder.php @@ -10,6 +10,7 @@ use App\Domains\Catalog\Services\ProductService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Seeder; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Str; use RuntimeException; @@ -205,6 +206,10 @@ class ProductCatalogFromImagesSeeder extends Seeder 'attribute_ids' => array_values($attributeIds->all()), ]); + $productAttributeIds = DB::table('products_attributes') + ->where('product_id', $product->id) + ->pluck('id', 'attribute_id'); + $sizes = $metadata['type'] === 'zapatillas' ? self::SHOE_SIZES : self::CLOTHING_SIZES; @@ -219,8 +224,14 @@ class ProductCatalogFromImagesSeeder extends Seeder $definitions = []; if (isset($attributeIds['color']) && $variantGroup['metadata']['color'] !== null) { + $colorProductAttributeId = $productAttributeIds[$attributeIds['color']] ?? null; + + if ($colorProductAttributeId === null) { + throw new RuntimeException("Missing product attribute for color on product '{$metadata['product_slug']}'."); + } + $definitions[] = [ - 'attribute_id' => $attributeIds['color'], + 'products_attribute_id' => $colorProductAttributeId, 'value' => $variantGroup['metadata']['color'], ]; } @@ -229,8 +240,14 @@ class ProductCatalogFromImagesSeeder extends Seeder ? 'talle_numerico' : 'talle'; + $sizeProductAttributeId = $productAttributeIds[$attributeIds[$sizeAttributeCode]] ?? null; + + if ($sizeProductAttributeId === null) { + throw new RuntimeException("Missing product attribute for size on product '{$metadata['product_slug']}'."); + } + $definitions[] = [ - 'attribute_id' => $attributeIds[$sizeAttributeCode], + 'products_attribute_id' => $sizeProductAttributeId, 'value' => $size, ];