feat(categories): add category retrieval and pagination for tenant-specific products

This commit is contained in:
2026-07-28 12:20:17 -03:00
parent ff9d7728e8
commit 8dc80411d7
14 changed files with 355 additions and 8 deletions

View File

@@ -4,9 +4,11 @@ namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\FeaturedItem; use App\Domains\Catalog\Models\FeaturedItem;
use App\Domains\Catalog\Requests\CatalogItemDetailRequest; use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
use App\Domains\Catalog\Requests\CategoryPageRequest;
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest; use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest; use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
use App\Domains\Catalog\Requests\StoreCatalogItemRequest; 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( public function featuredGroupItems(
FeaturedGroupPageRequest $request, FeaturedGroupPageRequest $request,
Tenant $tenant, Tenant $tenant,

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CategoryPageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'page' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -7,6 +7,7 @@ use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
@@ -210,6 +211,29 @@ class CatalogService
])); ]));
} }
/** @return LengthAwarePaginator<CatalogItem> */
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 public function delete(CatalogItem $catalogItem): void
{ {
DB::transaction(function () use ($catalogItem): void { DB::transaction(function () use ($catalogItem): void {

View File

@@ -9,6 +9,8 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
->name('catalog.featured-groups.items.index'); ->name('catalog.featured-groups.items.index');
Route::get('catalog-items', [CatalogController::class, 'search']) Route::get('catalog-items', [CatalogController::class, 'search'])
->name('catalog-items.index'); ->name('catalog-items.index');
Route::get('categories/{category}', [CatalogController::class, 'category'])
->name('categories.show');
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']); Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
Route::post('catalog-items', [CatalogController::class, 'store']); Route::post('catalog-items', [CatalogController::class, 'store']);
}); });

View File

@@ -16,7 +16,14 @@ class BootstrapTenantController extends Controller
return TenantResource::make( return TenantResource::make(
Tenant::query() Tenant::query()
->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'menues', 'socialMedia']) ->with([
'headerLogo',
'footerLogo',
'mainCarouselImages',
'menues',
'socialMedia',
'categories' => fn ($query) => $query->orderBy('nombre'),
])
->where('dominio', $dominio) ->where('dominio', $dominio)
->firstOrFail() ->firstOrFail()
); );

View File

@@ -6,6 +6,7 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu; use App\Domains\Menu\Models\TenantMenu;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -109,6 +110,14 @@ class Tenant extends Model
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo'); return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
} }
/**
* @return HasMany<Category, $this>
*/
public function categories(): HasMany
{
return $this->hasMany(Category::class, 'tenant_code', 'codigo');
}
/** /**
* @return BelongsToMany<SocialMedia, $this> * @return BelongsToMany<SocialMedia, $this>
*/ */

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Tenant\Resources; namespace App\Domains\Tenant\Resources;
use App\Domains\Catalog\Models\Category;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -64,9 +65,43 @@ class TenantResource extends JsonResource
'menues', 'menues',
fn () => $this->menuTree($this->menues) fn () => $this->menuTree($this->menues)
), ),
'categories' => $this->whenLoaded(
'categories',
fn () => $this->categoryTree($this->categories)
),
]; ];
} }
/**
* @param Collection<int, Category> $categories
* @return Collection<int, array<string, mixed>>
*/
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<int, Menu> $menus * @param Collection<int, Menu> $menus
* @return Collection<int, array<string, mixed>> * @return Collection<int, array<string, mixed>>

View File

@@ -3,6 +3,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class CategorySeeder extends Seeder class CategorySeeder extends Seeder
@@ -12,6 +13,12 @@ class CategorySeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
$tenant = Tenant::query()->where('codigo', 'sonder')->first();
if (! $tenant instanceof Tenant) {
return;
}
$categories = [ $categories = [
'Remeras', 'Remeras',
'Pantalones', 'Pantalones',
@@ -23,8 +30,21 @@ class CategorySeeder extends Seeder
foreach ($categories as $nombre) { foreach ($categories as $nombre) {
Category::firstOrCreate([ Category::firstOrCreate([
'nombre' => $nombre, '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]);
} }
} }

View File

@@ -30,11 +30,15 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$ticketCategory = Category::query()->firstOrCreate([ $ticketCategory = Category::query()->firstOrCreate([
'nombre' => 'Entradas', 'nombre' => 'Entradas',
'tenant_code' => null, 'tenant_code' => $tenant->codigo,
]); ]);
$foodCategory = Category::query()->firstOrCreate([ $foodCategory = Category::query()->firstOrCreate([
'nombre' => 'Gastronomía', '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']; $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' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'category_id' => $foodCategory->id],
['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, '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' => '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-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $parkingCategory->id],
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $ticketCategory->id], ['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $parkingCategory->id],
]; ];
$createdItems = []; $createdItems = [];

View File

@@ -236,11 +236,11 @@ class ProductCatalogFromImagesSeeder extends Seeder
$category = Category::query() $category = Category::query()
->where('nombre', $metadata['category_name']) ->where('nombre', $metadata['category_name'])
->whereNull('tenant_code') ->where('tenant_code', $tenant->codigo)
->first(); ->first();
if (! $category instanceof Category) { 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() $attributeCodes = Attribute::query()

View File

@@ -0,0 +1,123 @@
<?php
namespace Tests\Feature\Catalog;
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\Inventory;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CategoryDetailTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_category_products_paginated_with_the_tenant_search_layout(): void
{
$tenant = $this->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,
]);
}
}

View File

@@ -9,6 +9,7 @@ use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Inventory;
use App\Domains\Tenant\Models\Tenant; 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(9, CatalogItem::query()->where('tenant_code', $tenant->codigo)->count());
$this->assertSame(10, Inventory::query()->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() $featuredGroups = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)

View File

@@ -7,6 +7,7 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Database\Seeders\AttributeSeeder; use Database\Seeders\AttributeSeeder;
@@ -91,5 +92,19 @@ class ProductCatalogFromImagesSeederTest extends TestCase
->pluck('catalog_item_id') ->pluck('catalog_item_id')
->diff($catalogItemIds), ->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(),
);
} }
} }

View File

@@ -4,6 +4,7 @@ namespace Tests\Feature\Tenant;
use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\Category;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -95,6 +96,45 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonPath('data.dominio', 'acme.com'); ->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 public function test_it_returns_not_found_when_the_domain_does_not_exist(): void
{ {
$response = $this->getJson('/api/tenants/bootstrap/missing.example'); $response = $this->getJson('/api/tenants/bootstrap/missing.example');