From 9b7d7281171fa97d705caffc4a97d0bf0a4cd6e9 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 29 Jun 2026 11:37:22 -0300 Subject: [PATCH] feat: implement product and attribute management services with corresponding controllers and feature tests --- .../Controllers/AttributeController.php | 43 +---- .../Catalog/Controllers/ProductController.php | 8 +- .../Controllers/ProductVariantController.php | 38 +--- app/Domains/Catalog/Models/Product.php | 180 ++++++++++++++++++ .../Catalog/Services/ProductService.php | 167 +++++++++------- .../Catalog/AttributeControllerTest.php | 17 ++ .../Feature/Catalog/ProductControllerTest.php | 49 +++++ 7 files changed, 365 insertions(+), 137 deletions(-) diff --git a/app/Domains/Catalog/Controllers/AttributeController.php b/app/Domains/Catalog/Controllers/AttributeController.php index a706cb5..9580296 100644 --- a/app/Domains/Catalog/Controllers/AttributeController.php +++ b/app/Domains/Catalog/Controllers/AttributeController.php @@ -6,6 +6,7 @@ use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Requests\StoreAttributeRequest; use App\Domains\Catalog\Requests\UpdateAttributeRequest; use App\Domains\Catalog\Resources\AttributeResource; +use App\Domains\Catalog\Services\ProductService; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; @@ -28,26 +29,7 @@ class AttributeController extends Controller 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'); - }); + $attribute = ProductService::createAttribute($tenant, $request->validated()); return AttributeResource::make($attribute)->response()->setStatusCode(201); } @@ -62,24 +44,7 @@ class AttributeController extends Controller 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'); - }); + $attribute = ProductService::updateAttribute($attribute, $request->validated()); return AttributeResource::make($attribute); } @@ -87,7 +52,7 @@ class AttributeController extends Controller public function destroy(Tenant $tenant, Attribute $attribute): Response { $attribute = $this->resolveScopedAttribute($tenant, $attribute); - $attribute->delete(); + ProductService::deleteAttribute($attribute); return response()->noContent(); } diff --git a/app/Domains/Catalog/Controllers/ProductController.php b/app/Domains/Catalog/Controllers/ProductController.php index 989b1ef..3eb1c96 100644 --- a/app/Domains/Catalog/Controllers/ProductController.php +++ b/app/Domains/Catalog/Controllers/ProductController.php @@ -24,7 +24,7 @@ class ProductController extends Controller public function store(StoreProductRequest $request, Tenant $tenant, ProductService $productService): JsonResponse { - $product = $productService->createProductWithVariants($tenant, $request->validated()); + $product = $productService->create($tenant, $request->validated()); return ProductResource::make($product)->response()->setStatusCode(201); } @@ -39,15 +39,15 @@ class ProductController extends Controller public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource { $producto = $this->resolveScopedProduct($tenant, $producto); - $producto = $productService->updateProductWithVariants($producto, $request->validated()); + $producto = $productService->update($producto, $request->validated()); return ProductResource::make($producto); } - public function destroy(Tenant $tenant, Product $producto): Response + public function destroy(Tenant $tenant, Product $producto, ProductService $productService): Response { $producto = $this->resolveScopedProduct($tenant, $producto); - $producto->delete(); + $productService->delete($producto); return response()->noContent(); } diff --git a/app/Domains/Catalog/Controllers/ProductVariantController.php b/app/Domains/Catalog/Controllers/ProductVariantController.php index 7b361a5..2d63db0 100644 --- a/app/Domains/Catalog/Controllers/ProductVariantController.php +++ b/app/Domains/Catalog/Controllers/ProductVariantController.php @@ -7,6 +7,7 @@ use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Catalog\Requests\StoreProductVariantRequest; use App\Domains\Catalog\Requests\UpdateProductVariantRequest; use App\Domains\Catalog\Resources\ProductVariantResource; +use App\Domains\Catalog\Services\ProductService; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; @@ -26,22 +27,12 @@ class ProductVariantController extends Controller return ProductVariantResource::collection($query->paginateFromRequest())->response(); } - public function store(StoreProductVariantRequest $request, Tenant $tenant): JsonResponse + public function store(StoreProductVariantRequest $request, Tenant $tenant, ProductService $productService): JsonResponse { $validated = $request->validated(); - $variant = DB::transaction(function () use ($request, $tenant, $validated): ProductVariant { - - $definitions = $validated['definitions'] ?? []; - unset($validated['definitions']); + $product = $this->resolveTenantProduct($tenant, (int) $validated['producto_id']); - $product = $this->resolveTenantProduct($tenant, (int) $validated['producto_id']); - - /** @var ProductVariant $variant */ - $variant = $product->variants()->create($validated); - $variant->definitions()->createMany($definitions); - - return $variant->load(['product', 'definitions.attribute']); - }); + $variant = $productService->createVariant($product, $validated); return ProductVariantResource::make($variant)->response()->setStatusCode(201); } @@ -53,31 +44,22 @@ class ProductVariantController extends Controller return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute'])); } - public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant): ProductVariantResource + public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant, ProductService $productService): ProductVariantResource { $productVariant = $this->resolveScopedVariant($tenant, $productVariant); + $validated = $request->validated(); - $productVariant = DB::transaction(function () use ($request, $tenant, $productVariant): ProductVariant { - $validated = $request->validated(); - $definitions = $validated['definitions'] ?? []; - unset($validated['definitions']); + $this->resolveTenantProduct($tenant, (int) $validated['producto_id']); - $this->resolveTenantProduct($tenant, (int) $validated['producto_id']); - - $productVariant->update($validated); - $productVariant->definitions()->delete(); - $productVariant->definitions()->createMany($definitions); - - return $productVariant->load(['product', 'definitions.attribute']); - }); + $productVariant = $productService->updateVariant($productVariant, $validated); return ProductVariantResource::make($productVariant); } - public function destroy(Tenant $tenant, ProductVariant $productVariant): Response + public function destroy(Tenant $tenant, ProductVariant $productVariant, ProductService $productService): Response { $productVariant = $this->resolveScopedVariant($tenant, $productVariant); - $productVariant->delete(); + $productService->deleteVariant($productVariant); return response()->noContent(); } diff --git a/app/Domains/Catalog/Models/Product.php b/app/Domains/Catalog/Models/Product.php index b7ccee8..6f6f92e 100644 --- a/app/Domains/Catalog/Models/Product.php +++ b/app/Domains/Catalog/Models/Product.php @@ -60,4 +60,184 @@ class Product extends Model 'attribute_id' )->withTimestamps(); } + + /** + * Create a variant for this product with its definitions. + * + * @param array $data + */ + public function createVariant(array $data): ProductVariant + { + $definitions = $data['definitions'] ?? []; + $this->validateVariantDefinitions($definitions); + + unset($data['definitions']); + + /** @var ProductVariant $variant */ + $variant = $this->variants()->create($data); + $variant->definitions()->createMany($definitions); + + return $variant; + } + + /** + * Create multiple variants for this product. + * + * @param array> $variantsData + * @return \Illuminate\Database\Eloquent\Collection + */ + public function createVariants(array $variantsData): \Illuminate\Database\Eloquent\Collection + { + $variants = new \Illuminate\Database\Eloquent\Collection(); + + foreach ($variantsData as $variantData) { + $variants->push($this->createVariant($variantData)); + } + + return $variants; + } + + /** + * Update a product variant. + * + * @param array $data + */ + public function updateVariant(ProductVariant $variant, array $data): ProductVariant + { + $definitions = $data['definitions'] ?? []; + $this->validateVariantDefinitions($definitions); + + unset($data['definitions']); + unset($data['producto_id']); + + $variant->update($data); + $variant->definitions()->delete(); + $variant->definitions()->createMany($definitions); + + return $variant; + } + + /** + * Delete a product variant. + */ + public function deleteVariant(ProductVariant $variant): void + { + $variant->definitions()->delete(); + $variant->delete(); + } + + /** + * Validate variant definitions options against Attribute configuration. + * + * @param array $definitions + * @throws \InvalidArgumentException + */ + protected function validateVariantDefinitions(array $definitions): void + { + foreach ($definitions as $definition) { + $attributeId = $definition['attribute_id'] ?? null; + if (!$attributeId) { + continue; + } + + $attribute = Attribute::find($attributeId); + if (!$attribute) { + throw new \InvalidArgumentException("Attribute with ID {$attributeId} not found."); + } + + 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)) { + 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) { + $allowedValues = $attribute->options()->pluck('value')->toArray(); + $val = $definition['value'] ?? null; + if ($val !== null) { + $values = []; + if (is_array($val)) { + $values = $val; + } else { + $decoded = json_decode($val, true); + if (is_array($decoded)) { + $values = $decoded; + } else { + $values = array_map('trim', explode(',', $val)); + } + } + foreach ($values as $v) { + if (!in_array($v, $allowedValues, true)) { + throw new \InvalidArgumentException("The value '{$v}' is not a valid option for the multiselect attribute '{$attribute->nombre}'."); + } + } + } + } + } + } + + /** + * Create an attribute. + * + * @param array $data + */ + public static function createAttribute(Tenant $tenant, array $data): Attribute + { + $options = $data['options'] ?? []; + unset($data['options']); + + $type = \App\Domains\Shared\Enums\FieldType::from((string) $data['type']); + if (! $type->supportsOptions() && ! empty($options)) { + throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.'); + } + + if (! $type->supportsOptions()) { + $data['metadata_schema'] = null; + } + + /** @var Attribute $attribute */ + $attribute = Attribute::query()->create([ + ...$data, + 'tenant_codigo' => $tenant->codigo, + ]); + $attribute->options()->createMany($options); + + return $attribute->load('options'); + } + + /** + * Update an attribute. + * + * @param array $data + */ + public static function updateAttribute(Attribute $attribute, array $data): Attribute + { + $options = $data['options'] ?? []; + unset($data['options']); + + $typeStr = $data['type'] ?? $attribute->type->value; + $type = \App\Domains\Shared\Enums\FieldType::from((string) $typeStr); + if (! $type->supportsOptions() && ! empty($options)) { + throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.'); + } + + if (! $type->supportsOptions()) { + $data['metadata_schema'] = null; + } + + $attribute->update($data); + $attribute->options()->delete(); + $attribute->options()->createMany($options); + + return $attribute->load('options'); + } + + /** + * Delete an attribute. + */ + public static function deleteAttribute(Attribute $attribute): void + { + $attribute->options()->delete(); + $attribute->delete(); + } } diff --git a/app/Domains/Catalog/Services/ProductService.php b/app/Domains/Catalog/Services/ProductService.php index eb03bed..44278a6 100644 --- a/app/Domains/Catalog/Services/ProductService.php +++ b/app/Domains/Catalog/Services/ProductService.php @@ -2,23 +2,22 @@ namespace App\Domains\Catalog\Services; +use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Product; +use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Tenant\Models\Tenant; use Illuminate\Support\Facades\DB; class ProductService { /** - * Create a product along with its variants and variant definitions. + * Create a product. * * @param array $data */ - public function createProductWithVariants(Tenant $tenant, array $data): Product + public function create(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']); @@ -28,88 +27,124 @@ class ProductService 'tenant_codigo' => $tenant->codigo, ]); - foreach ($variantsData as $variantData) { - $definitions = $variantData['definitions'] ?? []; - unset($variantData['definitions']); + $product->attributes()->sync($attributeIds); - $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']); + return $product->load('attributes'); }); } /** - * Update a product along with its variants and variant definitions. + * Update a product. * * @param array $data */ - public function updateProductWithVariants(Product $product, array $data): Product + public function update(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']); + // Ensure tenant_codigo cannot be updated/changed + unset($data['tenant_codigo']); + $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) { + $product->attributes()->sync($attributeIds); } - if ($hasAttributeIds || $hasVariants) { - $explicitIds = $hasAttributeIds ? $attributeIds : $product->attributes()->pluck('attribute_id')->toArray(); + return $product->load('attributes'); + }); + } - $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); + /** + * Delete a product. + */ + public function delete(Product $product): void + { + DB::transaction(function () use ($product) { + foreach ($product->variants as $variant) { + $product->deleteVariant($variant); } + $product->attributes()->detach(); + $product->delete(); + }); + } - return $product->load(['variants.definitions.attribute', 'attributes']); + /** + * Create a product variant. + * + * @param array $data + */ + public function createVariant(Product $product, array $data): ProductVariant + { + return DB::transaction(function () use ($product, $data) { + $variant = $product->createVariant($data); + + return $variant->load(['product', 'definitions.attribute']); + }); + } + + /** + * Update a product variant. + * + * @param array $data + */ + public function updateVariant(ProductVariant $variant, array $data): ProductVariant + { + return DB::transaction(function () use ($variant, $data) { + /** @var Product $product */ + $product = $variant->product; + $updatedVariant = $product->updateVariant($variant, $data); + + return $updatedVariant->load(['product', 'definitions.attribute']); + }); + } + + /** + * Delete a product variant. + */ + public function deleteVariant(ProductVariant $variant): void + { + DB::transaction(function () use ($variant) { + /** @var Product $product */ + $product = $variant->product; + $product->deleteVariant($variant); + }); + } + + /** + * Create an attribute. + * + * @param array $data + */ + public static function createAttribute(Tenant $tenant, array $data): Attribute + { + return DB::transaction(function () use ($tenant, $data) { + return Product::createAttribute($tenant, $data); + }); + } + + /** + * Update an attribute. + * + * @param array $data + */ + public static function updateAttribute(Attribute $attribute, array $data): Attribute + { + return DB::transaction(function () use ($attribute, $data) { + return Product::updateAttribute($attribute, $data); + }); + } + + /** + * Delete an attribute. + */ + public static function deleteAttribute(Attribute $attribute): void + { + DB::transaction(function () use ($attribute) { + Product::deleteAttribute($attribute); }); } } - diff --git a/tests/Feature/Catalog/AttributeControllerTest.php b/tests/Feature/Catalog/AttributeControllerTest.php index eebbdce..ca3f36a 100644 --- a/tests/Feature/Catalog/AttributeControllerTest.php +++ b/tests/Feature/Catalog/AttributeControllerTest.php @@ -78,6 +78,23 @@ class AttributeControllerTest extends TestCase ->assertJsonValidationErrors(['options']); } + public function test_it_throws_exception_when_creating_non_select_attribute_with_options_directly_on_model(): void + { + $tenant = $this->createTenant('acme2', 'Acme 2', 'acme2.com'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Options are only allowed for select and multiselect attributes.'); + + \App\Domains\Catalog\Models\Product::createAttribute($tenant, [ + 'codigo' => 'material2', + 'nombre' => 'Material 2', + 'type' => 'string', + 'options' => [ + ['label' => 'Cotton', 'value' => 'cotton'], + ], + ]); + } + protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant { $hdrKey = (string) \Illuminate\Support\Str::uuid(); diff --git a/tests/Feature/Catalog/ProductControllerTest.php b/tests/Feature/Catalog/ProductControllerTest.php index 2bab839..0573476 100644 --- a/tests/Feature/Catalog/ProductControllerTest.php +++ b/tests/Feature/Catalog/ProductControllerTest.php @@ -33,6 +33,10 @@ class ProductControllerTest extends TestCase 'is_required' => true, 'type' => 'select', ]); + $this->sizeAttr->options()->createMany([ + ['value' => 'S', 'label' => 'S'], + ['value' => '38', 'label' => '38'], + ]); $this->colorAttr = Attribute::create([ 'tenant_codigo' => $this->tenant->codigo, @@ -41,6 +45,10 @@ class ProductControllerTest extends TestCase 'is_required' => true, 'type' => 'select', ]); + $this->colorAttr->options()->createMany([ + ['value' => 'Azul', 'label' => 'Azul'], + ['value' => 'Rojo', 'label' => 'Rojo'], + ]); $this->extraAttr = Attribute::create([ 'tenant_codigo' => $this->tenant->codigo, @@ -340,6 +348,47 @@ class ProductControllerTest extends TestCase $response->assertJsonValidationErrors(['definitions.0.attribute_id']); } + public function test_it_throws_exception_when_variant_value_does_not_belong_to_attribute_options(): void + { + // 1. Create a select attribute with options + $selectAttr = Product::createAttribute($this->tenant, [ + 'codigo' => 'tamanho', + 'nombre' => 'Tamanho', + 'type' => 'select', + 'options' => [ + ['value' => 'P', 'label' => 'Piqueno'], + ['value' => 'M', 'label' => 'Medio'], + ] + ]); + + // 2. Create product and associate attribute + $product = Product::create([ + 'tenant_codigo' => $this->tenant->codigo, + 'categoria_id' => 1, + 'slug' => 'test-prod-validation', + 'nombre' => 'Test Prod Validation', + 'precio' => 100.00, + ]); + $product->attributes()->sync([$selectAttr->id]); + + // 3. Expect exception when creating variant with invalid value 'G' + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("The value 'G' is not a valid option for the select attribute 'Tamanho'."); + + $product->createVariant([ + 'slug' => 'test-prod-validation-g', + 'nombre' => 'Test Prod Validation G', + 'stock' => 5, + 'precio' => 100.00, + 'definitions' => [ + [ + 'attribute_id' => $selectAttr->id, + 'value' => 'G', // Invalid value + ] + ] + ]); + } + protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant { $hdrKey = (string) Str::uuid();