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

This commit is contained in:
2026-10-01 15:08:05 -03:00
parent 95442d4e0b
commit 57ea0024dd
11 changed files with 588 additions and 2 deletions

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Commerce\Catalog\Controllers\AdminApp;
use App\Domains\Commerce\Catalog\Requests\AdminApp\UpdateCategoryConfigurationRequest;
use App\Domains\Commerce\Catalog\Resources\AdminApp\CategoryConfigurationResource;
use App\Domains\Commerce\Catalog\Services\CategoryConfigurationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class CategoryConfigurationController extends Controller
{
public function index(Request $request, CategoryConfigurationService $service): AnonymousResourceCollection
{
return CategoryConfigurationResource::collection(
$service->forTenant($request->user()->tenant()->firstOrFail())
);
}
public function update(UpdateCategoryConfigurationRequest $request, CategoryConfigurationService $service): AnonymousResourceCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
$service->update($tenant, $request->validated());
return CategoryConfigurationResource::collection($service->forTenant($tenant));
}
}

View File

@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'is_enabled',
'banner_id',
'banner_enabled',
'sort_order',
])]
class Category extends Model
{
@@ -41,6 +42,7 @@ class Category extends Model
'is_enabled' => 'boolean',
'banner_id' => 'integer',
'banner_enabled' => 'boolean',
'sort_order' => 'integer',
];
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Domains\Commerce\Catalog\Requests\AdminApp;
use App\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Validator as ValidationValidator;
class UpdateCategoryConfigurationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'categories' => ['present', 'array', 'max:500'],
'deleted_ids' => ['present', 'array', 'max:1000'],
'deleted_ids.*' => ['required', 'integer', 'min:1', 'distinct'],
];
}
public function withValidator(ValidationValidator $validator): void
{
$validator->after(function (ValidationValidator $validator): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
$this->validateNodes($validator, $this->input('categories'), 'categories', 0);
});
}
private function validateNodes(ValidationValidator $validator, array $nodes, string $path, int $depth): void
{
if ($depth > 10) {
$validator->errors()->add($path, 'La jerarquía de categorías es demasiado profunda.');
return;
}
foreach ($nodes as $index => $node) {
$key = "{$path}.{$index}";
if (! is_array($node)) {
$validator->errors()->add($key, 'La categoría debe ser un objeto.');
continue;
}
$nested = Validator::make($node, [
'id' => ['present', 'nullable', 'integer', 'min:1'],
'nombre' => ['required', 'string', 'max:255'],
'is_enabled' => ['required', 'boolean'],
'banner_enabled' => ['required', 'boolean'],
'banner' => ['present', 'nullable', new ImageOrBase64Rule],
'subcategories' => ['present', 'array', 'max:500'],
]);
foreach ($nested->errors()->messages() as $field => $messages) {
foreach ($messages as $message) {
$validator->errors()->add("{$key}.{$field}", $message);
}
}
if (! $nested->fails()) {
$this->validateNodes($validator, $node['subcategories'], "{$key}.subcategories", $depth + 1);
}
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Commerce\Catalog\Resources\AdminApp;
use App\Domains\Commerce\Catalog\Models\Category;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Category */
class CategoryConfigurationResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nombre' => $this->nombre,
'is_enabled' => $this->is_enabled,
'banner_enabled' => $this->banner_enabled,
'banner_key' => $this->banner?->key,
'banner_name' => $this->banner?->filename,
'banner_url' => $this->banner?->getTemporaryUrl(1440),
'subcategories' => self::collection($this->subCategories),
];
}
}

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

View File

@@ -31,6 +31,16 @@ Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, de
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
### Configuración de categorías de producto
`GET /v1/adminapp/tenant/categories` devuelve un árbol ordenado de las categorías del tenant autenticado, incluidas las ocultas. Cada nodo contiene `id`, `nombre`, `is_enabled`, `banner_enabled`, `banner_key`, `banner_name`, `banner_url` y `subcategories`. La URL administrativa se devuelve incluso si el banner está deshabilitado.
`PUT /v1/adminapp/tenant/categories` recibe `categories` y `deleted_ids`. Cada nodo enviado contiene `id` (null para altas), `nombre`, `is_enabled`, `banner_enabled`, `banner` y `subcategories`. `banner` es una clave UUID de un banner ya asociado a las categorías del tenant, una imagen base64/archivo para reemplazarlo o null para quitarlo. Deshabilitar un banner conserva el adjunto. Duplicar una categoría puede compartir su banner; los adjuntos previos no se eliminan al quitar o reemplazar esa referencia.
La posición de cada nodo en su lista determina `sort_order`; este campo no se expone en las respuestas públicas ni administrativas. La migración inicial conserva el orden alfabético existente. El bootstrap del storefront ordena por `sort_order` e ID y no muestra categorías ocultas ni sus descendientes.
El guardado es transaccional. Solo se eliminan los IDs explícitos, se rechazan IDs y banners ajenos, categorías repetidas y jerarquías inválidas. No se pueden eliminar categorías con productos (incluidos los archivados) o grupos destacados asociados. Los archivos recién subidos se limpian si falla la transacción. La respuesta devuelve el árbol persistido y los IDs asignados a las altas.
## Dependencias y reglas
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.

View File

@@ -1,11 +1,14 @@
<?php
use App\Domains\Commerce\Catalog\Controllers\AdminApp\CategoryConfigurationController;
use App\Domains\Commerce\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('categories', [CategoryConfigurationController::class, 'index']);
Route::put('categories', [CategoryConfigurationController::class, 'update']);
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
->name('adminapp.featured-groups.index');
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])