From 18504594eb8141872c248ff6f3994128946d867e Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 29 Jun 2026 11:02:04 -0300 Subject: [PATCH] feat: implement product creation workflow with variants, attribute definitions, and validation logic --- .../Controllers/AttributeController.php | 103 +++++ .../ProductAttributeController.php | 103 ----- .../Catalog/Controllers/ProductController.php | 14 +- .../{ProductAttribute.php => Attribute.php} | 8 +- ...ttributeOption.php => AttributeOption.php} | 6 +- app/Domains/Catalog/Models/Product.php | 14 + .../Models/ProductVariantDefinition.php | 6 +- ...eRequest.php => StoreAttributeRequest.php} | 4 +- .../Catalog/Requests/StoreProductRequest.php | 32 ++ .../Requests/StoreProductVariantRequest.php | 2 +- ...Request.php => UpdateAttributeRequest.php} | 10 +- .../Catalog/Requests/UpdateProductRequest.php | 39 ++ .../Requests/UpdateProductVariantRequest.php | 2 +- ...source.php => AttributeOptionResource.php} | 4 +- ...buteResource.php => AttributeResource.php} | 6 +- .../Catalog/Resources/ProductResource.php | 1 + .../ProductVariantDefinitionResource.php | 2 +- .../Catalog/Services/ProductService.php | 109 ++++- app/Domains/Catalog/routes/api.php | 5 +- ...2114_refactor_product_attributes_table.php | 50 +++ tests/Feature/Cart/CartControllerTest.php | 4 +- ...erTest.php => AttributeControllerTest.php} | 8 +- .../Feature/Catalog/ProductControllerTest.php | 388 ++++++++++++++++++ 23 files changed, 774 insertions(+), 146 deletions(-) create mode 100644 app/Domains/Catalog/Controllers/AttributeController.php delete mode 100644 app/Domains/Catalog/Controllers/ProductAttributeController.php rename app/Domains/Catalog/Models/{ProductAttribute.php => Attribute.php} (83%) rename app/Domains/Catalog/Models/{ProductAttributeOption.php => AttributeOption.php} (80%) rename app/Domains/Catalog/Requests/{StoreProductAttributeRequest.php => StoreAttributeRequest.php} (94%) rename app/Domains/Catalog/Requests/{UpdateProductAttributeRequest.php => UpdateAttributeRequest.php} (87%) rename app/Domains/Catalog/Resources/{ProductAttributeOptionResource.php => AttributeOptionResource.php} (80%) rename app/Domains/Catalog/Resources/{ProductAttributeResource.php => AttributeResource.php} (74%) create mode 100644 database/migrations/2026_06_29_102114_refactor_product_attributes_table.php rename tests/Feature/Catalog/{ProductAttributeControllerTest.php => AttributeControllerTest.php} (92%) create mode 100644 tests/Feature/Catalog/ProductControllerTest.php diff --git a/app/Domains/Catalog/Controllers/AttributeController.php b/app/Domains/Catalog/Controllers/AttributeController.php new file mode 100644 index 0000000..a706cb5 --- /dev/null +++ b/app/Domains/Catalog/Controllers/AttributeController.php @@ -0,0 +1,103 @@ +where('tenant_codigo', $tenant->codigo) + ->with('options') + ->latest(); + + return AttributeResource::collection($query->paginateFromRequest())->response(); + } + + public function store(StoreAttributeRequest $request, Tenant $tenant): JsonResponse + { + $attribute = DB::transaction(function () use ($request, $tenant): Attribute { + $validated = $request->validated(); + $options = $validated['options'] ?? []; + unset($validated['options']); + + $type = FieldType::from((string) $validated['type']); + if (! $type->supportsOptions()) { + $validated['metadata_schema'] = null; + $options = []; + } + + /** @var Attribute $attribute */ + $attribute = Attribute::query()->create([ + ...$validated, + 'tenant_codigo' => $tenant->codigo, + ]); + $attribute->options()->createMany($options); + + return $attribute->load('options'); + }); + + return AttributeResource::make($attribute)->response()->setStatusCode(201); + } + + public function show(Tenant $tenant, Attribute $attribute): AttributeResource + { + $attribute = $this->resolveScopedAttribute($tenant, $attribute); + + return AttributeResource::make($attribute->load('options')); + } + + public function update(UpdateAttributeRequest $request, Tenant $tenant, Attribute $attribute): AttributeResource + { + $attribute = $this->resolveScopedAttribute($tenant, $attribute); + + $attribute = DB::transaction(function () use ($request, $attribute): Attribute { + $validated = $request->validated(); + $options = $validated['options'] ?? []; + unset($validated['options']); + + $type = FieldType::from((string) $validated['type']); + if (! $type->supportsOptions()) { + $validated['metadata_schema'] = null; + $options = []; + } + + $attribute->update($validated); + $attribute->options()->delete(); + $attribute->options()->createMany($options); + + return $attribute->load('options'); + }); + + return AttributeResource::make($attribute); + } + + public function destroy(Tenant $tenant, Attribute $attribute): Response + { + $attribute = $this->resolveScopedAttribute($tenant, $attribute); + $attribute->delete(); + + return response()->noContent(); + } + + protected function resolveScopedAttribute(Tenant $tenant, Attribute $attribute): Attribute + { + if ($attribute->tenant_codigo !== $tenant->codigo) { + throw new NotFoundHttpException('Attribute not found for tenant.'); + } + + return $attribute; + } +} diff --git a/app/Domains/Catalog/Controllers/ProductAttributeController.php b/app/Domains/Catalog/Controllers/ProductAttributeController.php deleted file mode 100644 index e19c09b..0000000 --- a/app/Domains/Catalog/Controllers/ProductAttributeController.php +++ /dev/null @@ -1,103 +0,0 @@ -where('tenant_codigo', $tenant->codigo) - ->with('options') - ->latest(); - - return ProductAttributeResource::collection($query->paginateFromRequest())->response(); - } - - public function store(StoreProductAttributeRequest $request, Tenant $tenant): JsonResponse - { - $attribute = DB::transaction(function () use ($request, $tenant): ProductAttribute { - $validated = $request->validated(); - $options = $validated['options'] ?? []; - unset($validated['options']); - - $type = FieldType::from((string) $validated['type']); - if (! $type->supportsOptions()) { - $validated['metadata_schema'] = null; - $options = []; - } - - /** @var ProductAttribute $attribute */ - $attribute = ProductAttribute::query()->create([ - ...$validated, - 'tenant_codigo' => $tenant->codigo, - ]); - $attribute->options()->createMany($options); - - return $attribute->load('options'); - }); - - return ProductAttributeResource::make($attribute)->response()->setStatusCode(201); - } - - public function show(Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource - { - $productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute); - - return ProductAttributeResource::make($productAttribute->load('options')); - } - - public function update(UpdateProductAttributeRequest $request, Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource - { - $productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute); - - $productAttribute = DB::transaction(function () use ($request, $productAttribute): ProductAttribute { - $validated = $request->validated(); - $options = $validated['options'] ?? []; - unset($validated['options']); - - $type = FieldType::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(Tenant $tenant, ProductAttribute $productAttribute): Response - { - $productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute); - $productAttribute->delete(); - - return response()->noContent(); - } - - protected function resolveScopedAttribute(Tenant $tenant, ProductAttribute $attribute): ProductAttribute - { - if ($attribute->tenant_codigo !== $tenant->codigo) { - throw new NotFoundHttpException('Product attribute not found for tenant.'); - } - - return $attribute; - } -} diff --git a/app/Domains/Catalog/Controllers/ProductController.php b/app/Domains/Catalog/Controllers/ProductController.php index 8b46023..989b1ef 100644 --- a/app/Domains/Catalog/Controllers/ProductController.php +++ b/app/Domains/Catalog/Controllers/ProductController.php @@ -6,6 +6,7 @@ use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Requests\StoreProductRequest; use App\Domains\Catalog\Requests\UpdateProductRequest; use App\Domains\Catalog\Resources\ProductResource; +use App\Domains\Catalog\Services\ProductService; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; @@ -21,12 +22,9 @@ class ProductController extends Controller )->response(); } - public function store(StoreProductRequest $request, Tenant $tenant): JsonResponse + public function store(StoreProductRequest $request, Tenant $tenant, ProductService $productService): JsonResponse { - $product = Product::query()->create([ - ...$request->validated(), - 'tenant_codigo' => $tenant->codigo, - ]); + $product = $productService->createProductWithVariants($tenant, $request->validated()); return ProductResource::make($product)->response()->setStatusCode(201); } @@ -35,13 +33,13 @@ class ProductController extends Controller { $producto = $this->resolveScopedProduct($tenant, $producto); - return ProductResource::make($producto); + return ProductResource::make($producto->load('variants.definitions.attribute')); } - public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto): ProductResource + public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource { $producto = $this->resolveScopedProduct($tenant, $producto); - $producto->update($request->validated()); + $producto = $productService->updateProductWithVariants($producto, $request->validated()); return ProductResource::make($producto); } diff --git a/app/Domains/Catalog/Models/ProductAttribute.php b/app/Domains/Catalog/Models/Attribute.php similarity index 83% rename from app/Domains/Catalog/Models/ProductAttribute.php rename to app/Domains/Catalog/Models/Attribute.php index 82b06ed..ee36dfc 100644 --- a/app/Domains/Catalog/Models/ProductAttribute.php +++ b/app/Domains/Catalog/Models/Attribute.php @@ -18,11 +18,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'metadata_schema', 'type', ])] -class ProductAttribute extends Model +class Attribute extends Model { use HasFactory; - protected $table = 'productos_attributes'; + protected $table = 'attribute'; /** * @return array @@ -45,11 +45,11 @@ class ProductAttribute extends Model } /** - * @return HasMany + * @return HasMany */ public function options(): HasMany { - return $this->hasMany(ProductAttributeOption::class, 'attribute_id')->orderBy('sort_order'); + return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order'); } /** diff --git a/app/Domains/Catalog/Models/ProductAttributeOption.php b/app/Domains/Catalog/Models/AttributeOption.php similarity index 80% rename from app/Domains/Catalog/Models/ProductAttributeOption.php rename to app/Domains/Catalog/Models/AttributeOption.php index f1e75a9..fe9811c 100644 --- a/app/Domains/Catalog/Models/ProductAttributeOption.php +++ b/app/Domains/Catalog/Models/AttributeOption.php @@ -14,7 +14,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'sort_order', 'metadata', ])] -class ProductAttributeOption extends Model +class AttributeOption extends Model { use HasFactory; @@ -32,10 +32,10 @@ class ProductAttributeOption extends Model } /** - * @return BelongsTo + * @return BelongsTo */ public function attribute(): BelongsTo { - return $this->belongsTo(ProductAttribute::class, 'attribute_id'); + return $this->belongsTo(Attribute::class, 'attribute_id'); } } diff --git a/app/Domains/Catalog/Models/Product.php b/app/Domains/Catalog/Models/Product.php index 8063d72..b7ccee8 100644 --- a/app/Domains/Catalog/Models/Product.php +++ b/app/Domains/Catalog/Models/Product.php @@ -7,6 +7,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\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; #[Fillable([ @@ -46,4 +47,17 @@ class Product extends Model { return $this->hasMany(ProductVariant::class, 'producto_id'); } + + /** + * @return BelongsToMany + */ + public function attributes(): BelongsToMany + { + return $this->belongsToMany( + Attribute::class, + 'products_attributes', + 'product_id', + 'attribute_id' + )->withTimestamps(); + } } diff --git a/app/Domains/Catalog/Models/ProductVariantDefinition.php b/app/Domains/Catalog/Models/ProductVariantDefinition.php index 07c9026..1513dbc 100644 --- a/app/Domains/Catalog/Models/ProductVariantDefinition.php +++ b/app/Domains/Catalog/Models/ProductVariantDefinition.php @@ -16,7 +16,7 @@ class ProductVariantDefinition extends Model { use HasFactory; - protected $table = 'productos_variantes_definiciones'; + protected $table = 'productos_variantes_values'; /** * @return BelongsTo @@ -27,10 +27,10 @@ class ProductVariantDefinition extends Model } /** - * @return BelongsTo + * @return BelongsTo */ public function attribute(): BelongsTo { - return $this->belongsTo(ProductAttribute::class, 'attribute_id'); + return $this->belongsTo(Attribute::class, 'attribute_id'); } } diff --git a/app/Domains/Catalog/Requests/StoreProductAttributeRequest.php b/app/Domains/Catalog/Requests/StoreAttributeRequest.php similarity index 94% rename from app/Domains/Catalog/Requests/StoreProductAttributeRequest.php rename to app/Domains/Catalog/Requests/StoreAttributeRequest.php index 5e8cc65..70f7cad 100644 --- a/app/Domains/Catalog/Requests/StoreProductAttributeRequest.php +++ b/app/Domains/Catalog/Requests/StoreAttributeRequest.php @@ -7,7 +7,7 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; -class StoreProductAttributeRequest extends FormRequest +class StoreAttributeRequest extends FormRequest { public function authorize(): bool { @@ -24,7 +24,7 @@ class StoreProductAttributeRequest extends FormRequest 'required', 'string', 'max:255', - Rule::unique('productos_attributes', 'codigo')->where( + Rule::unique('attribute', 'codigo')->where( fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) ), ], diff --git a/app/Domains/Catalog/Requests/StoreProductRequest.php b/app/Domains/Catalog/Requests/StoreProductRequest.php index a01bdb8..00f1285 100644 --- a/app/Domains/Catalog/Requests/StoreProductRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductRequest.php @@ -23,6 +23,38 @@ class StoreProductRequest extends FormRequest 'nombre' => ['required', 'string', 'max:255'], 'descripcion' => ['nullable', 'string'], 'precio' => ['required', 'numeric', 'min:0'], + 'attribute_ids' => ['sometimes', 'array'], + 'attribute_ids.*' => [ + 'required', + 'integer', + Rule::exists('attribute', 'id')->where( + fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) + ), + ], + 'variants' => ['sometimes', 'array'], + 'variants.*.slug' => ['nullable', 'string', 'max:255'], + 'variants.*.nombre' => ['nullable', 'string', 'max:255'], + 'variants.*.stock' => ['sometimes', 'integer', 'min:0'], + 'variants.*.descripcion' => ['nullable', 'string'], + 'variants.*.precio' => ['required', 'numeric', 'min:0'], + 'variants.*.definitions' => [ + 'sometimes', + 'array', + function ($attribute, $value, $fail) { + $attributeIds = array_column($value, 'attribute_id'); + if (count($attributeIds) !== count(array_unique($attributeIds))) { + $fail('The definitions must have distinct attribute ids.'); + } + } + ], + 'variants.*.definitions.*.attribute_id' => [ + 'required', + 'integer', + Rule::exists('attribute', 'id')->where( + fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) + ), + ], + 'variants.*.definitions.*.value' => ['nullable', 'string'], ]; } } diff --git a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php b/app/Domains/Catalog/Requests/StoreProductVariantRequest.php index aab6c2a..5560de6 100644 --- a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductVariantRequest.php @@ -42,7 +42,7 @@ class StoreProductVariantRequest extends FormRequest 'required', 'integer', 'distinct', - Rule::exists('productos_attributes', 'id')->where( + Rule::exists('attribute', 'id')->where( fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) ), ], diff --git a/app/Domains/Catalog/Requests/UpdateProductAttributeRequest.php b/app/Domains/Catalog/Requests/UpdateAttributeRequest.php similarity index 87% rename from app/Domains/Catalog/Requests/UpdateProductAttributeRequest.php rename to app/Domains/Catalog/Requests/UpdateAttributeRequest.php index 46f9921..4daa37c 100644 --- a/app/Domains/Catalog/Requests/UpdateProductAttributeRequest.php +++ b/app/Domains/Catalog/Requests/UpdateAttributeRequest.php @@ -2,13 +2,13 @@ namespace App\Domains\Catalog\Requests; -use App\Domains\Catalog\Models\ProductAttribute; +use App\Domains\Catalog\Models\Attribute; use App\Domains\Shared\Enums\FieldType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; -class UpdateProductAttributeRequest extends FormRequest +class UpdateAttributeRequest extends FormRequest { public function authorize(): bool { @@ -20,15 +20,15 @@ class UpdateProductAttributeRequest extends FormRequest */ public function rules(): array { - /** @var ProductAttribute|null $attribute */ - $attribute = $this->route('productAttribute'); + /** @var Attribute|null $attribute */ + $attribute = $this->route('attribute'); return [ 'codigo' => [ 'required', 'string', 'max:255', - Rule::unique('productos_attributes', 'codigo') + Rule::unique('attribute', 'codigo') ->ignore($attribute?->id) ->where(fn ($query) => $query->where('tenant_codigo', $attribute?->tenant_codigo)), ], diff --git a/app/Domains/Catalog/Requests/UpdateProductRequest.php b/app/Domains/Catalog/Requests/UpdateProductRequest.php index f270db8..c2a0b86 100644 --- a/app/Domains/Catalog/Requests/UpdateProductRequest.php +++ b/app/Domains/Catalog/Requests/UpdateProductRequest.php @@ -32,6 +32,45 @@ class UpdateProductRequest extends FormRequest 'nombre' => ['required', 'string', 'max:255'], 'descripcion' => ['nullable', 'string'], 'precio' => ['required', 'numeric', 'min:0'], + 'attribute_ids' => ['sometimes', 'array'], + 'attribute_ids.*' => [ + 'required', + 'integer', + Rule::exists('attribute', 'id')->where( + fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) + ), + ], + 'variants' => ['sometimes', 'array'], + 'variants.*.id' => [ + 'sometimes', + 'integer', + Rule::exists('productos_variantes', 'id')->where( + fn ($query) => $query->where('producto_id', $product?->id) + ), + ], + 'variants.*.slug' => ['nullable', 'string', 'max:255'], + 'variants.*.nombre' => ['nullable', 'string', 'max:255'], + 'variants.*.stock' => ['sometimes', 'integer', 'min:0'], + 'variants.*.descripcion' => ['nullable', 'string'], + 'variants.*.precio' => ['required', 'numeric', 'min:0'], + 'variants.*.definitions' => [ + 'sometimes', + 'array', + function ($attribute, $value, $fail) { + $attributeIds = array_column($value, 'attribute_id'); + if (count($attributeIds) !== count(array_unique($attributeIds))) { + $fail('The definitions must have distinct attribute ids.'); + } + } + ], + 'variants.*.definitions.*.attribute_id' => [ + 'required', + 'integer', + Rule::exists('attribute', 'id')->where( + fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) + ), + ], + 'variants.*.definitions.*.value' => ['nullable', 'string'], ]; } } diff --git a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php b/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php index 42e1f7f..d868917 100644 --- a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php +++ b/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php @@ -46,7 +46,7 @@ class UpdateProductVariantRequest extends FormRequest 'required', 'integer', 'distinct', - Rule::exists('productos_attributes', 'id')->where( + Rule::exists('attribute', 'id')->where( fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo) ), ], diff --git a/app/Domains/Catalog/Resources/ProductAttributeOptionResource.php b/app/Domains/Catalog/Resources/AttributeOptionResource.php similarity index 80% rename from app/Domains/Catalog/Resources/ProductAttributeOptionResource.php rename to app/Domains/Catalog/Resources/AttributeOptionResource.php index 78ad00c..2ffcae9 100644 --- a/app/Domains/Catalog/Resources/ProductAttributeOptionResource.php +++ b/app/Domains/Catalog/Resources/AttributeOptionResource.php @@ -6,9 +6,9 @@ use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Domains\Catalog\Models\ProductAttributeOption + * @mixin \App\Domains\Catalog\Models\AttributeOption */ -class ProductAttributeOptionResource extends JsonResource +class AttributeOptionResource extends JsonResource { /** * @return array diff --git a/app/Domains/Catalog/Resources/ProductAttributeResource.php b/app/Domains/Catalog/Resources/AttributeResource.php similarity index 74% rename from app/Domains/Catalog/Resources/ProductAttributeResource.php rename to app/Domains/Catalog/Resources/AttributeResource.php index 0069e69..924b9fb 100644 --- a/app/Domains/Catalog/Resources/ProductAttributeResource.php +++ b/app/Domains/Catalog/Resources/AttributeResource.php @@ -6,9 +6,9 @@ use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Domains\Catalog\Models\ProductAttribute + * @mixin \App\Domains\Catalog\Models\Attribute */ -class ProductAttributeResource extends JsonResource +class AttributeResource extends JsonResource { /** * @return array @@ -23,7 +23,7 @@ class ProductAttributeResource extends JsonResource 'is_required' => $this->is_required, 'metadata_schema' => $this->metadata_schema, 'type' => $this->type?->value ?? $this->type, - 'options' => ProductAttributeOptionResource::collection($this->whenLoaded('options')), + 'options' => AttributeOptionResource::collection($this->whenLoaded('options')), ]; } } diff --git a/app/Domains/Catalog/Resources/ProductResource.php b/app/Domains/Catalog/Resources/ProductResource.php index e1c93c7..08b5f50 100644 --- a/app/Domains/Catalog/Resources/ProductResource.php +++ b/app/Domains/Catalog/Resources/ProductResource.php @@ -23,6 +23,7 @@ class ProductResource extends JsonResource 'nombre' => $this->nombre, 'descripcion' => $this->descripcion, 'precio' => $this->precio, + 'variants' => ProductVariantResource::collection($this->whenLoaded('variants')), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php b/app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php index 9602357..1bc2c4d 100644 --- a/app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php +++ b/app/Domains/Catalog/Resources/ProductVariantDefinitionResource.php @@ -20,7 +20,7 @@ class ProductVariantDefinitionResource extends JsonResource 'producto_variante_id' => $this->producto_variante_id, 'attribute_id' => $this->attribute_id, 'value' => $this->value, - 'attribute' => ProductAttributeResource::make($this->whenLoaded('attribute')), + 'attribute' => AttributeResource::make($this->whenLoaded('attribute')), ]; } } diff --git a/app/Domains/Catalog/Services/ProductService.php b/app/Domains/Catalog/Services/ProductService.php index e5dc4c1..eb03bed 100644 --- a/app/Domains/Catalog/Services/ProductService.php +++ b/app/Domains/Catalog/Services/ProductService.php @@ -2,7 +2,114 @@ namespace App\Domains\Catalog\Services; +use App\Domains\Catalog\Models\Product; +use App\Domains\Tenant\Models\Tenant; +use Illuminate\Support\Facades\DB; + class ProductService { - // Reserved for product domain business logic. + /** + * Create a product along with its variants and variant definitions. + * + * @param array $data + */ + public function createProductWithVariants(Tenant $tenant, array $data): Product + { + return DB::transaction(function () use ($tenant, $data) { + $variantsData = $data['variants'] ?? []; + unset($data['variants']); + + $attributeIds = $data['attribute_ids'] ?? []; + unset($data['attribute_ids']); + + /** @var Product $product */ + $product = Product::query()->create([ + ...$data, + 'tenant_codigo' => $tenant->codigo, + ]); + + foreach ($variantsData as $variantData) { + $definitions = $variantData['definitions'] ?? []; + unset($variantData['definitions']); + + $variant = $product->variants()->create($variantData); + $variant->definitions()->createMany($definitions); + } + + $attributeIdsFromVariants = collect($variantsData)->flatMap(function ($variant) { + return collect($variant['definitions'] ?? [])->pluck('attribute_id'); + })->unique()->toArray(); + + $allAttributeIds = array_unique(array_merge($attributeIds, $attributeIdsFromVariants)); + $product->attributes()->sync($allAttributeIds); + + return $product->load(['variants.definitions.attribute', 'attributes']); + }); + } + + /** + * Update a product along with its variants and variant definitions. + * + * @param array $data + */ + public function updateProductWithVariants(Product $product, array $data): Product + { + return DB::transaction(function () use ($product, $data) { + $hasVariants = array_key_exists('variants', $data); + $variantsData = $data['variants'] ?? []; + unset($data['variants']); + + $hasAttributeIds = array_key_exists('attribute_ids', $data); + $attributeIds = $data['attribute_ids'] ?? []; + unset($data['attribute_ids']); + + $product->update($data); + + if ($hasVariants) { + $existingVariantIds = $product->variants()->pluck('id')->toArray(); + $incomingVariantIds = []; + + foreach ($variantsData as $variantData) { + $definitions = $variantData['definitions'] ?? []; + unset($variantData['definitions']); + + $variantId = $variantData['id'] ?? null; + + if ($variantId && in_array($variantId, $existingVariantIds)) { + $variant = $product->variants()->findOrFail($variantId); + $variant->update($variantData); + $incomingVariantIds[] = (int) $variantId; + } else { + $variant = $product->variants()->create($variantData); + $incomingVariantIds[] = $variant->id; + } + + $variant->definitions()->delete(); + $variant->definitions()->createMany($definitions); + } + + $variantsToDelete = array_diff($existingVariantIds, $incomingVariantIds); + if (!empty($variantsToDelete)) { + $product->variants()->whereIn('id', $variantsToDelete)->delete(); + } + } + + if ($hasAttributeIds || $hasVariants) { + $explicitIds = $hasAttributeIds ? $attributeIds : $product->attributes()->pluck('attribute_id')->toArray(); + + $variantIds = $product->variants()->pluck('id')->toArray(); + $attributeIdsFromVariants = DB::table('productos_variantes_values') + ->whereIn('producto_variante_id', $variantIds) + ->pluck('attribute_id') + ->unique() + ->toArray(); + + $allAttributeIds = array_unique(array_merge($explicitIds, $attributeIdsFromVariants)); + $product->attributes()->sync($allAttributeIds); + } + + return $product->load(['variants.definitions.attribute', 'attributes']); + }); + } } + diff --git a/app/Domains/Catalog/routes/api.php b/app/Domains/Catalog/routes/api.php index 2d0c515..d5c6e65 100644 --- a/app/Domains/Catalog/routes/api.php +++ b/app/Domains/Catalog/routes/api.php @@ -3,7 +3,7 @@ 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 App\Domains\Catalog\Controllers\AttributeController; use App\Domains\Catalog\Controllers\ProductVariantController; use Illuminate\Support\Facades\Route; @@ -11,8 +11,7 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void { 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']); + Route::apiResource('attributes', AttributeController::class); Route::apiResource('product-variants', ProductVariantController::class) ->parameters(['product-variants' => 'productVariant']); }); 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 new file mode 100644 index 0000000..07df76d --- /dev/null +++ b/database/migrations/2026_06_29_102114_refactor_product_attributes_table.php @@ -0,0 +1,50 @@ +id(); + $table->unsignedBigInteger('product_id'); + $table->unsignedBigInteger('attribute_id'); + $table->timestamps(); + + $table->foreign('product_id') + ->references('id') + ->on('productos') + ->cascadeOnDelete(); + + $table->foreign('attribute_id') + ->references('id') + ->on('attribute') + ->cascadeOnDelete(); + + $table->unique(['product_id', 'attribute_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products_attributes'); + Schema::rename('productos_variantes_values', 'productos_variantes_definiciones'); + Schema::rename('attribute', 'productos_attributes'); + } +}; diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index ef12c19..a9d3fd4 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -3,7 +3,7 @@ namespace Tests\Feature\Cart; use App\Domains\Catalog\Models\Product; -use App\Domains\Catalog\Models\ProductAttribute; +use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Catalog\Models\ProductVariantDefinition; use App\Domains\Tenant\Models\Tenant; @@ -33,7 +33,7 @@ class CartControllerTest extends TestCase public function test_it_creates_a_guest_cart_and_returns_the_cart_snapshot(): void { $variant = $this->createVariantForTenant('acme', 10, '49.90'); - $attribute = ProductAttribute::query()->create([ + $attribute = Attribute::query()->create([ 'tenant_codigo' => 'acme', 'codigo' => 'color', 'nombre' => 'Color', diff --git a/tests/Feature/Catalog/ProductAttributeControllerTest.php b/tests/Feature/Catalog/AttributeControllerTest.php similarity index 92% rename from tests/Feature/Catalog/ProductAttributeControllerTest.php rename to tests/Feature/Catalog/AttributeControllerTest.php index 9e36add..eebbdce 100644 --- a/tests/Feature/Catalog/ProductAttributeControllerTest.php +++ b/tests/Feature/Catalog/AttributeControllerTest.php @@ -6,7 +6,7 @@ use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; -class ProductAttributeControllerTest extends TestCase +class AttributeControllerTest extends TestCase { use RefreshDatabase; @@ -14,7 +14,7 @@ class ProductAttributeControllerTest extends TestCase { $this->createTenant('acme', 'Acme', 'acme.com'); - $response = $this->postJson('/api/tenants/acme/product-attributes', [ + $response = $this->postJson('/api/tenants/acme/attributes', [ 'codigo' => 'color', 'nombre' => 'Color', 'is_required' => true, @@ -47,7 +47,7 @@ class ProductAttributeControllerTest extends TestCase ->assertJsonPath('data.options.0.label', 'Red') ->assertJsonPath('data.options.1.metadata.hex', '#0000ff'); - $this->assertDatabaseHas('productos_attributes', [ + $this->assertDatabaseHas('attribute', [ 'tenant_codigo' => 'acme', 'codigo' => 'color', 'type' => 'select', @@ -64,7 +64,7 @@ class ProductAttributeControllerTest extends TestCase { $this->createTenant('acme', 'Acme', 'acme.com'); - $response = $this->postJson('/api/tenants/acme/product-attributes', [ + $response = $this->postJson('/api/tenants/acme/attributes', [ 'codigo' => 'material', 'nombre' => 'Material', 'type' => 'string', diff --git a/tests/Feature/Catalog/ProductControllerTest.php b/tests/Feature/Catalog/ProductControllerTest.php new file mode 100644 index 0000000..9f4bd86 --- /dev/null +++ b/tests/Feature/Catalog/ProductControllerTest.php @@ -0,0 +1,388 @@ +tenant = $this->createTenant('acme', 'Acme Inc.', 'acme.com'); + + // Create attributes for variants + $this->sizeAttr = Attribute::create([ + 'tenant_codigo' => $this->tenant->codigo, + 'codigo' => 'talle', + 'nombre' => 'Talle', + 'is_required' => true, + 'type' => 'select', + ]); + + $this->colorAttr = Attribute::create([ + 'tenant_codigo' => $this->tenant->codigo, + 'codigo' => 'color', + 'nombre' => 'Color', + 'is_required' => true, + 'type' => 'select', + ]); + + $this->extraAttr = Attribute::create([ + 'tenant_codigo' => $this->tenant->codigo, + 'codigo' => 'extra', + 'nombre' => 'Extra Attribute', + 'is_required' => false, + 'type' => 'string', + ]); + } + + public function test_it_creates_product_with_variants_and_definitions_and_syncs_attributes(): void + { + $payload = [ + 'categoria_id' => 1, + 'slug' => 'remera-sport', + 'nombre' => 'Remera Sport', + 'descripcion' => 'Remera para hacer deportes', + 'precio' => 15000.00, + 'attribute_ids' => [ + $this->extraAttr->id, + ], + 'variants' => [ + [ + 'slug' => 'remera-sport-s-azul', + 'nombre' => 'Remera Sport S Azul', + 'stock' => 10, + 'precio' => 15000.00, + 'definitions' => [ + [ + 'attribute_id' => $this->sizeAttr->id, + 'value' => 'S', + ], + [ + 'attribute_id' => $this->colorAttr->id, + 'value' => 'Azul', + ], + ], + ], + [ + 'slug' => 'remera-sport-m-azul', + 'nombre' => 'Remera Sport M Azul', + 'stock' => 5, + 'precio' => 16000.00, + 'definitions' => [ + [ + 'attribute_id' => $this->sizeAttr->id, + 'value' => 'M', + ], + [ + 'attribute_id' => $this->colorAttr->id, + 'value' => 'Azul', + ], + ], + ], + ], + ]; + + $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", $payload); + + $response->assertCreated(); + + // Assert JSON structure + $response->assertJsonPath('data.nombre', 'Remera Sport'); + $response->assertJsonCount(2, 'data.variants'); + $response->assertJsonPath('data.variants.0.slug', 'remera-sport-s-azul'); + $response->assertJsonPath('data.variants.0.definitions.0.value', 'S'); + $response->assertJsonPath('data.variants.1.precio', '16000.00'); + + // Assert Database + $this->assertDatabaseHas('productos', [ + 'tenant_codigo' => $this->tenant->codigo, + 'slug' => 'remera-sport', + ]); + + $product = Product::where('slug', 'remera-sport')->firstOrFail(); + + // Assert that products_attributes has both explicit extraAttr and those from variants (sizeAttr, colorAttr) + $this->assertDatabaseHas('products_attributes', [ + 'product_id' => $product->id, + 'attribute_id' => $this->extraAttr->id, + ]); + $this->assertDatabaseHas('products_attributes', [ + 'product_id' => $product->id, + 'attribute_id' => $this->sizeAttr->id, + ]); + $this->assertDatabaseHas('products_attributes', [ + 'product_id' => $product->id, + 'attribute_id' => $this->colorAttr->id, + ]); + + $this->assertCount(2, $product->variants); + $this->assertDatabaseHas('productos_variantes', [ + 'producto_id' => $product->id, + 'slug' => 'remera-sport-s-azul', + 'stock' => 10, + 'precio' => 15000.00, + ]); + + $variantS = ProductVariant::where('slug', 'remera-sport-s-azul')->firstOrFail(); + $this->assertDatabaseHas('productos_variantes_values', [ + 'producto_variante_id' => $variantS->id, + 'attribute_id' => $this->sizeAttr->id, + 'value' => 'S', + ]); + $this->assertDatabaseHas('productos_variantes_values', [ + 'producto_variante_id' => $variantS->id, + 'attribute_id' => $this->colorAttr->id, + 'value' => 'Azul', + ]); + } + + public function test_it_updates_product_and_syncs_variants_and_attributes(): void + { + // 1. Create a product with 2 variants initially + $product = Product::create([ + 'tenant_codigo' => $this->tenant->codigo, + 'categoria_id' => 1, + 'slug' => 'pantalon-cargo', + 'nombre' => 'Pantalon Cargo', + 'precio' => 20000.00, + ]); + + $product->attributes()->sync([$this->extraAttr->id]); + + $v1 = $product->variants()->create([ + 'slug' => 'pantalon-cargo-38', + 'nombre' => 'Pantalon Cargo 38', + 'stock' => 4, + 'precio' => 20000.00, + ]); + $v1->definitions()->create([ + 'attribute_id' => $this->sizeAttr->id, + 'value' => '38', + ]); + + $v2 = $product->variants()->create([ + 'slug' => 'pantalon-cargo-40', + 'nombre' => 'Pantalon Cargo 40', + 'stock' => 8, + 'precio' => 20000.00, + ]); + $v2->definitions()->create([ + 'attribute_id' => $this->sizeAttr->id, + 'value' => '40', + ]); + + // 2. Perform update payload + // We will: + // - Update v1 (change stock/precio, keep id) + // - Delete v2 (by omitting it) + // - Create a new variant v3 + // - Update attribute_ids (remove extraAttr, add sizeAttr and colorAttr indirectly through variants) + $payload = [ + 'categoria_id' => 1, + 'slug' => 'pantalon-cargo-new-slug', + 'nombre' => 'Pantalon Cargo V2', + 'precio' => 22000.00, + 'attribute_ids' => [], // explicitly remove extraAttr + 'variants' => [ + [ + 'id' => $v1->id, + 'slug' => 'pantalon-cargo-38-updated', + 'nombre' => 'Pantalon Cargo 38 Updated', + 'stock' => 12, + 'precio' => 22000.00, + 'definitions' => [ + [ + 'attribute_id' => $this->sizeAttr->id, + 'value' => '38-updated', + ], + ], + ], + [ + 'slug' => 'pantalon-cargo-42', + 'nombre' => 'Pantalon Cargo 42', + 'stock' => 15, + 'precio' => 22000.00, + 'definitions' => [ + [ + 'attribute_id' => $this->sizeAttr->id, + 'value' => '42', + ], + ], + ], + ], + ]; + + $response = $this->putJson( + "/api/tenants/{$this->tenant->codigo}/productos/{$product->id}", + $payload + ); + + $response->assertOk(); + + // Assert updated values + $response->assertJsonPath('data.nombre', 'Pantalon Cargo V2'); + $response->assertJsonCount(2, 'data.variants'); + + // Check DB state + // extraAttr must be detached + $this->assertDatabaseMissing('products_attributes', [ + 'product_id' => $product->id, + 'attribute_id' => $this->extraAttr->id, + ]); + // sizeAttr must be attached (as it's used in variant v1 and v3 definitions) + $this->assertDatabaseHas('products_attributes', [ + 'product_id' => $product->id, + 'attribute_id' => $this->sizeAttr->id, + ]); + + // v1 must be updated + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $v1->id, + 'slug' => 'pantalon-cargo-38-updated', + 'stock' => 12, + 'precio' => 22000.00, + ]); + $this->assertDatabaseHas('productos_variantes_values', [ + 'producto_variante_id' => $v1->id, + 'attribute_id' => $this->sizeAttr->id, + 'value' => '38-updated', + ]); + + // v2 must be deleted + $this->assertDatabaseMissing('productos_variantes', [ + 'id' => $v2->id, + ]); + $this->assertDatabaseMissing('productos_variantes_values', [ + 'producto_variante_id' => $v2->id, + ]); + + // v3 must be created + $this->assertDatabaseHas('productos_variantes', [ + 'producto_id' => $product->id, + 'slug' => 'pantalon-cargo-42', + 'stock' => 15, + 'precio' => 22000.00, + ]); + } + + public function test_it_does_not_modify_variants_if_not_present_in_update_payload(): void + { + $product = Product::create([ + 'tenant_codigo' => $this->tenant->codigo, + 'categoria_id' => 1, + 'slug' => 'short-running', + 'nombre' => 'Short Running', + 'precio' => 8000.00, + ]); + + $v1 = $product->variants()->create([ + 'slug' => 'short-running-m', + 'nombre' => 'Short Running M', + 'stock' => 5, + 'precio' => 8000.00, + ]); + + $payload = [ + 'categoria_id' => 1, + 'slug' => 'short-running', + 'nombre' => 'Short Running Updated', + 'precio' => 9000.00, + ]; + + $response = $this->putJson( + "/api/tenants/{$this->tenant->codigo}/productos/{$product->id}", + $payload + ); + + $response->assertOk(); + $this->assertDatabaseHas('productos', [ + 'id' => $product->id, + 'nombre' => 'Short Running Updated', + ]); + + // Variant should still exist untouched + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $v1->id, + 'slug' => 'short-running-m', + 'stock' => 5, + ]); + } + + public function test_it_rejects_variants_with_invalid_attributes(): void + { + $payload = [ + 'categoria_id' => 1, + 'slug' => 'remera-sport', + 'nombre' => 'Remera Sport', + 'precio' => 15000.00, + 'variants' => [ + [ + 'slug' => 'remera-sport-s-azul', + 'nombre' => 'Remera Sport S Azul', + 'stock' => 10, + 'precio' => 15000.00, + 'definitions' => [ + [ + 'attribute_id' => 99999, // Non-existent ID + 'value' => 'S', + ], + ], + ], + ], + ]; + + $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", $payload); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['variants.0.definitions.0.attribute_id']); + } + + protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant + { + $hdrKey = (string) Str::uuid(); + $ftrKey = (string) Str::uuid(); + + $headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + 'key' => $hdrKey, + 'path' => 'tenants/' . $hdrKey . '.png', + 'filename' => 'logo_header.png', + 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + 'key' => $ftrKey, + 'path' => 'tenants/' . $ftrKey . '.png', + 'filename' => 'logo_footer.png', + 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + + return Tenant::create([ + 'codigo' => $codigo, + 'nombre' => $nombre, + 'dominio' => $dominio, + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'header_footer_bg_color' => '#444444', + 'header_logo_id' => $headerAttachment->id, + 'footer_logo_id' => $footerAttachment->id, + ]); + } +}