From 12c10bbe066de4c5595f106df0ae9db737786e68 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 10:49:14 -0300 Subject: [PATCH 01/17] feat(catalog): add is_enabled attribute to categories and filter items accordingly --- app/Domains/Catalog/Models/Category.php | 6 +++ .../Catalog/Services/FeaturedGroupService.php | 9 ++++ ...000_add_is_enabled_to_categorias_table.php | 22 +++++++++ .../Feature/Catalog/CatalogControllerTest.php | 47 +++++++++++++++++++ tests/Feature/Catalog/CatalogSchemaTest.php | 16 +++++++ 5 files changed, 100 insertions(+) create mode 100644 database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php diff --git a/app/Domains/Catalog/Models/Category.php b/app/Domains/Catalog/Models/Category.php index ee683ba..c0ed114 100644 --- a/app/Domains/Catalog/Models/Category.php +++ b/app/Domains/Catalog/Models/Category.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'tenant_code', 'categoria_id', 'nombre', + 'is_enabled', ])] class Category extends Model { @@ -22,6 +23,10 @@ class Category extends Model protected $table = 'categorias'; + protected $attributes = [ + 'is_enabled' => true, + ]; + /** * @return array */ @@ -29,6 +34,7 @@ class Category extends Model { return [ 'categoria_id' => 'integer', + 'is_enabled' => 'boolean', ]; } diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index f01b679..0302a1f 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -49,6 +49,15 @@ class FeaturedGroupService { $query = CatalogItem::query() ->where('catalog_items.tenant_code', $featuredGroup->tenant_code) + ->where(function (Builder $query): void { + $query + ->whereDoesntHave('category') + ->orWhereHas( + 'category', + fn (Builder $categoryQuery): Builder => $categoryQuery + ->where('is_enabled', true), + ); + }) ->with([ 'inventory', 'attachments', diff --git a/database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php b/database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php new file mode 100644 index 0000000..e61c5b0 --- /dev/null +++ b/database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php @@ -0,0 +1,22 @@ +boolean('is_enabled')->default(true)->after('nombre'); + }); + } + + public function down(): void + { + Schema::table('categorias', function (Blueprint $table): void { + $table->dropColumn('is_enabled'); + }); + } +}; diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php index 63f1051..cdc98d2 100644 --- a/tests/Feature/Catalog/CatalogControllerTest.php +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -367,6 +367,53 @@ class CatalogControllerTest extends TestCase ->assertJsonPath('1.items.meta.total', 2); } + public function test_index_excludes_items_from_disabled_categories(): void + { + $tenant = $this->createTenant('catalog-disabled-category'); + $group = $this->createGroup( + $tenant, + ProductLayout::Row, + 'All products', + groupLayout: GroupLayout::Simple, + ); + $enabledCategory = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Enabled', + ]); + $disabledCategory = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Disabled', + 'is_enabled' => false, + ]); + + $enabledItem = $this->createItem($tenant, 'Enabled item'); + $enabledItem->category()->associate($enabledCategory)->save(); + $disabledItem = $this->createItem($tenant, 'Disabled item'); + $disabledItem->category()->associate($disabledCategory)->save(); + $uncategorizedItem = $this->createItem($tenant, 'Uncategorized item'); + + foreach ([$enabledItem, $disabledItem, $uncategorizedItem] as $order => $item) { + $group->featuredItems()->create([ + 'catalog_item_id' => $item->id, + 'order' => $order, + ]); + } + + $this->getJson("/api/tenants/{$tenant->codigo}/catalog") + ->assertOk() + ->assertJsonCount(2, '0.items') + ->assertJsonPath('0.items.0.nombre', 'Enabled item') + ->assertJsonPath('0.items.1.nombre', 'Uncategorized item') + ->assertJsonMissing(['nombre' => 'Disabled item']); + + $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog/featured-groups/{$group->id}/items" + ) + ->assertOk() + ->assertJsonCount(2) + ->assertJsonMissing(['nombre' => 'Disabled item']); + } + private function createGroup( Tenant $tenant, ProductLayout $layout, diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php index 705bfc7..43147b4 100644 --- a/tests/Feature/Catalog/CatalogSchemaTest.php +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Catalog; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; 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; @@ -15,6 +16,21 @@ class CatalogSchemaTest extends TestCase { use RefreshDatabase; + public function test_categories_are_enabled_by_default(): void + { + $this->assertTrue(Schema::hasColumn('categorias', 'is_enabled')); + + $category = Category::query()->create([ + 'nombre' => 'Enabled category', + ]); + + $this->assertTrue($category->is_enabled); + $this->assertDatabaseHas('categorias', [ + 'id' => $category->id, + 'is_enabled' => true, + ]); + } + public function test_legacy_product_tables_are_replaced_by_catalog_tables(): void { $this->assertFalse(Schema::hasTable('productos')); From 80adbd9ca944e33ceb8a461aa14e18c46a8fedca Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 11:31:26 -0300 Subject: [PATCH 02/17] feat(category-visibility): implement category visibility controller and service with update functionality --- .../CategoryVisibilityController.php | 49 +++++++++++++++++++ .../Enums/FiestaCategory.php | 21 ++++++++ .../UpdateCategoryVisibilityRequest.php | 16 ++++++ .../Services/CategoryVisibilityService.php | 29 +++++++++++ .../FiestaFutbolInfantil/routes/api.php | 15 ++++++ .../EntryControllerTest.php | 39 +++++++++++++++ 6 files changed, 169 insertions(+) create mode 100644 app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php create mode 100644 app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php create mode 100644 app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php create mode 100644 app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php new file mode 100644 index 0000000..26d6f7f --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php @@ -0,0 +1,49 @@ +category($request); + $tenant = $request->user()->tenant()->firstOrFail(); + + return response()->json([ + 'data' => [ + 'is_enabled' => $this->visibilityService->isEnabled($tenant, $category), + ], + ]); + } + + public function update(UpdateCategoryVisibilityRequest $request): JsonResponse + { + $category = $this->category($request); + $tenant = $request->user()->tenant()->firstOrFail(); + $model = $this->visibilityService->update( + $tenant, + $category, + $request->boolean('is_enabled'), + ); + + return response()->json([ + 'data' => [ + 'is_enabled' => $model->is_enabled, + ], + ]); + } + + private function category(Request $request): FiestaCategory + { + return FiestaCategory::from((string) $request->route('category')); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php b/app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php new file mode 100644 index 0000000..652e4a8 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php @@ -0,0 +1,21 @@ + 'Entradas', + self::Foods => 'Comidas', + self::Accommodations => 'Alojamientos', + self::Merchandise => 'Merchandising', + }; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php new file mode 100644 index 0000000..49def22 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php @@ -0,0 +1,16 @@ + */ + public function rules(): array + { + return [ + 'is_enabled' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php b/app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php new file mode 100644 index 0000000..d1fb4d5 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php @@ -0,0 +1,29 @@ +where('tenant_code', $tenant->codigo) + ->where('nombre', $category->categoryName()) + ->value('is_enabled') ?? true; + } + + public function update(Tenant $tenant, FiestaCategory $category, bool $isEnabled): Category + { + $model = Category::query()->firstOrCreate([ + 'tenant_code' => $tenant->codigo, + 'nombre' => $category->categoryName(), + ]); + $model->update(['is_enabled' => $isEnabled]); + + return $model; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/routes/api.php b/app/Domains/FiestaFutbolInfantil/routes/api.php index 86065e8..39d4a47 100644 --- a/app/Domains/FiestaFutbolInfantil/routes/api.php +++ b/app/Domains/FiestaFutbolInfantil/routes/api.php @@ -1,6 +1,7 @@ middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { + $visibilityRoutes = static function (string $endpoint, string $category, string $menuCode): void { + Route::get("{$endpoint}/visibility", [CategoryVisibilityController::class, 'show']) + ->defaults('category', $category) + ->middleware("tenant.menu:{$menuCode}"); + Route::patch("{$endpoint}/visibility", [CategoryVisibilityController::class, 'update']) + ->defaults('category', $category) + ->middleware("tenant.menu:{$menuCode}"); + }; + + $visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas'); + $visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida'); + $visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos'); + $visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising'); + Route::get('entries', [EntryController::class, 'index']) ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas') ->name('adminapp.fiesta-futbol-infantil.entries.index'); diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index 3a66f36..4cc9a7b 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -38,6 +38,45 @@ class EntryControllerTest extends TestCase ->assertUnauthorized(); } + public function test_category_visibility_can_be_read_and_updated_for_every_fiesta_menu(): void + { + $tenant = $this->createFiestaTenant(); + $categories = [ + 'entries' => ['Entradas', 'adminapp.fiesta-futbol-infantil.entradas'], + 'foods' => ['Comidas', 'adminapp.fiesta-futbol-infantil.comida'], + 'accommodations' => ['Alojamientos', 'adminapp.fiesta-futbol-infantil.alojamientos'], + 'merchandise' => ['Merchandising', 'adminapp.fiesta-futbol-infantil.merchandising'], + ]; + + foreach ($categories as [$label, $menuCode]) { + $menu = Menu::query()->firstOrCreate( + ['code' => $menuCode], + ['label' => $label, 'route' => '/admin'], + ); + $tenant->menues()->syncWithoutDetaching([$menu->code]); + } + + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + foreach ($categories as $endpoint => [$categoryName]) { + $this->getJson("/api/v1/adminapp/tenant/{$endpoint}/visibility") + ->assertOk() + ->assertJsonPath('data.is_enabled', true); + + $this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [ + 'is_enabled' => false, + ]) + ->assertOk() + ->assertJsonPath('data.is_enabled', false); + + $this->assertDatabaseHas('categorias', [ + 'tenant_code' => $tenant->codigo, + 'nombre' => $categoryName, + 'is_enabled' => false, + ]); + } + } + public function test_it_creates_multiple_entries_with_dates_and_tracked_inventory(): void { $tenant = $this->createFiestaTenant(); From 21cfbe2a4630626601e74b6ca22ae357f8e24ec4 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 12:35:20 -0300 Subject: [PATCH 03/17] feat(purchase): add filtering for multiple statuses in purchase index and order by status then date --- .../Controllers/PurchaseController.php | 15 ++++-- tests/Feature/Purchase/StorePurchaseTest.php | 46 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 483b73f..5db012f 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -24,13 +24,22 @@ class PurchaseController extends Controller { public function index(Request $request, Tenant $tenant): JsonResponse { + $statusParam = $request->query('status'); + $statuses = is_string($statusParam) + ? collect(explode(',', $statusParam)) + ->map(fn (string $status): string => trim($status)) + ->filter() + ->unique() + ->values() + ->all() + : []; + return PurchaseResource::collection( Purchase::query() ->where('tenant_codigo', $tenant->codigo) ->where('user_id', $request->user()->id) - ->when($request->query('status'), function ($query, $status) { - $query->where('status', $status); - }) + ->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses)) + ->orderBy('status') ->latest() ->paginateFromRequest() )->response(); diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 9fda55a..91f521a 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -1230,6 +1230,52 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.0.total', '100.00'); } + public function test_purchase_index_filters_multiple_comma_separated_statuses_and_orders_by_status_ascending_then_date(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + + $olderPaid = Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_PAID, + 'total' => '10.00', + ]); + $newerInReview = Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_IN_REVIEW, + 'total' => '20.00', + ]); + $newerPaid = Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_PAID, + 'total' => '30.00', + ]); + Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_CANCELLED, + 'total' => '40.00', + ]); + + $olderPaid->forceFill(['created_at' => now()->subDays(3)])->saveQuietly(); + $newerInReview->forceFill(['created_at' => now()])->saveQuietly(); + $newerPaid->forceFill(['created_at' => now()->subDay()])->saveQuietly(); + + $this->actingAs($user, 'sanctum') + ->getJson('/api/tenants/sonder/compras?status=paid,%20in_review,paid') + ->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0.id', $newerInReview->id) + ->assertJsonPath('data.0.status', Purchase::STATUS_IN_REVIEW) + ->assertJsonPath('data.1.id', $newerPaid->id) + ->assertJsonPath('data.1.status', Purchase::STATUS_PAID) + ->assertJsonPath('data.2.id', $olderPaid->id) + ->assertJsonPath('data.2.status', Purchase::STATUS_PAID); + } + public function test_it_rejects_a_cart_from_another_user(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); From d1c4acc7ae84e5e5aafe7b1f4c6a15e723a44b47 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 12:44:37 -0300 Subject: [PATCH 04/17] feat(category-visibility): add success messages for category visibility updates --- .../CategoryVisibilityController.php | 3 +++ .../EntryControllerTest.php | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php index 26d6f7f..c67aef4 100644 --- a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php +++ b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php @@ -39,6 +39,9 @@ class CategoryVisibilityController extends Controller 'data' => [ 'is_enabled' => $model->is_enabled, ], + 'message' => $model->is_enabled + ? 'Mostrar en sitio web se activo correctamente' + : 'Mostrar en sitio web se desactivo correctamente', ]); } diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index 4cc9a7b..9b084f4 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -67,12 +67,26 @@ class EntryControllerTest extends TestCase 'is_enabled' => false, ]) ->assertOk() - ->assertJsonPath('data.is_enabled', false); + ->assertJsonPath('data.is_enabled', false) + ->assertJsonPath( + 'message', + 'Mostrar en sitio web se desactivo correctamente', + ); + + $this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [ + 'is_enabled' => true, + ]) + ->assertOk() + ->assertJsonPath('data.is_enabled', true) + ->assertJsonPath( + 'message', + 'Mostrar en sitio web se activo correctamente', + ); $this->assertDatabaseHas('categorias', [ 'tenant_code' => $tenant->codigo, 'nombre' => $categoryName, - 'is_enabled' => false, + 'is_enabled' => true, ]); } } From c69ebe5f7c6f939c2b35d147f31d4845bcb93929 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 15:59:00 -0300 Subject: [PATCH 05/17] feat(sale-modification): add changed_at field serialization to ISO 8601 format --- .../AdminApp/SaleModificationResource.php | 1 + .../Sale/SaleModificationResourceTest.php | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tests/Unit/Sale/SaleModificationResourceTest.php diff --git a/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php b/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php index c842c0a..8a9a651 100644 --- a/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php +++ b/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php @@ -23,6 +23,7 @@ class SaleModificationResource extends JsonResource 'attribute' => $this->attribute, 'old_value' => $this->old_value, 'new_value' => $this->new_value, + 'changed_at' => $this->changed_at->utc()->toIso8601String(), 'date' => $this->changed_at->format('Y-m-d'), 'time' => $this->changed_at->format('H:i:s'), 'actor_type' => $this->actor_type->value, diff --git a/tests/Unit/Sale/SaleModificationResourceTest.php b/tests/Unit/Sale/SaleModificationResourceTest.php new file mode 100644 index 0000000..1fe1386 --- /dev/null +++ b/tests/Unit/Sale/SaleModificationResourceTest.php @@ -0,0 +1,33 @@ +forceFill([ + 'id' => 1, + 'trackable_id' => 33, + 'attribute' => 'status', + 'old_value' => 'pending_payment', + 'new_value' => 'cancelled', + 'changed_at' => CarbonImmutable::parse('2026-08-24 17:53:00', 'UTC'), + 'actor_type' => 'system', + ]); + $modification->setRelation('trackable', null); + $modification->setRelation('user', null); + + $data = (new SaleModificationResource($modification))->resolve(Request::create('/')); + + $this->assertSame('2026-08-24T17:53:00+00:00', $data['changed_at']); + $this->assertSame('2026-08-24', $data['date']); + $this->assertSame('17:53:00', $data['time']); + } +} From 24e000b423958644d4c532d8d8c2d707f6db01fc Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 10:49:14 -0300 Subject: [PATCH 06/17] feat(catalog): add is_enabled attribute to categories and filter items accordingly --- app/Domains/Catalog/Models/Category.php | 6 +++ .../Catalog/Services/FeaturedGroupService.php | 9 ++++ ...000_add_is_enabled_to_categorias_table.php | 22 +++++++++ .../Feature/Catalog/CatalogControllerTest.php | 47 +++++++++++++++++++ tests/Feature/Catalog/CatalogSchemaTest.php | 16 +++++++ 5 files changed, 100 insertions(+) create mode 100644 database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php diff --git a/app/Domains/Catalog/Models/Category.php b/app/Domains/Catalog/Models/Category.php index ee683ba..c0ed114 100644 --- a/app/Domains/Catalog/Models/Category.php +++ b/app/Domains/Catalog/Models/Category.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'tenant_code', 'categoria_id', 'nombre', + 'is_enabled', ])] class Category extends Model { @@ -22,6 +23,10 @@ class Category extends Model protected $table = 'categorias'; + protected $attributes = [ + 'is_enabled' => true, + ]; + /** * @return array */ @@ -29,6 +34,7 @@ class Category extends Model { return [ 'categoria_id' => 'integer', + 'is_enabled' => 'boolean', ]; } diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index f01b679..0302a1f 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -49,6 +49,15 @@ class FeaturedGroupService { $query = CatalogItem::query() ->where('catalog_items.tenant_code', $featuredGroup->tenant_code) + ->where(function (Builder $query): void { + $query + ->whereDoesntHave('category') + ->orWhereHas( + 'category', + fn (Builder $categoryQuery): Builder => $categoryQuery + ->where('is_enabled', true), + ); + }) ->with([ 'inventory', 'attachments', diff --git a/database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php b/database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php new file mode 100644 index 0000000..e61c5b0 --- /dev/null +++ b/database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php @@ -0,0 +1,22 @@ +boolean('is_enabled')->default(true)->after('nombre'); + }); + } + + public function down(): void + { + Schema::table('categorias', function (Blueprint $table): void { + $table->dropColumn('is_enabled'); + }); + } +}; diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php index 63f1051..cdc98d2 100644 --- a/tests/Feature/Catalog/CatalogControllerTest.php +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -367,6 +367,53 @@ class CatalogControllerTest extends TestCase ->assertJsonPath('1.items.meta.total', 2); } + public function test_index_excludes_items_from_disabled_categories(): void + { + $tenant = $this->createTenant('catalog-disabled-category'); + $group = $this->createGroup( + $tenant, + ProductLayout::Row, + 'All products', + groupLayout: GroupLayout::Simple, + ); + $enabledCategory = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Enabled', + ]); + $disabledCategory = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Disabled', + 'is_enabled' => false, + ]); + + $enabledItem = $this->createItem($tenant, 'Enabled item'); + $enabledItem->category()->associate($enabledCategory)->save(); + $disabledItem = $this->createItem($tenant, 'Disabled item'); + $disabledItem->category()->associate($disabledCategory)->save(); + $uncategorizedItem = $this->createItem($tenant, 'Uncategorized item'); + + foreach ([$enabledItem, $disabledItem, $uncategorizedItem] as $order => $item) { + $group->featuredItems()->create([ + 'catalog_item_id' => $item->id, + 'order' => $order, + ]); + } + + $this->getJson("/api/tenants/{$tenant->codigo}/catalog") + ->assertOk() + ->assertJsonCount(2, '0.items') + ->assertJsonPath('0.items.0.nombre', 'Enabled item') + ->assertJsonPath('0.items.1.nombre', 'Uncategorized item') + ->assertJsonMissing(['nombre' => 'Disabled item']); + + $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog/featured-groups/{$group->id}/items" + ) + ->assertOk() + ->assertJsonCount(2) + ->assertJsonMissing(['nombre' => 'Disabled item']); + } + private function createGroup( Tenant $tenant, ProductLayout $layout, diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php index 705bfc7..43147b4 100644 --- a/tests/Feature/Catalog/CatalogSchemaTest.php +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Catalog; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; 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; @@ -15,6 +16,21 @@ class CatalogSchemaTest extends TestCase { use RefreshDatabase; + public function test_categories_are_enabled_by_default(): void + { + $this->assertTrue(Schema::hasColumn('categorias', 'is_enabled')); + + $category = Category::query()->create([ + 'nombre' => 'Enabled category', + ]); + + $this->assertTrue($category->is_enabled); + $this->assertDatabaseHas('categorias', [ + 'id' => $category->id, + 'is_enabled' => true, + ]); + } + public function test_legacy_product_tables_are_replaced_by_catalog_tables(): void { $this->assertFalse(Schema::hasTable('productos')); From 6ff91190862e01908a51fa3d78b49fdc277bd3a1 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 11:31:26 -0300 Subject: [PATCH 07/17] feat(category-visibility): implement category visibility controller and service with update functionality --- .../CategoryVisibilityController.php | 49 +++++++++++++++++++ .../Enums/FiestaCategory.php | 21 ++++++++ .../UpdateCategoryVisibilityRequest.php | 16 ++++++ .../Services/CategoryVisibilityService.php | 29 +++++++++++ .../FiestaFutbolInfantil/routes/api.php | 15 ++++++ .../EntryControllerTest.php | 39 +++++++++++++++ 6 files changed, 169 insertions(+) create mode 100644 app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php create mode 100644 app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php create mode 100644 app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php create mode 100644 app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php new file mode 100644 index 0000000..26d6f7f --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php @@ -0,0 +1,49 @@ +category($request); + $tenant = $request->user()->tenant()->firstOrFail(); + + return response()->json([ + 'data' => [ + 'is_enabled' => $this->visibilityService->isEnabled($tenant, $category), + ], + ]); + } + + public function update(UpdateCategoryVisibilityRequest $request): JsonResponse + { + $category = $this->category($request); + $tenant = $request->user()->tenant()->firstOrFail(); + $model = $this->visibilityService->update( + $tenant, + $category, + $request->boolean('is_enabled'), + ); + + return response()->json([ + 'data' => [ + 'is_enabled' => $model->is_enabled, + ], + ]); + } + + private function category(Request $request): FiestaCategory + { + return FiestaCategory::from((string) $request->route('category')); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php b/app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php new file mode 100644 index 0000000..652e4a8 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php @@ -0,0 +1,21 @@ + 'Entradas', + self::Foods => 'Comidas', + self::Accommodations => 'Alojamientos', + self::Merchandise => 'Merchandising', + }; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php new file mode 100644 index 0000000..49def22 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php @@ -0,0 +1,16 @@ + */ + public function rules(): array + { + return [ + 'is_enabled' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php b/app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php new file mode 100644 index 0000000..d1fb4d5 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php @@ -0,0 +1,29 @@ +where('tenant_code', $tenant->codigo) + ->where('nombre', $category->categoryName()) + ->value('is_enabled') ?? true; + } + + public function update(Tenant $tenant, FiestaCategory $category, bool $isEnabled): Category + { + $model = Category::query()->firstOrCreate([ + 'tenant_code' => $tenant->codigo, + 'nombre' => $category->categoryName(), + ]); + $model->update(['is_enabled' => $isEnabled]); + + return $model; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/routes/api.php b/app/Domains/FiestaFutbolInfantil/routes/api.php index 86065e8..39d4a47 100644 --- a/app/Domains/FiestaFutbolInfantil/routes/api.php +++ b/app/Domains/FiestaFutbolInfantil/routes/api.php @@ -1,6 +1,7 @@ middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { + $visibilityRoutes = static function (string $endpoint, string $category, string $menuCode): void { + Route::get("{$endpoint}/visibility", [CategoryVisibilityController::class, 'show']) + ->defaults('category', $category) + ->middleware("tenant.menu:{$menuCode}"); + Route::patch("{$endpoint}/visibility", [CategoryVisibilityController::class, 'update']) + ->defaults('category', $category) + ->middleware("tenant.menu:{$menuCode}"); + }; + + $visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas'); + $visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida'); + $visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos'); + $visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising'); + Route::get('entries', [EntryController::class, 'index']) ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas') ->name('adminapp.fiesta-futbol-infantil.entries.index'); diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index 3a66f36..4cc9a7b 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -38,6 +38,45 @@ class EntryControllerTest extends TestCase ->assertUnauthorized(); } + public function test_category_visibility_can_be_read_and_updated_for_every_fiesta_menu(): void + { + $tenant = $this->createFiestaTenant(); + $categories = [ + 'entries' => ['Entradas', 'adminapp.fiesta-futbol-infantil.entradas'], + 'foods' => ['Comidas', 'adminapp.fiesta-futbol-infantil.comida'], + 'accommodations' => ['Alojamientos', 'adminapp.fiesta-futbol-infantil.alojamientos'], + 'merchandise' => ['Merchandising', 'adminapp.fiesta-futbol-infantil.merchandising'], + ]; + + foreach ($categories as [$label, $menuCode]) { + $menu = Menu::query()->firstOrCreate( + ['code' => $menuCode], + ['label' => $label, 'route' => '/admin'], + ); + $tenant->menues()->syncWithoutDetaching([$menu->code]); + } + + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + foreach ($categories as $endpoint => [$categoryName]) { + $this->getJson("/api/v1/adminapp/tenant/{$endpoint}/visibility") + ->assertOk() + ->assertJsonPath('data.is_enabled', true); + + $this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [ + 'is_enabled' => false, + ]) + ->assertOk() + ->assertJsonPath('data.is_enabled', false); + + $this->assertDatabaseHas('categorias', [ + 'tenant_code' => $tenant->codigo, + 'nombre' => $categoryName, + 'is_enabled' => false, + ]); + } + } + public function test_it_creates_multiple_entries_with_dates_and_tracked_inventory(): void { $tenant = $this->createFiestaTenant(); From 53d1415b1895f693faedbf4ae19e1341df8ecdae Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 12:35:20 -0300 Subject: [PATCH 08/17] feat(purchase): add filtering for multiple statuses in purchase index and order by status then date --- .../Controllers/PurchaseController.php | 15 ++++-- tests/Feature/Purchase/StorePurchaseTest.php | 46 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 483b73f..5db012f 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -24,13 +24,22 @@ class PurchaseController extends Controller { public function index(Request $request, Tenant $tenant): JsonResponse { + $statusParam = $request->query('status'); + $statuses = is_string($statusParam) + ? collect(explode(',', $statusParam)) + ->map(fn (string $status): string => trim($status)) + ->filter() + ->unique() + ->values() + ->all() + : []; + return PurchaseResource::collection( Purchase::query() ->where('tenant_codigo', $tenant->codigo) ->where('user_id', $request->user()->id) - ->when($request->query('status'), function ($query, $status) { - $query->where('status', $status); - }) + ->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses)) + ->orderBy('status') ->latest() ->paginateFromRequest() )->response(); diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 9fda55a..91f521a 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -1230,6 +1230,52 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.0.total', '100.00'); } + public function test_purchase_index_filters_multiple_comma_separated_statuses_and_orders_by_status_ascending_then_date(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + + $olderPaid = Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_PAID, + 'total' => '10.00', + ]); + $newerInReview = Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_IN_REVIEW, + 'total' => '20.00', + ]); + $newerPaid = Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_PAID, + 'total' => '30.00', + ]); + Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_CANCELLED, + 'total' => '40.00', + ]); + + $olderPaid->forceFill(['created_at' => now()->subDays(3)])->saveQuietly(); + $newerInReview->forceFill(['created_at' => now()])->saveQuietly(); + $newerPaid->forceFill(['created_at' => now()->subDay()])->saveQuietly(); + + $this->actingAs($user, 'sanctum') + ->getJson('/api/tenants/sonder/compras?status=paid,%20in_review,paid') + ->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0.id', $newerInReview->id) + ->assertJsonPath('data.0.status', Purchase::STATUS_IN_REVIEW) + ->assertJsonPath('data.1.id', $newerPaid->id) + ->assertJsonPath('data.1.status', Purchase::STATUS_PAID) + ->assertJsonPath('data.2.id', $olderPaid->id) + ->assertJsonPath('data.2.status', Purchase::STATUS_PAID); + } + public function test_it_rejects_a_cart_from_another_user(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); From f8b8bab4747d668d10773d0016dd335881addb49 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 12:44:37 -0300 Subject: [PATCH 09/17] feat(category-visibility): add success messages for category visibility updates --- .../CategoryVisibilityController.php | 3 +++ .../EntryControllerTest.php | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php index 26d6f7f..c67aef4 100644 --- a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php +++ b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php @@ -39,6 +39,9 @@ class CategoryVisibilityController extends Controller 'data' => [ 'is_enabled' => $model->is_enabled, ], + 'message' => $model->is_enabled + ? 'Mostrar en sitio web se activo correctamente' + : 'Mostrar en sitio web se desactivo correctamente', ]); } diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index 4cc9a7b..9b084f4 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -67,12 +67,26 @@ class EntryControllerTest extends TestCase 'is_enabled' => false, ]) ->assertOk() - ->assertJsonPath('data.is_enabled', false); + ->assertJsonPath('data.is_enabled', false) + ->assertJsonPath( + 'message', + 'Mostrar en sitio web se desactivo correctamente', + ); + + $this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [ + 'is_enabled' => true, + ]) + ->assertOk() + ->assertJsonPath('data.is_enabled', true) + ->assertJsonPath( + 'message', + 'Mostrar en sitio web se activo correctamente', + ); $this->assertDatabaseHas('categorias', [ 'tenant_code' => $tenant->codigo, 'nombre' => $categoryName, - 'is_enabled' => false, + 'is_enabled' => true, ]); } } From 8d01620fa8e8d268a5d0f715ee6562616b1a8928 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 15:59:00 -0300 Subject: [PATCH 10/17] feat(sale-modification): add changed_at field serialization to ISO 8601 format --- .../AdminApp/SaleModificationResource.php | 1 + .../Sale/SaleModificationResourceTest.php | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 tests/Unit/Sale/SaleModificationResourceTest.php diff --git a/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php b/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php index c842c0a..8a9a651 100644 --- a/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php +++ b/app/Domains/Sale/Resources/AdminApp/SaleModificationResource.php @@ -23,6 +23,7 @@ class SaleModificationResource extends JsonResource 'attribute' => $this->attribute, 'old_value' => $this->old_value, 'new_value' => $this->new_value, + 'changed_at' => $this->changed_at->utc()->toIso8601String(), 'date' => $this->changed_at->format('Y-m-d'), 'time' => $this->changed_at->format('H:i:s'), 'actor_type' => $this->actor_type->value, diff --git a/tests/Unit/Sale/SaleModificationResourceTest.php b/tests/Unit/Sale/SaleModificationResourceTest.php new file mode 100644 index 0000000..1fe1386 --- /dev/null +++ b/tests/Unit/Sale/SaleModificationResourceTest.php @@ -0,0 +1,33 @@ +forceFill([ + 'id' => 1, + 'trackable_id' => 33, + 'attribute' => 'status', + 'old_value' => 'pending_payment', + 'new_value' => 'cancelled', + 'changed_at' => CarbonImmutable::parse('2026-08-24 17:53:00', 'UTC'), + 'actor_type' => 'system', + ]); + $modification->setRelation('trackable', null); + $modification->setRelation('user', null); + + $data = (new SaleModificationResource($modification))->resolve(Request::create('/')); + + $this->assertSame('2026-08-24T17:53:00+00:00', $data['changed_at']); + $this->assertSame('2026-08-24', $data['date']); + $this->assertSame('17:53:00', $data['time']); + } +} From bd6d672df2b1550a7fe71d80e710541ba2fc69a5 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 10:49:14 -0300 Subject: [PATCH 11/17] feat(catalog): add is_enabled attribute to categories and filter items accordingly --- app/Domains/Catalog/Models/Category.php | 6 +++ .../Catalog/Services/FeaturedGroupService.php | 9 ++++ ...000_add_is_enabled_to_categorias_table.php | 22 +++++++++ .../Feature/Catalog/CatalogControllerTest.php | 47 +++++++++++++++++++ tests/Feature/Catalog/CatalogSchemaTest.php | 16 +++++++ 5 files changed, 100 insertions(+) create mode 100644 database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php diff --git a/app/Domains/Catalog/Models/Category.php b/app/Domains/Catalog/Models/Category.php index ee683ba..c0ed114 100644 --- a/app/Domains/Catalog/Models/Category.php +++ b/app/Domains/Catalog/Models/Category.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'tenant_code', 'categoria_id', 'nombre', + 'is_enabled', ])] class Category extends Model { @@ -22,6 +23,10 @@ class Category extends Model protected $table = 'categorias'; + protected $attributes = [ + 'is_enabled' => true, + ]; + /** * @return array */ @@ -29,6 +34,7 @@ class Category extends Model { return [ 'categoria_id' => 'integer', + 'is_enabled' => 'boolean', ]; } diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index f01b679..0302a1f 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -49,6 +49,15 @@ class FeaturedGroupService { $query = CatalogItem::query() ->where('catalog_items.tenant_code', $featuredGroup->tenant_code) + ->where(function (Builder $query): void { + $query + ->whereDoesntHave('category') + ->orWhereHas( + 'category', + fn (Builder $categoryQuery): Builder => $categoryQuery + ->where('is_enabled', true), + ); + }) ->with([ 'inventory', 'attachments', diff --git a/database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php b/database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php new file mode 100644 index 0000000..e61c5b0 --- /dev/null +++ b/database/migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php @@ -0,0 +1,22 @@ +boolean('is_enabled')->default(true)->after('nombre'); + }); + } + + public function down(): void + { + Schema::table('categorias', function (Blueprint $table): void { + $table->dropColumn('is_enabled'); + }); + } +}; diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php index 63f1051..cdc98d2 100644 --- a/tests/Feature/Catalog/CatalogControllerTest.php +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -367,6 +367,53 @@ class CatalogControllerTest extends TestCase ->assertJsonPath('1.items.meta.total', 2); } + public function test_index_excludes_items_from_disabled_categories(): void + { + $tenant = $this->createTenant('catalog-disabled-category'); + $group = $this->createGroup( + $tenant, + ProductLayout::Row, + 'All products', + groupLayout: GroupLayout::Simple, + ); + $enabledCategory = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Enabled', + ]); + $disabledCategory = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Disabled', + 'is_enabled' => false, + ]); + + $enabledItem = $this->createItem($tenant, 'Enabled item'); + $enabledItem->category()->associate($enabledCategory)->save(); + $disabledItem = $this->createItem($tenant, 'Disabled item'); + $disabledItem->category()->associate($disabledCategory)->save(); + $uncategorizedItem = $this->createItem($tenant, 'Uncategorized item'); + + foreach ([$enabledItem, $disabledItem, $uncategorizedItem] as $order => $item) { + $group->featuredItems()->create([ + 'catalog_item_id' => $item->id, + 'order' => $order, + ]); + } + + $this->getJson("/api/tenants/{$tenant->codigo}/catalog") + ->assertOk() + ->assertJsonCount(2, '0.items') + ->assertJsonPath('0.items.0.nombre', 'Enabled item') + ->assertJsonPath('0.items.1.nombre', 'Uncategorized item') + ->assertJsonMissing(['nombre' => 'Disabled item']); + + $this->getJson( + "/api/tenants/{$tenant->codigo}/catalog/featured-groups/{$group->id}/items" + ) + ->assertOk() + ->assertJsonCount(2) + ->assertJsonMissing(['nombre' => 'Disabled item']); + } + private function createGroup( Tenant $tenant, ProductLayout $layout, diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php index 705bfc7..43147b4 100644 --- a/tests/Feature/Catalog/CatalogSchemaTest.php +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Catalog; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; 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; @@ -15,6 +16,21 @@ class CatalogSchemaTest extends TestCase { use RefreshDatabase; + public function test_categories_are_enabled_by_default(): void + { + $this->assertTrue(Schema::hasColumn('categorias', 'is_enabled')); + + $category = Category::query()->create([ + 'nombre' => 'Enabled category', + ]); + + $this->assertTrue($category->is_enabled); + $this->assertDatabaseHas('categorias', [ + 'id' => $category->id, + 'is_enabled' => true, + ]); + } + public function test_legacy_product_tables_are_replaced_by_catalog_tables(): void { $this->assertFalse(Schema::hasTable('productos')); From 75d3c819d96a0072d45b005a02bd526098fed85f Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 11:31:26 -0300 Subject: [PATCH 12/17] feat(category-visibility): implement category visibility controller and service with update functionality --- .../CategoryVisibilityController.php | 49 +++++++++++++++++++ .../Enums/FiestaCategory.php | 21 ++++++++ .../UpdateCategoryVisibilityRequest.php | 16 ++++++ .../Services/CategoryVisibilityService.php | 29 +++++++++++ .../FiestaFutbolInfantil/routes/api.php | 15 ++++++ .../EntryControllerTest.php | 39 +++++++++++++++ 6 files changed, 169 insertions(+) create mode 100644 app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php create mode 100644 app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php create mode 100644 app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php create mode 100644 app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php new file mode 100644 index 0000000..26d6f7f --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php @@ -0,0 +1,49 @@ +category($request); + $tenant = $request->user()->tenant()->firstOrFail(); + + return response()->json([ + 'data' => [ + 'is_enabled' => $this->visibilityService->isEnabled($tenant, $category), + ], + ]); + } + + public function update(UpdateCategoryVisibilityRequest $request): JsonResponse + { + $category = $this->category($request); + $tenant = $request->user()->tenant()->firstOrFail(); + $model = $this->visibilityService->update( + $tenant, + $category, + $request->boolean('is_enabled'), + ); + + return response()->json([ + 'data' => [ + 'is_enabled' => $model->is_enabled, + ], + ]); + } + + private function category(Request $request): FiestaCategory + { + return FiestaCategory::from((string) $request->route('category')); + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php b/app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php new file mode 100644 index 0000000..652e4a8 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php @@ -0,0 +1,21 @@ + 'Entradas', + self::Foods => 'Comidas', + self::Accommodations => 'Alojamientos', + self::Merchandise => 'Merchandising', + }; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php b/app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php new file mode 100644 index 0000000..49def22 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Requests/UpdateCategoryVisibilityRequest.php @@ -0,0 +1,16 @@ + */ + public function rules(): array + { + return [ + 'is_enabled' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php b/app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php new file mode 100644 index 0000000..d1fb4d5 --- /dev/null +++ b/app/Domains/FiestaFutbolInfantil/Services/CategoryVisibilityService.php @@ -0,0 +1,29 @@ +where('tenant_code', $tenant->codigo) + ->where('nombre', $category->categoryName()) + ->value('is_enabled') ?? true; + } + + public function update(Tenant $tenant, FiestaCategory $category, bool $isEnabled): Category + { + $model = Category::query()->firstOrCreate([ + 'tenant_code' => $tenant->codigo, + 'nombre' => $category->categoryName(), + ]); + $model->update(['is_enabled' => $isEnabled]); + + return $model; + } +} diff --git a/app/Domains/FiestaFutbolInfantil/routes/api.php b/app/Domains/FiestaFutbolInfantil/routes/api.php index 86065e8..39d4a47 100644 --- a/app/Domains/FiestaFutbolInfantil/routes/api.php +++ b/app/Domains/FiestaFutbolInfantil/routes/api.php @@ -1,6 +1,7 @@ middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { + $visibilityRoutes = static function (string $endpoint, string $category, string $menuCode): void { + Route::get("{$endpoint}/visibility", [CategoryVisibilityController::class, 'show']) + ->defaults('category', $category) + ->middleware("tenant.menu:{$menuCode}"); + Route::patch("{$endpoint}/visibility", [CategoryVisibilityController::class, 'update']) + ->defaults('category', $category) + ->middleware("tenant.menu:{$menuCode}"); + }; + + $visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas'); + $visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida'); + $visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos'); + $visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising'); + Route::get('entries', [EntryController::class, 'index']) ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas') ->name('adminapp.fiesta-futbol-infantil.entries.index'); diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index 3a66f36..4cc9a7b 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -38,6 +38,45 @@ class EntryControllerTest extends TestCase ->assertUnauthorized(); } + public function test_category_visibility_can_be_read_and_updated_for_every_fiesta_menu(): void + { + $tenant = $this->createFiestaTenant(); + $categories = [ + 'entries' => ['Entradas', 'adminapp.fiesta-futbol-infantil.entradas'], + 'foods' => ['Comidas', 'adminapp.fiesta-futbol-infantil.comida'], + 'accommodations' => ['Alojamientos', 'adminapp.fiesta-futbol-infantil.alojamientos'], + 'merchandise' => ['Merchandising', 'adminapp.fiesta-futbol-infantil.merchandising'], + ]; + + foreach ($categories as [$label, $menuCode]) { + $menu = Menu::query()->firstOrCreate( + ['code' => $menuCode], + ['label' => $label, 'route' => '/admin'], + ); + $tenant->menues()->syncWithoutDetaching([$menu->code]); + } + + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + foreach ($categories as $endpoint => [$categoryName]) { + $this->getJson("/api/v1/adminapp/tenant/{$endpoint}/visibility") + ->assertOk() + ->assertJsonPath('data.is_enabled', true); + + $this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [ + 'is_enabled' => false, + ]) + ->assertOk() + ->assertJsonPath('data.is_enabled', false); + + $this->assertDatabaseHas('categorias', [ + 'tenant_code' => $tenant->codigo, + 'nombre' => $categoryName, + 'is_enabled' => false, + ]); + } + } + public function test_it_creates_multiple_entries_with_dates_and_tracked_inventory(): void { $tenant = $this->createFiestaTenant(); From c6da71894e514045f73da9ad7b6e4bd12caeb88c Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 12:35:20 -0300 Subject: [PATCH 13/17] feat(purchase): add filtering for multiple statuses in purchase index and order by status then date --- .../Controllers/PurchaseController.php | 15 ++++-- tests/Feature/Purchase/StorePurchaseTest.php | 46 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 483b73f..5db012f 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -24,13 +24,22 @@ class PurchaseController extends Controller { public function index(Request $request, Tenant $tenant): JsonResponse { + $statusParam = $request->query('status'); + $statuses = is_string($statusParam) + ? collect(explode(',', $statusParam)) + ->map(fn (string $status): string => trim($status)) + ->filter() + ->unique() + ->values() + ->all() + : []; + return PurchaseResource::collection( Purchase::query() ->where('tenant_codigo', $tenant->codigo) ->where('user_id', $request->user()->id) - ->when($request->query('status'), function ($query, $status) { - $query->where('status', $status); - }) + ->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses)) + ->orderBy('status') ->latest() ->paginateFromRequest() )->response(); diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 9fda55a..91f521a 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -1230,6 +1230,52 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.0.total', '100.00'); } + public function test_purchase_index_filters_multiple_comma_separated_statuses_and_orders_by_status_ascending_then_date(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + + $olderPaid = Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_PAID, + 'total' => '10.00', + ]); + $newerInReview = Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_IN_REVIEW, + 'total' => '20.00', + ]); + $newerPaid = Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_PAID, + 'total' => '30.00', + ]); + Purchase::query()->create([ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => Purchase::STATUS_CANCELLED, + 'total' => '40.00', + ]); + + $olderPaid->forceFill(['created_at' => now()->subDays(3)])->saveQuietly(); + $newerInReview->forceFill(['created_at' => now()])->saveQuietly(); + $newerPaid->forceFill(['created_at' => now()->subDay()])->saveQuietly(); + + $this->actingAs($user, 'sanctum') + ->getJson('/api/tenants/sonder/compras?status=paid,%20in_review,paid') + ->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0.id', $newerInReview->id) + ->assertJsonPath('data.0.status', Purchase::STATUS_IN_REVIEW) + ->assertJsonPath('data.1.id', $newerPaid->id) + ->assertJsonPath('data.1.status', Purchase::STATUS_PAID) + ->assertJsonPath('data.2.id', $olderPaid->id) + ->assertJsonPath('data.2.status', Purchase::STATUS_PAID); + } + public function test_it_rejects_a_cart_from_another_user(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); From 45bba9516dffe4ae07af0c4b809d0243b76317a7 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 12:44:37 -0300 Subject: [PATCH 14/17] feat(category-visibility): add success messages for category visibility updates --- .../CategoryVisibilityController.php | 3 +++ .../EntryControllerTest.php | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php index 26d6f7f..c67aef4 100644 --- a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php +++ b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php @@ -39,6 +39,9 @@ class CategoryVisibilityController extends Controller 'data' => [ 'is_enabled' => $model->is_enabled, ], + 'message' => $model->is_enabled + ? 'Mostrar en sitio web se activo correctamente' + : 'Mostrar en sitio web se desactivo correctamente', ]); } diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index 4cc9a7b..9b084f4 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -67,12 +67,26 @@ class EntryControllerTest extends TestCase 'is_enabled' => false, ]) ->assertOk() - ->assertJsonPath('data.is_enabled', false); + ->assertJsonPath('data.is_enabled', false) + ->assertJsonPath( + 'message', + 'Mostrar en sitio web se desactivo correctamente', + ); + + $this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [ + 'is_enabled' => true, + ]) + ->assertOk() + ->assertJsonPath('data.is_enabled', true) + ->assertJsonPath( + 'message', + 'Mostrar en sitio web se activo correctamente', + ); $this->assertDatabaseHas('categorias', [ 'tenant_code' => $tenant->codigo, 'nombre' => $categoryName, - 'is_enabled' => false, + 'is_enabled' => true, ]); } } From 47196a572a07329474912122b800333f09061fa1 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 11:31:26 -0300 Subject: [PATCH 15/17] feat(category-visibility): implement category visibility controller and service with update functionality --- .../CategoryVisibilityController.php | 3 --- .../EntryControllerTest.php | 18 ++---------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php index c67aef4..26d6f7f 100644 --- a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php +++ b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php @@ -39,9 +39,6 @@ class CategoryVisibilityController extends Controller 'data' => [ 'is_enabled' => $model->is_enabled, ], - 'message' => $model->is_enabled - ? 'Mostrar en sitio web se activo correctamente' - : 'Mostrar en sitio web se desactivo correctamente', ]); } diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index 9b084f4..4cc9a7b 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -67,26 +67,12 @@ class EntryControllerTest extends TestCase 'is_enabled' => false, ]) ->assertOk() - ->assertJsonPath('data.is_enabled', false) - ->assertJsonPath( - 'message', - 'Mostrar en sitio web se desactivo correctamente', - ); - - $this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [ - 'is_enabled' => true, - ]) - ->assertOk() - ->assertJsonPath('data.is_enabled', true) - ->assertJsonPath( - 'message', - 'Mostrar en sitio web se activo correctamente', - ); + ->assertJsonPath('data.is_enabled', false); $this->assertDatabaseHas('categorias', [ 'tenant_code' => $tenant->codigo, 'nombre' => $categoryName, - 'is_enabled' => true, + 'is_enabled' => false, ]); } } From 885ab2a6e3a713744e5252a6fda4fdcc66656ae7 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 24 Aug 2026 12:44:37 -0300 Subject: [PATCH 16/17] feat(category-visibility): add success messages for category visibility updates --- .../CategoryVisibilityController.php | 3 +++ .../EntryControllerTest.php | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php index 26d6f7f..c67aef4 100644 --- a/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php +++ b/app/Domains/FiestaFutbolInfantil/Controllers/CategoryVisibilityController.php @@ -39,6 +39,9 @@ class CategoryVisibilityController extends Controller 'data' => [ 'is_enabled' => $model->is_enabled, ], + 'message' => $model->is_enabled + ? 'Mostrar en sitio web se activo correctamente' + : 'Mostrar en sitio web se desactivo correctamente', ]); } diff --git a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php index 4cc9a7b..9b084f4 100644 --- a/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php +++ b/tests/Feature/FiestaFutbolInfantil/EntryControllerTest.php @@ -67,12 +67,26 @@ class EntryControllerTest extends TestCase 'is_enabled' => false, ]) ->assertOk() - ->assertJsonPath('data.is_enabled', false); + ->assertJsonPath('data.is_enabled', false) + ->assertJsonPath( + 'message', + 'Mostrar en sitio web se desactivo correctamente', + ); + + $this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [ + 'is_enabled' => true, + ]) + ->assertOk() + ->assertJsonPath('data.is_enabled', true) + ->assertJsonPath( + 'message', + 'Mostrar en sitio web se activo correctamente', + ); $this->assertDatabaseHas('categorias', [ 'tenant_code' => $tenant->codigo, 'nombre' => $categoryName, - 'is_enabled' => false, + 'is_enabled' => true, ]); } } From 07d2129410d74c2d4dce713f1c2a8cc47dcd85ff Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 25 Aug 2026 09:32:56 -0300 Subject: [PATCH 17/17] feat(catalog): exclude out-of-stock items and update variant visibility logic --- app/Domains/Catalog/Models/CatalogItem.php | 20 ++++++++++--- .../Resources/CatalogFeaturedItemResource.php | 4 +-- .../Resources/CatalogSearchItemResource.php | 5 ++-- .../Catalog/Services/CatalogService.php | 9 ++++++ .../Catalog/Services/FeaturedGroupService.php | 1 + .../Feature/Catalog/CatalogControllerTest.php | 28 +++++++----------- .../CatalogItemDetailControllerTest.php | 29 +++++++++++++------ tests/Feature/Catalog/CatalogSearchTest.php | 20 +++++++++++++ tests/Feature/Catalog/CategoryDetailTest.php | 12 ++++++++ 9 files changed, 94 insertions(+), 34 deletions(-) diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 2828f65..1a3a4ad 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -172,17 +172,29 @@ class CatalogItem extends Model } /** @param Builder $query */ - public function scopeWhereVariantsAvailable(Builder $query): Builder + public function scopeWhereAvailable(Builder $query): Builder { return $query->where(function (Builder $query): void { $query - ->whereDoesntHave('variants') - ->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) + ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value) ->orWhereHas( 'variants.inventory', fn (Builder $inventoryQuery): Builder => $inventoryQuery ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') - ); + ) + ->orWhere(function (Builder $directItemQuery): void { + $directItemQuery + ->whereDoesntHave('variants') + ->where(function (Builder $inventoryQuery): void { + $inventoryQuery + ->whereNull('catalog_items.inventory_id') + ->orWhereHas( + 'inventory', + fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery + ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') + ); + }); + }); }); } diff --git a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php index 4a02a78..3aabebe 100644 --- a/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogFeaturedItemResource.php @@ -46,7 +46,7 @@ class CatalogFeaturedItemResource extends JsonResource $availableStock, $remainingUserQuota, ), - 'variants' => $catalogItem->variants + 'variants' => $catalogItem->visibleVariants() ->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array { $variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited ? null @@ -115,7 +115,7 @@ class CatalogFeaturedItemResource extends JsonResource private function firstImageUrl(CatalogItem $catalogItem): ?string { $attachment = $catalogItem->attachments->first() - ?? $catalogItem->variants + ?? $catalogItem->visibleVariants() ->flatMap(fn (Variant $variant) => $variant->attachments) ->first(); diff --git a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php index 3d1c2d8..86e13b0 100644 --- a/app/Domains/Catalog/Resources/CatalogSearchItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogSearchItemResource.php @@ -16,8 +16,9 @@ class CatalogSearchItemResource extends JsonResource public function toArray(Request $request): array { $availableStock = $this->availableStock(); + $visibleVariants = $this->visibleVariants(); $attachment = $this->attachments->first() - ?? $this->variants + ?? $visibleVariants ->flatMap(fn (Variant $variant) => $variant->attachments) ->first(); @@ -30,7 +31,7 @@ class CatalogSearchItemResource extends JsonResource 'image' => $attachment?->getTemporaryUrl(1440), 'maximum_addable_quantity' => $this->maximumAddable($availableStock), 'unavailable_message' => $this->unavailableMessage($availableStock), - 'variants' => $this->variants + 'variants' => $visibleVariants ->map(function (Variant $variant): array { $variantStock = $this->inventory_policy === InventoryPolicy::Unlimited ? null diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index d5b4f1b..2b46377 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -205,6 +205,13 @@ class CatalogService ]); $visibleVariants = $catalogItem->visibleVariants(); + if ($catalogItem->type === CatalogItemType::Standard + && ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty()) + && ! $catalogItem->isAvailable()) { + throw new NotFoundHttpException('Catalog item is out of stock.'); + } + + $catalogItem->setRelation('variants', $visibleVariants); $selectedVariant = $variantId === null ? $visibleVariants->first() : $visibleVariants->firstWhere('id', $variantId); @@ -231,6 +238,7 @@ class CatalogService $paginator = CatalogItem::query() ->where('tenant_code', $tenant->codigo) + ->whereAvailable() ->where(function (Builder $query) use ($containsPattern): void { $query ->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern]) @@ -280,6 +288,7 @@ class CatalogService return CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('category_id', $category->id) + ->whereAvailable() ->with([ 'attachments', 'inventory', diff --git a/app/Domains/Catalog/Services/FeaturedGroupService.php b/app/Domains/Catalog/Services/FeaturedGroupService.php index 0302a1f..cca82b7 100644 --- a/app/Domains/Catalog/Services/FeaturedGroupService.php +++ b/app/Domains/Catalog/Services/FeaturedGroupService.php @@ -49,6 +49,7 @@ class FeaturedGroupService { $query = CatalogItem::query() ->where('catalog_items.tenant_code', $featuredGroup->tenant_code) + ->whereAvailable() ->where(function (Builder $query): void { $query ->whereDoesntHave('category') diff --git a/tests/Feature/Catalog/CatalogControllerTest.php b/tests/Feature/Catalog/CatalogControllerTest.php index cdc98d2..0147ada 100644 --- a/tests/Feature/Catalog/CatalogControllerTest.php +++ b/tests/Feature/Catalog/CatalogControllerTest.php @@ -73,19 +73,18 @@ class CatalogControllerTest extends TestCase ->assertJsonPath('0.items.0.descripcion', 'Variants description') ->assertJsonPath('0.items.0.precio', '100.00') ->assertJsonPath('0.items.0.maximum_addable_quantity', 7) - ->assertJsonCount(3, '0.items.0.variants') + ->assertJsonCount(2, '0.items.0.variants') ->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4) ->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3) - ->assertJsonPath('0.items.0.variants.2.id', $unavailableVariant->id) - ->assertJsonPath('0.items.0.variants.2.maximum_addable_quantity', 0) - ->assertJsonPath( - '0.items.0.variants.2.unavailable_message', - 'Este producto no tiene stock disponible.', - ) ->assertJsonPath('1.title', 'Row') ->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8) ->assertJsonMissingPath('1.items.data.0.stock_tecnico') ->assertJsonCount(0, '1.items.data.0.variants'); + + $this->assertNotContains( + $unavailableVariant->id, + collect($response->json('0.items.0.variants'))->pluck('id')->all(), + ); } public function test_maximum_addable_quantity_shares_the_authenticated_user_quota_between_variants(): void @@ -133,7 +132,7 @@ class CatalogControllerTest extends TestCase ->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico'); } - public function test_it_includes_out_of_stock_items_with_an_unavailable_message(): void + public function test_it_excludes_out_of_stock_items(): void { $tenant = $this->createTenant('catalog-available-variants'); $group = $this->createGroup( @@ -161,15 +160,10 @@ class CatalogControllerTest extends TestCase $this->getJson("/api/tenants/{$tenant->codigo}/catalog") ->assertOk() - ->assertJsonCount(2, '0.items') - ->assertJsonPath('0.items.0.nombre', 'Unavailable') - ->assertJsonPath('0.items.0.maximum_addable_quantity', 0) - ->assertJsonPath( - '0.items.0.unavailable_message', - 'Este producto no tiene stock disponible.', - ) - ->assertJsonPath('0.items.1.nombre', 'Available') - ->assertJsonPath('0.items.1.unavailable_message', null); + ->assertJsonCount(1, '0.items') + ->assertJsonPath('0.items.0.nombre', 'Available') + ->assertJsonPath('0.items.0.unavailable_message', null) + ->assertJsonMissing(['nombre' => 'Unavailable']); } public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void diff --git a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php index 816cd99..5d8d3a8 100644 --- a/tests/Feature/Catalog/CatalogItemDetailControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemDetailControllerTest.php @@ -48,7 +48,7 @@ class CatalogItemDetailControllerTest extends TestCase $this->assertStringContainsString($itemImage->path, $response->json('data.images.0')); } - public function test_it_lists_unavailable_variants_and_selects_the_first_available_one(): void + public function test_it_omits_unavailable_variants_and_selects_the_first_available_one(): void { Storage::fake('s3'); $tenant = $this->createTenant('detail-default'); @@ -69,14 +69,8 @@ class CatalogItemDetailControllerTest extends TestCase $response ->assertOk() - ->assertJsonCount(2, 'data.variants') - ->assertJsonPath('data.variants.0.id', $firstVariant->id) - ->assertJsonPath('data.variants.0.maximum_addable_quantity', 0) - ->assertJsonPath( - 'data.variants.0.unavailable_message', - 'Este producto no tiene stock disponible.', - ) - ->assertJsonPath('data.variants.1.id', $secondVariant->id) + ->assertJsonCount(1, 'data.variants') + ->assertJsonPath('data.variants.0.id', $secondVariant->id) ->assertJsonPath('data.selected_variant.id', $secondVariant->id) ->assertJsonPath('data.selected_variant.maximum_addable_quantity', 6) ->assertJsonMissingPath('data.selected_variant.stock_tecnico') @@ -84,6 +78,10 @@ class CatalogItemDetailControllerTest extends TestCase $response ->assertJsonMissingPath('data.stock_tecnico') ->assertJsonMissingPath('data.images'); + $this->assertNotContains( + $firstVariant->id, + collect($response->json('data.variants'))->pluck('id')->all(), + ); $this->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0')); $this->assertStringNotContainsString($firstImage->path, $response->json('data.selected_variant.images.0')); $this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0')); @@ -93,6 +91,19 @@ class CatalogItemDetailControllerTest extends TestCase )->assertNotFound(); } + public function test_it_does_not_return_an_out_of_stock_item(): void + { + $tenant = $this->createTenant('detail-out-of-stock'); + $inventory = Inventory::query()->create([ + 'real_stock' => 5, + 'reserved_stock' => 5, + ]); + $item = $this->createItem($tenant, 'Sold out item', $inventory); + + $this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}") + ->assertNotFound(); + } + public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void { Storage::fake('s3'); diff --git a/tests/Feature/Catalog/CatalogSearchTest.php b/tests/Feature/Catalog/CatalogSearchTest.php index 7fd4b07..ba07c2f 100644 --- a/tests/Feature/Catalog/CatalogSearchTest.php +++ b/tests/Feature/Catalog/CatalogSearchTest.php @@ -62,6 +62,24 @@ class CatalogSearchTest extends TestCase $this->createCatalogItem($tenant, "Running {$number}"); } $exactMatch = $this->createCatalogItem($tenant, 'Running'); + $outOfStock = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'running-sold-out', + 'nombre' => 'Running sold out', + 'descripcion' => 'Running sold out description', + 'precio' => 100, + ]); + $outOfStock->variants()->create([ + 'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id, + ]); + CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'inventory_id' => Inventory::query()->create(['real_stock' => 2, 'reserved_stock' => 2])->id, + 'slug' => 'running-direct-sold-out', + 'nombre' => 'Running direct sold out', + 'descripcion' => 'Running direct sold out description', + 'precio' => 100, + ]); $this->createCatalogItem($tenant, 'Unrelated'); $this->createCatalogItem($otherTenant, 'Running foreign'); @@ -76,6 +94,8 @@ class CatalogSearchTest extends TestCase ->assertJsonPath('meta.total', 6) ->assertJsonCount(4, 'data') ->assertJsonPath('data.0.id', $exactMatch->id) + ->assertJsonMissing(['nombre' => 'Running sold out']) + ->assertJsonMissing(['nombre' => 'Running direct sold out']) ->assertJsonMissing(['nombre' => 'Running foreign']) ->assertJsonMissing(['nombre' => 'Unrelated']); } diff --git a/tests/Feature/Catalog/CategoryDetailTest.php b/tests/Feature/Catalog/CategoryDetailTest.php index d7d6784..0b6ab2a 100644 --- a/tests/Feature/Catalog/CategoryDetailTest.php +++ b/tests/Feature/Catalog/CategoryDetailTest.php @@ -29,6 +29,17 @@ class CategoryDetailTest extends TestCase $this->createCatalogItem($tenant, $category, 'Remera C'); $firstItem = $this->createCatalogItem($tenant, $category, 'Remera A'); $secondItem = $this->createCatalogItem($tenant, $category, 'Remera B'); + $outOfStock = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'category_id' => $category->id, + 'slug' => 'remera-agotada', + 'nombre' => 'Remera agotada', + 'descripcion' => 'Sin stock', + 'precio' => 100, + ]); + $outOfStock->variants()->create([ + 'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id, + ]); $this->createCatalogItem($tenant, $otherCategory, 'Pantalón'); $this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}") @@ -44,6 +55,7 @@ class CategoryDetailTest extends TestCase ->assertJsonCount(2, 'data') ->assertJsonPath('data.0.id', $firstItem->id) ->assertJsonPath('data.1.id', $secondItem->id) + ->assertJsonMissing(['nombre' => 'Remera agotada']) ->assertJsonMissing(['nombre' => 'Pantalón']); }