feat: implement product and attribute management services with corresponding controllers and feature tests
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -60,4 +60,184 @@ class Product extends Model
|
||||
'attribute_id'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a variant for this product with its definitions.
|
||||
*
|
||||
* @param array<string, mixed> $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<int, array<string, mixed>> $variantsData
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, ProductVariant>
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $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<string, mixed> $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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user