diff --git a/app/Domains/Catalog/Controllers/ProductAttributeController.php b/app/Domains/Catalog/Controllers/ProductAttributeController.php new file mode 100644 index 0000000..3fbbd21 --- /dev/null +++ b/app/Domains/Catalog/Controllers/ProductAttributeController.php @@ -0,0 +1,85 @@ +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(); + } +} diff --git a/app/Domains/Catalog/Models/ProductAttribute.php b/app/Domains/Catalog/Models/ProductAttribute.php new file mode 100644 index 0000000..9d3f333 --- /dev/null +++ b/app/Domains/Catalog/Models/ProductAttribute.php @@ -0,0 +1,62 @@ + + */ + protected function casts(): array + { + return [ + 'is_required' => 'boolean', + 'metadata_schema' => 'array', + 'type' => ProductAttributeType::class, + ]; + } + + /** + * @return BelongsTo + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); + } + + /** + * @return HasMany + */ + public function options(): HasMany + { + return $this->hasMany(ProductAttributeOption::class, 'attribute_id')->orderBy('sort_order'); + } + + /** + * @return HasMany + */ + public function variantDefinitions(): HasMany + { + return $this->hasMany(ProductVariantDefinition::class, 'attribute_id'); + } +} diff --git a/app/Domains/Catalog/Models/ProductAttributeOption.php b/app/Domains/Catalog/Models/ProductAttributeOption.php new file mode 100644 index 0000000..9dd1f18 --- /dev/null +++ b/app/Domains/Catalog/Models/ProductAttributeOption.php @@ -0,0 +1,40 @@ + + */ + protected function casts(): array + { + return [ + 'sort_order' => 'integer', + 'metadata' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function attribute(): BelongsTo + { + return $this->belongsTo(ProductAttribute::class, 'attribute_id'); + } +} diff --git a/app/Domains/Catalog/Models/ProductVariant.php b/app/Domains/Catalog/Models/ProductVariant.php index edefee7..15aee27 100644 --- a/app/Domains/Catalog/Models/ProductVariant.php +++ b/app/Domains/Catalog/Models/ProductVariant.php @@ -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 + */ + public function definitions(): HasMany + { + return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id'); + } } diff --git a/app/Domains/Catalog/Models/ProductVariantDefinition.php b/app/Domains/Catalog/Models/ProductVariantDefinition.php new file mode 100644 index 0000000..07c9026 --- /dev/null +++ b/app/Domains/Catalog/Models/ProductVariantDefinition.php @@ -0,0 +1,36 @@ + + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'producto_variante_id'); + } + + /** + * @return BelongsTo + */ + public function attribute(): BelongsTo + { + return $this->belongsTo(ProductAttribute::class, 'attribute_id'); + } +} diff --git a/app/Domains/Catalog/Requests/StoreProductAttributeRequest.php b/app/Domains/Catalog/Requests/StoreProductAttributeRequest.php new file mode 100644 index 0000000..5a45bed --- /dev/null +++ b/app/Domains/Catalog/Requests/StoreProductAttributeRequest.php @@ -0,0 +1,68 @@ + + */ + 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.'); + } + }); + } +} diff --git a/app/Domains/Catalog/Requests/UpdateProductAttributeRequest.php b/app/Domains/Catalog/Requests/UpdateProductAttributeRequest.php new file mode 100644 index 0000000..f093b00 --- /dev/null +++ b/app/Domains/Catalog/Requests/UpdateProductAttributeRequest.php @@ -0,0 +1,61 @@ + + */ + 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.'); + } + }); + } +} diff --git a/app/Domains/Catalog/Resources/ProductAttributeOptionResource.php b/app/Domains/Catalog/Resources/ProductAttributeOptionResource.php new file mode 100644 index 0000000..60b5aed --- /dev/null +++ b/app/Domains/Catalog/Resources/ProductAttributeOptionResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'label' => $this->label, + 'sort_order' => $this->sort_order, + 'metadata' => $this->metadata, + ]; + } +} diff --git a/app/Domains/Catalog/Resources/ProductAttributeResource.php b/app/Domains/Catalog/Resources/ProductAttributeResource.php new file mode 100644 index 0000000..0069e69 --- /dev/null +++ b/app/Domains/Catalog/Resources/ProductAttributeResource.php @@ -0,0 +1,29 @@ + + */ + 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')), + ]; + } +} diff --git a/app/Domains/Catalog/Support/ProductAttributeType.php b/app/Domains/Catalog/Support/ProductAttributeType.php new file mode 100644 index 0000000..d3a67a8 --- /dev/null +++ b/app/Domains/Catalog/Support/ProductAttributeType.php @@ -0,0 +1,16 @@ +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']); diff --git a/database/migrations/2026_06_24_000300_create_productos_attributes_table.php b/database/migrations/2026_06_24_000300_create_productos_attributes_table.php new file mode 100644 index 0000000..bf47a7b --- /dev/null +++ b/database/migrations/2026_06_24_000300_create_productos_attributes_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('tenant_codigo'); + $table->string('codigo'); + $table->string('nombre'); + $table->boolean('is_required')->default(false); + $table->json('metadata_schema')->nullable(); + $table->string('type'); + $table->timestamps(); + + $table->foreign('tenant_codigo') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + + $table->unique(['tenant_codigo', 'codigo']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('productos_attributes'); + } +}; diff --git a/database/migrations/2026_06_24_000310_create_attribute_options_table.php b/database/migrations/2026_06_24_000310_create_attribute_options_table.php new file mode 100644 index 0000000..0d79eb3 --- /dev/null +++ b/database/migrations/2026_06_24_000310_create_attribute_options_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('attribute_id')->constrained('productos_attributes')->cascadeOnUpdate()->cascadeOnDelete(); + $table->string('label'); + $table->unsignedInteger('sort_order')->default(0); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('attribute_options'); + } +}; diff --git a/database/migrations/2026_06_24_000320_create_productos_variantes_definiciones_table.php b/database/migrations/2026_06_24_000320_create_productos_variantes_definiciones_table.php new file mode 100644 index 0000000..b70edd0 --- /dev/null +++ b/database/migrations/2026_06_24_000320_create_productos_variantes_definiciones_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedBigInteger('producto_variante_id'); + $table->foreignId('attribute_id')->constrained('productos_attributes')->cascadeOnUpdate()->cascadeOnDelete(); + $table->text('value')->nullable(); + $table->timestamps(); + + $table->foreign('producto_variante_id') + ->references('id') + ->on('productos_variantes') + ->cascadeOnUpdate() + ->cascadeOnDelete(); + + $table->unique(['producto_variante_id', 'attribute_id'], 'prod_var_def_variant_attr_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('productos_variantes_definiciones'); + } +}; diff --git a/tests/Feature/Catalog/ProductAttributeControllerTest.php b/tests/Feature/Catalog/ProductAttributeControllerTest.php new file mode 100644 index 0000000..e797ad7 --- /dev/null +++ b/tests/Feature/Catalog/ProductAttributeControllerTest.php @@ -0,0 +1,86 @@ + 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.com', + ]); + + $response = $this->postJson('/api/product-attributes', [ + 'tenant_codigo' => 'acme', + 'codigo' => 'color', + 'nombre' => 'Color', + 'is_required' => true, + 'type' => 'select', + 'metadata_schema' => [ + 'swatch' => ['type' => 'hex'], + ], + 'options' => [ + [ + 'label' => 'Red', + 'sort_order' => 1, + 'metadata' => ['hex' => '#ff0000'], + ], + [ + 'label' => 'Blue', + 'sort_order' => 2, + 'metadata' => ['hex' => '#0000ff'], + ], + ], + ]); + + $response + ->assertCreated() + ->assertJsonPath('tenant_codigo', 'acme') + ->assertJsonPath('codigo', 'color') + ->assertJsonPath('type', 'select') + ->assertJsonPath('options.0.label', 'Red') + ->assertJsonPath('options.1.metadata.hex', '#0000ff'); + + $this->assertDatabaseHas('productos_attributes', [ + 'tenant_codigo' => 'acme', + 'codigo' => 'color', + 'type' => 'select', + ]); + + $this->assertDatabaseHas('attribute_options', [ + 'label' => 'Red', + 'sort_order' => 1, + ]); + } + + public function test_it_rejects_options_for_text_attributes(): void + { + Tenant::create([ + 'codigo' => 'acme', + 'nombre' => 'Acme', + 'dominio' => 'acme.com', + ]); + + $response = $this->postJson('/api/product-attributes', [ + 'tenant_codigo' => 'acme', + 'codigo' => 'material', + 'nombre' => 'Material', + 'type' => 'text', + 'options' => [ + ['label' => 'Cotton'], + ], + ]); + + $response + ->assertUnprocessable() + ->assertJsonValidationErrors(['options']); + } +}