feat(catalog): add tenant-scoped product attributes and variant definitions

This commit is contained in:
2026-06-24 15:08:13 -03:00
parent 689d115a8f
commit c6e5177df1
15 changed files with 630 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Requests\StoreProductAttributeRequest;
use App\Domains\Catalog\Requests\UpdateProductAttributeRequest;
use App\Domains\Catalog\Resources\ProductAttributeResource;
use App\Domains\Catalog\Support\ProductAttributeType;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class ProductAttributeController extends Controller
{
public function index(): JsonResponse
{
$query = ProductAttribute::query()->with('options')->latest();
if (request()->filled('tenant_codigo')) {
$query->where('tenant_codigo', request('tenant_codigo'));
}
return ProductAttributeResource::collection($query->get())->response();
}
public function store(StoreProductAttributeRequest $request): JsonResponse
{
$attribute = DB::transaction(function () use ($request): ProductAttribute {
$validated = $request->validated();
$options = $validated['options'] ?? [];
unset($validated['options']);
$type = ProductAttributeType::from((string) $validated['type']);
if (! $type->supportsOptions()) {
$validated['metadata_schema'] = null;
$options = [];
}
/** @var ProductAttribute $attribute */
$attribute = ProductAttribute::query()->create($validated);
$attribute->options()->createMany($options);
return $attribute->load('options');
});
return ProductAttributeResource::make($attribute)->response()->setStatusCode(201);
}
public function show(ProductAttribute $productAttribute): ProductAttributeResource
{
return ProductAttributeResource::make($productAttribute->load('options'));
}
public function update(UpdateProductAttributeRequest $request, ProductAttribute $productAttribute): ProductAttributeResource
{
$productAttribute = DB::transaction(function () use ($request, $productAttribute): ProductAttribute {
$validated = $request->validated();
$options = $validated['options'] ?? [];
unset($validated['options']);
$type = ProductAttributeType::from((string) $validated['type']);
if (! $type->supportsOptions()) {
$validated['metadata_schema'] = null;
$options = [];
}
$productAttribute->update($validated);
$productAttribute->options()->delete();
$productAttribute->options()->createMany($options);
return $productAttribute->load('options');
});
return ProductAttributeResource::make($productAttribute);
}
public function destroy(ProductAttribute $productAttribute): Response
{
$productAttribute->delete();
return response()->noContent();
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Catalog\Support\ProductAttributeType;
use App\Domains\Tenant\Models\Tenant;
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([
'tenant_codigo',
'codigo',
'nombre',
'is_required',
'metadata_schema',
'type',
])]
class ProductAttribute extends Model
{
use HasFactory;
protected $table = 'productos_attributes';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_required' => 'boolean',
'metadata_schema' => 'array',
'type' => ProductAttributeType::class,
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<ProductAttributeOption, $this>
*/
public function options(): HasMany
{
return $this->hasMany(ProductAttributeOption::class, 'attribute_id')->orderBy('sort_order');
}
/**
* @return HasMany<ProductVariantDefinition, $this>
*/
public function variantDefinitions(): HasMany
{
return $this->hasMany(ProductVariantDefinition::class, 'attribute_id');
}
}

View File

@@ -0,0 +1,40 @@
<?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;
#[Fillable([
'attribute_id',
'label',
'sort_order',
'metadata',
])]
class ProductAttributeOption extends Model
{
use HasFactory;
protected $table = 'attribute_options';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'sort_order' => 'integer',
'metadata' => 'array',
];
}
/**
* @return BelongsTo<ProductAttribute, $this>
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
}
}

View File

@@ -6,6 +6,7 @@ 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([
'producto_id',
@@ -37,4 +38,12 @@ class ProductVariant extends Model
{
return $this->belongsTo(Product::class, 'producto_id');
}
/**
* @return HasMany<ProductVariantDefinition, $this>
*/
public function definitions(): HasMany
{
return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id');
}
}

View File

@@ -0,0 +1,36 @@
<?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;
#[Fillable([
'producto_variante_id',
'attribute_id',
'value',
])]
class ProductVariantDefinition extends Model
{
use HasFactory;
protected $table = 'productos_variantes_definiciones';
/**
* @return BelongsTo<ProductVariant, $this>
*/
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
}
/**
* @return BelongsTo<ProductAttribute, $this>
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Support\ProductAttributeType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreProductAttributeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
'codigo' => [
'required',
'string',
'max:255',
Rule::unique('productos_attributes', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $this->input('tenant_codigo'))
),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(ProductAttributeType::class)],
'options' => [
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
ProductAttributeType::Select->value,
ProductAttributeType::Multiselect->value,
], true)),
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
ProductAttributeType::Select->value,
ProductAttributeType::Multiselect->value,
], true)),
'array',
],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
}
if (! $supportsOptions && $this->filled('options')) {
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
}
});
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Support\ProductAttributeType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateProductAttributeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var ProductAttribute|null $attribute */
$attribute = $this->route('productAttribute');
return [
'codigo' => [
'required',
'string',
'max:255',
Rule::unique('productos_attributes', 'codigo')
->ignore($attribute?->id)
->where(fn ($query) => $query->where('tenant_codigo', $attribute?->tenant_codigo)),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(ProductAttributeType::class)],
'options' => ['sometimes', 'array'],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
}
if (! $supportsOptions && $this->filled('options')) {
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
}
});
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductAttributeOption
*/
class ProductAttributeOptionResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'label' => $this->label,
'sort_order' => $this->sort_order,
'metadata' => $this->metadata,
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductAttribute
*/
class ProductAttributeResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_codigo' => $this->tenant_codigo,
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'is_required' => $this->is_required,
'metadata_schema' => $this->metadata_schema,
'type' => $this->type?->value ?? $this->type,
'options' => ProductAttributeOptionResource::collection($this->whenLoaded('options')),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Catalog\Support;
enum ProductAttributeType: string
{
case Text = 'text';
case Numeric = 'numeric';
case Select = 'select';
case Multiselect = 'multiselect';
public function supportsOptions(): bool
{
return in_array($this, [self::Select, self::Multiselect], true);
}
}

View File

@@ -3,8 +3,11 @@
use App\Domains\Catalog\Controllers\BrandController;
use App\Domains\Catalog\Controllers\CategoryController;
use App\Domains\Catalog\Controllers\ProductController;
use App\Domains\Catalog\Controllers\ProductAttributeController;
use Illuminate\Support\Facades\Route;
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']);
Route::apiResource('productos', ProductController::class);
Route::apiResource('product-attributes', ProductAttributeController::class)
->parameters(['product-attributes' => 'productAttribute']);