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": {
"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",

View File

@@ -94,7 +94,7 @@ class CartService
{
return $cart->fresh()->load([
'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()
->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

View File

@@ -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<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\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<ProductAttribute, $this>
*/
public function productAttributes(): HasMany
{
return $this->hasMany(ProductAttribute::class, 'product_id');
}
/**
* @return BelongsToMany<Attachment, $this>
*/
@@ -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}'.");
}
}

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([
'producto_variante_id',
'attribute_id',
'products_attribute_id',
'value',
])]
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 [
'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'],

View File

@@ -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'],

View File

@@ -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(),
];
}

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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'])
);
}

View File

@@ -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');

View File

@@ -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,
];