feat(category): implement category configuration management with sorting and validation
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Services;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\Category;
|
||||
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Shared\Attachable\Enums\AttachmentType;
|
||||
use App\Shared\Attachable\Models\Attachment;
|
||||
use App\Shared\Attachable\Services\AttachmentService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class CategoryConfigurationService
|
||||
{
|
||||
public function __construct(private readonly AttachmentService $attachments) {}
|
||||
|
||||
/** @return Collection<int, Category> */
|
||||
public function forTenant(Tenant $tenant): Collection
|
||||
{
|
||||
$all = $tenant->categories()->with('banner')->orderBy('sort_order')->orderBy('id')->get();
|
||||
$children = $all->groupBy('categoria_id');
|
||||
foreach ($all as $category) {
|
||||
$category->setRelation('subCategories', $children->get($category->id, collect()));
|
||||
}
|
||||
|
||||
return $all->filter(fn (Category $category) => $category->categoria_id === null)->values();
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function update(Tenant $tenant, array $data): void
|
||||
{
|
||||
$created = [];
|
||||
try {
|
||||
DB::transaction(function () use ($tenant, $data, &$created): void {
|
||||
// Serialize saves for this tenant, including creation of new categories.
|
||||
Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail();
|
||||
$existing = $tenant->categories()->with('banner')->lockForUpdate()->get()->keyBy('id');
|
||||
$deleted = array_map('intval', $data['deleted_ids']);
|
||||
$seen = [];
|
||||
$allowedBannerKeys = $existing->pluck('banner.key')->filter()->all();
|
||||
$this->validateNodes($data['categories'], $existing, $deleted, $seen, $allowedBannerKeys);
|
||||
|
||||
foreach ($deleted as $id) {
|
||||
$category = $existing->get($id);
|
||||
if ($category === null) {
|
||||
$this->invalid('deleted_ids', 'Una categoría a eliminar no pertenece a esta tienda.');
|
||||
}
|
||||
if ($category->catalogItems()->withTrashed()->exists()
|
||||
|| FeaturedGroup::query()->where('category_id', $id)->exists()) {
|
||||
$this->invalid('deleted_ids', "No se puede eliminar «{$category->nombre}» porque tiene productos o grupos destacados asociados. Podés ocultarla.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->saveNodes($tenant, $data['categories'], null, $existing, $created);
|
||||
$remaining = $tenant->categories()->whereNotIn('id', $deleted)->get();
|
||||
$parents = $remaining->pluck('categoria_id', 'id');
|
||||
foreach ($remaining as $category) {
|
||||
$visited = [$category->id => true];
|
||||
$parentId = $category->categoria_id;
|
||||
while ($parentId !== null) {
|
||||
if (isset($visited[$parentId]) || ! $parents->has($parentId)) {
|
||||
$this->invalid('categories', 'La jerarquía contiene un ciclo o una categoría padre eliminada.');
|
||||
}
|
||||
$visited[$parentId] = true;
|
||||
$parentId = $parents->get($parentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Detach only explicitly deleted nodes before removing parents and children together.
|
||||
$tenant->categories()->whereIn('id', $deleted)->update(['categoria_id' => null]);
|
||||
$tenant->categories()->whereIn('id', $deleted)->delete();
|
||||
});
|
||||
} catch (Throwable $error) {
|
||||
foreach ($created as $attachment) {
|
||||
Storage::disk('s3')->delete($attachment->path);
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
|
||||
private function validateNodes(array $nodes, Collection $existing, array $deleted, array &$seen, array $bannerKeys, string $path = 'categories'): void
|
||||
{
|
||||
foreach ($nodes as $index => $node) {
|
||||
$field = "{$path}.{$index}";
|
||||
if ($node['id'] !== null) {
|
||||
$id = (int) $node['id'];
|
||||
if (! $existing->has($id) || isset($seen[$id]) || in_array($id, $deleted, true)) {
|
||||
$this->invalid("{$field}.id", 'La categoría es ajena a la tienda, está repetida o fue marcada para eliminar.');
|
||||
}
|
||||
$seen[$id] = true;
|
||||
}
|
||||
if (trim($node['nombre']) === '') {
|
||||
$this->invalid("{$field}.nombre", 'Completá el nombre de la categoría.');
|
||||
}
|
||||
if (is_string($node['banner']) && Str::isUuid($node['banner'])
|
||||
&& ! in_array($node['banner'], $bannerKeys, true)) {
|
||||
$this->invalid("{$field}.banner", 'El banner no pertenece a las categorías de esta tienda.');
|
||||
}
|
||||
$this->validateNodes($node['subcategories'], $existing, $deleted, $seen, $bannerKeys, "{$field}.subcategories");
|
||||
}
|
||||
}
|
||||
|
||||
private function saveNodes(Tenant $tenant, array $nodes, ?int $parentId, Collection $existing, array &$created): void
|
||||
{
|
||||
foreach (array_values($nodes) as $order => $node) {
|
||||
$category = $node['id'] === null ? new Category : $existing->get((int) $node['id']);
|
||||
$banner = null;
|
||||
if ($node['banner'] !== null) {
|
||||
if (is_string($node['banner']) && Str::isUuid($node['banner'])) {
|
||||
$banner = Attachment::query()->where('key', $node['banner'])->firstOrFail();
|
||||
if ($banner->type !== AttachmentType::Image) {
|
||||
$this->invalid('categories', 'El banner debe ser una imagen.');
|
||||
}
|
||||
} else {
|
||||
$banner = $this->attachments->store($node['banner'], "tenants/{$tenant->codigo}/categories");
|
||||
$created[] = $banner;
|
||||
}
|
||||
}
|
||||
$category->fill([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'categoria_id' => $parentId,
|
||||
'nombre' => trim($node['nombre']),
|
||||
'is_enabled' => $node['is_enabled'],
|
||||
'banner_id' => $banner?->id,
|
||||
'banner_enabled' => $node['banner_enabled'],
|
||||
'sort_order' => $order,
|
||||
])->save();
|
||||
$this->saveNodes($tenant, $node['subcategories'], $category->id, $existing, $created);
|
||||
}
|
||||
}
|
||||
|
||||
private function invalid(string $field, string $message): never
|
||||
{
|
||||
throw ValidationException::withMessages([$field => $message]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user