feat(category): implement category configuration management with sorting and validation

This commit is contained in:
2026-10-01 15:08:05 -03:00
parent 5ddc304435
commit 007ba9d572
11 changed files with 588 additions and 2 deletions

View File

@@ -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]);
}
}