diff --git a/app/Domains/Catalog/Controllers/CatalogController.php b/app/Domains/Catalog/Controllers/CatalogController.php index 2684d1b..60ee8ea 100644 --- a/app/Domains/Catalog/Controllers/CatalogController.php +++ b/app/Domains/Catalog/Controllers/CatalogController.php @@ -4,9 +4,11 @@ namespace App\Domains\Catalog\Controllers; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\FeaturedItem; use App\Domains\Catalog\Requests\CatalogItemDetailRequest; +use App\Domains\Catalog\Requests\CategoryPageRequest; use App\Domains\Catalog\Requests\FeaturedGroupPageRequest; use App\Domains\Catalog\Requests\SearchCatalogItemsRequest; use App\Domains\Catalog\Requests\StoreCatalogItemRequest; @@ -57,6 +59,32 @@ class CatalogController extends Controller ); } + public function category( + CategoryPageRequest $request, + Tenant $tenant, + Category $category, + CatalogService $catalogService, + ): AnonymousResourceCollection { + abort_unless($category->tenant_code === $tenant->codigo, 404); + + return CatalogSearchItemResource::collection( + $catalogService->categoryItems( + $tenant, + $category, + $tenant->search_items_per_page, + (int) $request->validated('page', 1), + ) + )->additional([ + 'category' => [ + 'id' => $category->id, + 'nombre' => $category->nombre, + 'categoria_id' => $category->categoria_id, + ], + 'layout' => $tenant->search_product_layout->value, + 'group_layout' => $tenant->search_group_layout->value, + ]); + } + public function featuredGroupItems( FeaturedGroupPageRequest $request, Tenant $tenant, diff --git a/app/Domains/Catalog/Requests/CategoryPageRequest.php b/app/Domains/Catalog/Requests/CategoryPageRequest.php new file mode 100644 index 0000000..5c12593 --- /dev/null +++ b/app/Domains/Catalog/Requests/CategoryPageRequest.php @@ -0,0 +1,21 @@ +> */ + public function rules(): array + { + return [ + 'page' => ['sometimes', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index 0736f03..0745324 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -7,6 +7,7 @@ use App\Domains\Attachable\Services\AttachmentService; use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\Variant; @@ -210,6 +211,29 @@ class CatalogService ])); } + /** @return LengthAwarePaginator */ + public function categoryItems( + Tenant $tenant, + Category $category, + int $perPage, + int $page, + ): LengthAwarePaginator { + return CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('category_id', $category->id) + ->with([ + 'attachments', + 'inventory', + 'variants.inventory', + 'variants.attachments', + 'variants.definitions.itemAttribute.attribute', + 'bundleComponents.catalogItem', + 'bundleComponents.variant.catalogItem', + ]) + ->orderBy('nombre') + ->paginate(perPage: $perPage, pageName: 'page', page: $page); + } + public function delete(CatalogItem $catalogItem): void { DB::transaction(function () use ($catalogItem): void { diff --git a/app/Domains/Catalog/routes/api.php b/app/Domains/Catalog/routes/api.php index 8c21316..b09e8c7 100644 --- a/app/Domains/Catalog/routes/api.php +++ b/app/Domains/Catalog/routes/api.php @@ -9,6 +9,8 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void { ->name('catalog.featured-groups.items.index'); Route::get('catalog-items', [CatalogController::class, 'search']) ->name('catalog-items.index'); + Route::get('categories/{category}', [CatalogController::class, 'category']) + ->name('categories.show'); Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']); Route::post('catalog-items', [CatalogController::class, 'store']); }); diff --git a/app/Domains/Tenant/Controllers/BootstrapTenantController.php b/app/Domains/Tenant/Controllers/BootstrapTenantController.php index e8d899c..ed1d97e 100644 --- a/app/Domains/Tenant/Controllers/BootstrapTenantController.php +++ b/app/Domains/Tenant/Controllers/BootstrapTenantController.php @@ -16,7 +16,14 @@ class BootstrapTenantController extends Controller return TenantResource::make( Tenant::query() - ->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'menues', 'socialMedia']) + ->with([ + 'headerLogo', + 'footerLogo', + 'mainCarouselImages', + 'menues', + 'socialMedia', + 'categories' => fn ($query) => $query->orderBy('nombre'), + ]) ->where('dominio', $dominio) ->firstOrFail() ); diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index f1e5f07..0b183ba 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -6,6 +6,7 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Category; use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\TenantMenu; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -109,6 +110,14 @@ class Tenant extends Model return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo'); } + /** + * @return HasMany + */ + public function categories(): HasMany + { + return $this->hasMany(Category::class, 'tenant_code', 'codigo'); + } + /** * @return BelongsToMany */ diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 7592b37..989d169 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -2,6 +2,7 @@ namespace App\Domains\Tenant\Resources; +use App\Domains\Catalog\Models\Category; use App\Domains\Menu\Models\Menu; use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; @@ -64,9 +65,43 @@ class TenantResource extends JsonResource 'menues', fn () => $this->menuTree($this->menues) ), + 'categories' => $this->whenLoaded( + 'categories', + fn () => $this->categoryTree($this->categories) + ), ]; } + /** + * @param Collection $categories + * @return Collection> + */ + private function categoryTree(Collection $categories): Collection + { + $categoryIds = $categories->pluck('id')->flip(); + $childrenByParent = $categories + ->filter(fn (Category $category) => $category->categoria_id !== null + && $categoryIds->has($category->categoria_id)) + ->groupBy('categoria_id'); + + $formatCategory = function (Category $category) use (&$formatCategory, $childrenByParent): array { + return [ + 'id' => $category->id, + 'nombre' => $category->nombre, + 'subcategories' => $childrenByParent + ->get($category->id, collect()) + ->map($formatCategory) + ->values(), + ]; + }; + + return $categories + ->filter(fn (Category $category) => $category->categoria_id === null + || ! $categoryIds->has($category->categoria_id)) + ->map($formatCategory) + ->values(); + } + /** * @param Collection $menus * @return Collection> diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php index 4ad5635..c6f51ba 100644 --- a/database/seeders/CategorySeeder.php +++ b/database/seeders/CategorySeeder.php @@ -3,6 +3,7 @@ namespace Database\Seeders; use App\Domains\Catalog\Models\Category; +use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Seeder; class CategorySeeder extends Seeder @@ -12,6 +13,12 @@ class CategorySeeder extends Seeder */ public function run(): void { + $tenant = Tenant::query()->where('codigo', 'sonder')->first(); + + if (! $tenant instanceof Tenant) { + return; + } + $categories = [ 'Remeras', 'Pantalones', @@ -23,8 +30,21 @@ class CategorySeeder extends Seeder foreach ($categories as $nombre) { Category::firstOrCreate([ 'nombre' => $nombre, - 'tenant_code' => null, + 'tenant_code' => $tenant->codigo, ]); } + + $indumentaria = Category::updateOrCreate( + [ + 'nombre' => 'Indumentaria', + 'tenant_code' => $tenant->codigo, + ], + ['categoria_id' => null], + ); + + Category::query() + ->where('nombre', 'Remeras') + ->where('tenant_code', $tenant->codigo) + ->update(['categoria_id' => $indumentaria->id]); } } diff --git a/database/seeders/FiestaFutbolInfantilProductSeeder.php b/database/seeders/FiestaFutbolInfantilProductSeeder.php index 240615f..b27fed6 100644 --- a/database/seeders/FiestaFutbolInfantilProductSeeder.php +++ b/database/seeders/FiestaFutbolInfantilProductSeeder.php @@ -30,11 +30,15 @@ class FiestaFutbolInfantilProductSeeder extends Seeder $ticketCategory = Category::query()->firstOrCreate([ 'nombre' => 'Entradas', - 'tenant_code' => null, + 'tenant_code' => $tenant->codigo, ]); $foodCategory = Category::query()->firstOrCreate([ 'nombre' => 'Gastronomía', - 'tenant_code' => null, + 'tenant_code' => $tenant->codigo, + ]); + $parkingCategory = Category::query()->firstOrCreate([ + 'nombre' => 'Estacionamiento', + 'tenant_code' => $tenant->codigo, ]); $dates = ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12']; @@ -69,8 +73,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder ['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'category_id' => $foodCategory->id], ['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'category_id' => $foodCategory->id], ['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'category_id' => $foodCategory->id], - ['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $ticketCategory->id], - ['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $ticketCategory->id], + ['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $parkingCategory->id], + ['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $parkingCategory->id], ]; $createdItems = []; diff --git a/database/seeders/ProductCatalogFromImagesSeeder.php b/database/seeders/ProductCatalogFromImagesSeeder.php index 8df4ef8..b3585c7 100644 --- a/database/seeders/ProductCatalogFromImagesSeeder.php +++ b/database/seeders/ProductCatalogFromImagesSeeder.php @@ -236,11 +236,11 @@ class ProductCatalogFromImagesSeeder extends Seeder $category = Category::query() ->where('nombre', $metadata['category_name']) - ->whereNull('tenant_code') + ->where('tenant_code', $tenant->codigo) ->first(); if (! $category instanceof Category) { - throw new RuntimeException("Category '{$metadata['category_name']}' not found."); + throw new RuntimeException("Category '{$metadata['category_name']}' not found for tenant '{$tenant->codigo}'."); } $attributeCodes = Attribute::query() diff --git a/tests/Feature/Catalog/CategoryDetailTest.php b/tests/Feature/Catalog/CategoryDetailTest.php new file mode 100644 index 0000000..d7d6784 --- /dev/null +++ b/tests/Feature/Catalog/CategoryDetailTest.php @@ -0,0 +1,123 @@ +createTenant('category-detail'); + $tenant->update([ + 'search_product_layout' => ProductLayout::Row->value, + 'search_group_layout' => GroupLayout::SimpleVertical->value, + 'search_items_per_page' => 2, + ]); + $category = $this->createCategory($tenant, 'Remeras'); + $otherCategory = $this->createCategory($tenant, 'Pantalones'); + + $this->createCatalogItem($tenant, $category, 'Remera C'); + $firstItem = $this->createCatalogItem($tenant, $category, 'Remera A'); + $secondItem = $this->createCatalogItem($tenant, $category, 'Remera B'); + $this->createCatalogItem($tenant, $otherCategory, 'Pantalón'); + + $this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}") + ->assertOk() + ->assertJsonPath('category.id', $category->id) + ->assertJsonPath('category.nombre', 'Remeras') + ->assertJsonPath('category.categoria_id', null) + ->assertJsonPath('layout', ProductLayout::Row->value) + ->assertJsonPath('group_layout', GroupLayout::SimpleVertical->value) + ->assertJsonPath('meta.current_page', 1) + ->assertJsonPath('meta.per_page', 2) + ->assertJsonPath('meta.total', 3) + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $firstItem->id) + ->assertJsonPath('data.1.id', $secondItem->id) + ->assertJsonMissing(['nombre' => 'Pantalón']); + } + + public function test_it_returns_an_empty_paginated_response_for_a_category_without_products(): void + { + $tenant = $this->createTenant('empty-category'); + $category = $this->createCategory($tenant, 'Sin productos'); + + $this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}") + ->assertOk() + ->assertJsonPath('category.id', $category->id) + ->assertJsonPath('meta.total', 0) + ->assertJsonPath('meta.current_page', 1) + ->assertJsonCount(0, 'data'); + } + + public function test_it_rejects_categories_from_another_tenant(): void + { + $tenant = $this->createTenant('category-owner'); + $otherTenant = $this->createTenant('category-foreign'); + $foreignCategory = $this->createCategory($otherTenant, 'Ajena'); + + $this->getJson("/api/tenants/{$tenant->codigo}/categories/{$foreignCategory->id}") + ->assertNotFound(); + } + + public function test_it_validates_the_page(): void + { + $tenant = $this->createTenant('category-page'); + $category = $this->createCategory($tenant, 'Remeras'); + + $this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}?page=0") + ->assertUnprocessable() + ->assertJsonValidationErrors('page'); + } + + private function createTenant(string $code): Tenant + { + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.local", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + ]); + } + + private function createCategory(Tenant $tenant, string $name): Category + { + return Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => $name, + ]); + } + + private function createCatalogItem( + Tenant $tenant, + Category $category, + string $name, + ): CatalogItem { + $inventory = Inventory::query()->create(['real_stock' => 10]); + + return CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'category_id' => $category->id, + 'inventory_id' => $inventory->id, + 'slug' => str($name)->slug()->toString(), + 'nombre' => $name, + 'descripcion' => "{$name} description", + 'precio' => 100, + ]); + } +} diff --git a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php index 9acf98d..90b7429 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -9,6 +9,7 @@ use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\Inventory; use App\Domains\Tenant\Models\Tenant; @@ -155,6 +156,24 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase ); $this->assertSame(9, CatalogItem::query()->where('tenant_code', $tenant->codigo)->count()); $this->assertSame(10, Inventory::query()->count()); + $this->assertSame( + ['Entradas', 'Estacionamiento', 'Gastronomía'], + Category::query() + ->where('tenant_code', $tenant->codigo) + ->orderBy('nombre') + ->pluck('nombre') + ->all(), + ); + $this->assertSame( + 'Estacionamiento', + CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('slug', 'estacionamiento-auto') + ->with('category') + ->sole() + ->category + ->nombre, + ); $featuredGroups = FeaturedGroup::query() ->where('tenant_code', $tenant->codigo) diff --git a/tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php b/tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php index 8fef115..f04915b 100644 --- a/tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php +++ b/tests/Feature/Seeders/ProductCatalogFromImagesSeederTest.php @@ -7,6 +7,7 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Tenant\Models\Tenant; use Database\Seeders\AttributeSeeder; @@ -91,5 +92,19 @@ class ProductCatalogFromImagesSeederTest extends TestCase ->pluck('catalog_item_id') ->diff($catalogItemIds), ); + $this->assertSame( + ['Accesorios', 'Buzos', 'Pantalones', 'Remeras', 'Zapatillas'], + Category::query() + ->where('tenant_code', $tenant->codigo) + ->orderBy('nombre') + ->pluck('nombre') + ->all(), + ); + $this->assertFalse( + CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->whereHas('category', fn ($query) => $query->where('tenant_code', '!=', $tenant->codigo)) + ->exists(), + ); } } diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index b6ec956..8e74b82 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Tenant; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Catalog\Models\Category; use App\Domains\Menu\Models\Menu; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -95,6 +96,45 @@ class BootstrapTenantControllerTest extends TestCase ->assertJsonPath('data.dominio', 'acme.com'); } + public function test_it_returns_only_the_tenant_categories_in_the_bootstrap(): void + { + $tenant = $this->createTenant(); + $otherTenant = $this->createTenant([ + 'codigo' => 'globex', + 'nombre' => 'Globex', + 'dominio' => 'globex.com', + ]); + + $parent = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Remeras', + ]); + Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'categoria_id' => $parent->id, + 'nombre' => 'Manga corta', + ]); + Category::query()->create([ + 'tenant_code' => $otherTenant->codigo, + 'nombre' => 'Otra categoría', + ]); + Category::query()->create([ + 'tenant_code' => null, + 'nombre' => 'Global', + ]); + + $this->getJson('/api/tenants/bootstrap/acme.com') + ->assertOk() + ->assertJsonCount(1, 'data.categories') + ->assertJsonPath('data.categories.0.nombre', 'Remeras') + ->assertJsonCount(1, 'data.categories.0.subcategories') + ->assertJsonPath('data.categories.0.subcategories.0.nombre', 'Manga corta') + ->assertJsonPath('data.categories.0.subcategories.0.subcategories', []) + ->assertJsonMissingPath('data.categories.0.categoria_id') + ->assertJsonMissing(['nombre' => 'Otra categoría']) + ->assertJsonMissing(['nombre' => 'Global']); + } + public function test_it_returns_not_found_when_the_domain_does_not_exist(): void { $response = $this->getJson('/api/tenants/bootstrap/missing.example');