feat: refactor product attributes handling to use product_attribute table and update related models and requests

This commit is contained in:
2026-06-30 16:35:56 -03:00
parent 191a40fcb9
commit cb15905c46
15 changed files with 189 additions and 63 deletions

View File

@@ -925,7 +925,7 @@
], ],
"body": { "body": {
"mode": "raw", "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": { "url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/product-variants", "raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/product-variants",
@@ -987,7 +987,7 @@
], ],
"body": { "body": {
"mode": "raw", "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": { "url": {
"raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/product-variants/{{product_variant_id}}", "raw": "{{base_url}}/api/tenants/{{tenant_codigo}}/product-variants/{{product_variant_id}}",
@@ -1499,6 +1499,16 @@
"value": "2", "value": "2",
"type": "string" "type": "string"
}, },
{
"key": "color_product_attribute_id",
"value": "1",
"type": "string"
},
{
"key": "talle_product_attribute_id",
"value": "2",
"type": "string"
},
{ {
"key": "producto_id", "key": "producto_id",
"value": "1", "value": "1",

View File

@@ -94,7 +94,7 @@ class CartService
{ {
return $cart->fresh()->load([ return $cart->fresh()->load([
'items.variant.product', 'items.variant.product',
'items.variant.definitions.attribute', 'items.variant.definitions.productAttribute.attribute',
]); ]);
} }

View File

@@ -21,7 +21,7 @@ class ProductVariantController extends Controller
$query = ProductVariant::query() $query = ProductVariant::query()
->where('producto_id', $producto->id) ->where('producto_id', $producto->id)
->with(['product', 'definitions.attribute.options', 'attachments']) ->with(['product', 'definitions.productAttribute.attribute.options', 'attachments'])
->latest(); ->latest();
return ProductVariantResource::collection($query->paginateFromRequest())->response(); return ProductVariantResource::collection($query->paginateFromRequest())->response();
@@ -41,7 +41,7 @@ class ProductVariantController extends Controller
$producto = $this->resolveScopedProduct($tenant, $producto); $producto = $this->resolveScopedProduct($tenant, $producto);
$productVariant = $this->resolveScopedVariant($producto, $productVariant); $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 public function update(UpdateProductVariantRequest $request, Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): ProductVariantResource

View File

@@ -9,6 +9,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\HasManyThrough;
#[Fillable([ #[Fillable([
'tenant_codigo', 'tenant_codigo',
@@ -53,10 +54,25 @@ class Attribute extends Model
} }
/** /**
* @return HasMany<ProductVariantDefinition, $this> * @return HasMany<ProductAttribute, $this>
*/ */
public function variantDefinitions(): HasMany public function productAttributes(): HasMany
{ {
return $this->hasMany(ProductVariantDefinition::class, 'attribute_id'); return $this->hasMany(ProductAttribute::class, 'attribute_id');
}
/**
* @return HasManyThrough<ProductVariantDefinition, ProductAttribute, $this>
*/
public function variantDefinitions(): HasManyThrough
{
return $this->hasManyThrough(
ProductVariantDefinition::class,
ProductAttribute::class,
'attribute_id',
'products_attribute_id',
'id',
'id'
);
} }
} }

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\Brand; use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -83,6 +84,14 @@ class Product extends Model
)->withTimestamps(); )->withTimestamps();
} }
/**
* @return HasMany<ProductAttribute, $this>
*/
public function productAttributes(): HasMany
{
return $this->hasMany(ProductAttribute::class, 'product_id');
}
/** /**
* @return BelongsToMany<Attachment, $this> * @return BelongsToMany<Attachment, $this>
*/ */
@@ -170,20 +179,25 @@ class Product extends Model
protected function validateVariantDefinitions(array $definitions): void protected function validateVariantDefinitions(array $definitions): void
{ {
foreach ($definitions as $definition) { foreach ($definitions as $definition) {
$attributeId = $definition['attribute_id'] ?? null; $productAttributeId = $definition['products_attribute_id'] ?? null;
if (!$attributeId) { if (! $productAttributeId) {
continue; continue;
} }
$attribute = Attribute::find($attributeId); $productAttribute = $this->productAttributes()
if (!$attribute) { ->with('attribute.options')
throw new \InvalidArgumentException("Attribute with ID {$attributeId} not found."); ->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) { if ($attribute->type === \App\Domains\Shared\Enums\FieldType::Select) {
$allowedValues = $attribute->options()->pluck('value')->toArray(); $allowedValues = $attribute->options()->pluck('value')->toArray();
$val = $definition['value'] ?? null; $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}'."); 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) { } elseif ($attribute->type === \App\Domains\Shared\Enums\FieldType::Multiselect) {
@@ -202,7 +216,7 @@ class Product extends Model
} }
} }
foreach ($values as $v) { 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}'."); throw new \InvalidArgumentException("The value '{$v}' is not a valid option for the multiselect attribute '{$attribute->nombre}'.");
} }
} }

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'product_id',
'attribute_id',
])]
class ProductAttribute extends Model
{
use HasFactory;
protected $table = 'products_attributes';
/**
* @return BelongsTo<Product, $this>
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'product_id');
}
/**
* @return BelongsTo<Attribute, $this>
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(Attribute::class, 'attribute_id');
}
/**
* @return HasMany<ProductVariantDefinition, $this>
*/
public function variantDefinitions(): HasMany
{
return $this->hasMany(ProductVariantDefinition::class, 'products_attribute_id');
}
}

View File

@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([ #[Fillable([
'producto_variante_id', 'producto_variante_id',
'attribute_id', 'products_attribute_id',
'value', 'value',
])] ])]
class ProductVariantDefinition extends Model class ProductVariantDefinition extends Model
@@ -27,10 +27,10 @@ class ProductVariantDefinition extends Model
} }
/** /**
* @return BelongsTo<Attribute, $this> * @return BelongsTo<ProductAttribute, $this>
*/ */
public function attribute(): BelongsTo public function productAttribute(): BelongsTo
{ {
return $this->belongsTo(Attribute::class, 'attribute_id'); return $this->belongsTo(ProductAttribute::class, 'products_attribute_id');
} }
} }

View File

@@ -21,25 +21,13 @@ class StoreProductVariantRequest extends FormRequest
return [ return [
'stock' => ['sometimes', 'integer', 'min:0'], 'stock' => ['sometimes', 'integer', 'min:0'],
'definitions' => ['sometimes', 'array'], 'definitions' => ['sometimes', 'array'],
'definitions.*.attribute_id' => [ 'definitions.*.products_attribute_id' => [
'required', 'required',
'integer', 'integer',
'distinct', 'distinct',
Rule::exists('attribute', 'id')->where( Rule::exists('products_attributes', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) 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'], 'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'], 'images' => ['sometimes', 'nullable', 'array'],

View File

@@ -21,25 +21,13 @@ class UpdateProductVariantRequest extends FormRequest
return [ return [
'stock' => ['sometimes', 'integer', 'min:0'], 'stock' => ['sometimes', 'integer', 'min:0'],
'definitions' => ['sometimes', 'array'], 'definitions' => ['sometimes', 'array'],
'definitions.*.attribute_id' => [ 'definitions.*.products_attribute_id' => [
'required', 'required',
'integer', 'integer',
'distinct', 'distinct',
Rule::exists('attribute', 'id')->where( Rule::exists('products_attributes', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) 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'], 'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'], 'images' => ['sometimes', 'nullable', 'array'],

View File

@@ -43,7 +43,7 @@ class ProductResource extends JsonResource
? $selectedVariant->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values() ? $selectedVariant->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values()
: $this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values(), : $this->attachments->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))->values(),
'attributes' => $selectedVariant->definitions->mapWithKeys(function ($definition) { 'attributes' => $selectedVariant->definitions->mapWithKeys(function ($definition) {
return [$definition->attribute?->codigo => $definition->value]; return [$definition->productAttribute?->attribute?->codigo => $definition->value];
})->toArray(), })->toArray(),
]; ];
} }

View File

@@ -18,9 +18,10 @@ class ProductVariantDefinitionResource extends JsonResource
return [ return [
'id' => $this->id, 'id' => $this->id,
'producto_variante_id' => $this->producto_variante_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, 'value' => $this->value,
'attribute' => $this->whenLoaded('attribute', fn () => $this->attribute?->nombre), 'attribute' => $this->whenLoaded('productAttribute', fn () => $this->productAttribute?->attribute?->nombre),
'metadata' => $this->resolveMetadata(), 'metadata' => $this->resolveMetadata(),
]; ];
} }
@@ -30,11 +31,17 @@ class ProductVariantDefinitionResource extends JsonResource
*/ */
protected function resolveMetadata(): ?array protected function resolveMetadata(): ?array
{ {
if (! $this->relationLoaded('attribute') || ! $this->attribute?->relationLoaded('options')) { if (! $this->relationLoaded('productAttribute')) {
return null; 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) { if ($option === null || $option->metadata === null) {
return null; return null;

View File

@@ -112,7 +112,7 @@ class ProductService
$this->syncVariantImages($variant, $images); $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); $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() $selectedVariantQuery = $product->variants()
->with([ ->with([
'attachments', 'attachments',
'definitions.attribute.options', 'definitions.productAttribute.attribute.options',
]); ]);
$selectedVariant = $variantId !== null $selectedVariant = $variantId !== null
@@ -302,7 +302,7 @@ class ProductService
$selectedVariant = $product->variants() $selectedVariant = $product->variants()
->with([ ->with([
'attachments', 'attachments',
'definitions.attribute.options', 'definitions.productAttribute.attribute.options',
]) ])
->first(); ->first();
} }

View File

@@ -20,7 +20,7 @@ class PurchaseController extends Controller
{ {
return PurchaseResource::collection( return PurchaseResource::collection(
Purchase::query() Purchase::query()
->with(['items.variant.product', 'items.variant.definitions']) ->with(['items.variant.product', 'items.variant.definitions.productAttribute.attribute'])
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->where('user_id', $request->user()->id) ->where('user_id', $request->user()->id)
->latest() ->latest()
@@ -48,7 +48,7 @@ class PurchaseController extends Controller
$purchase->items()->createMany($purchaseItems); $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); return PurchaseResource::make($purchase)->response()->setStatusCode(201);
@@ -59,7 +59,7 @@ class PurchaseController extends Controller
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make( return PurchaseResource::make(
$compra->loadMissing(['items.variant.product', 'items.variant.definitions']) $compra->loadMissing(['items.variant.product', 'items.variant.definitions.productAttribute.attribute'])
); );
} }

View File

@@ -36,6 +36,29 @@ return new class extends Migration
$table->unique(['product_id', 'attribute_id']); $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 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::dropIfExists('products_attributes');
Schema::rename('productos_variantes_values', 'productos_variantes_definiciones'); Schema::rename('productos_variantes_values', 'productos_variantes_definiciones');
Schema::rename('attribute', 'productos_attributes'); Schema::rename('attribute', 'productos_attributes');

View File

@@ -10,6 +10,7 @@ use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
@@ -205,6 +206,10 @@ class ProductCatalogFromImagesSeeder extends Seeder
'attribute_ids' => array_values($attributeIds->all()), 'attribute_ids' => array_values($attributeIds->all()),
]); ]);
$productAttributeIds = DB::table('products_attributes')
->where('product_id', $product->id)
->pluck('id', 'attribute_id');
$sizes = $metadata['type'] === 'zapatillas' $sizes = $metadata['type'] === 'zapatillas'
? self::SHOE_SIZES ? self::SHOE_SIZES
: self::CLOTHING_SIZES; : self::CLOTHING_SIZES;
@@ -219,8 +224,14 @@ class ProductCatalogFromImagesSeeder extends Seeder
$definitions = []; $definitions = [];
if (isset($attributeIds['color']) && $variantGroup['metadata']['color'] !== null) { 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[] = [ $definitions[] = [
'attribute_id' => $attributeIds['color'], 'products_attribute_id' => $colorProductAttributeId,
'value' => $variantGroup['metadata']['color'], 'value' => $variantGroup['metadata']['color'],
]; ];
} }
@@ -229,8 +240,14 @@ class ProductCatalogFromImagesSeeder extends Seeder
? 'talle_numerico' ? 'talle_numerico'
: 'talle'; : 'talle';
$sizeProductAttributeId = $productAttributeIds[$attributeIds[$sizeAttributeCode]] ?? null;
if ($sizeProductAttributeId === null) {
throw new RuntimeException("Missing product attribute for size on product '{$metadata['product_slug']}'.");
}
$definitions[] = [ $definitions[] = [
'attribute_id' => $attributeIds[$sizeAttributeCode], 'products_attribute_id' => $sizeProductAttributeId,
'value' => $size, 'value' => $size,
]; ];