From a2592987f529ec9214c1854d79b5ef472c107783 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 13 Aug 2026 11:26:13 -0300 Subject: [PATCH] feat(scanner): add scanner category validation logic and related tests --- .../Staff/Requests/StoreStaffRequest.php | 9 +++- .../Staff/Requests/UpdateStaffRequest.php | 8 +++- app/Domains/Staff/Services/StaffService.php | 27 ++++++++--- app/Domains/Tenant/Models/Tenant.php | 8 ++++ .../Tenant/Requests/StoreTenantRequest.php | 1 + .../Tenant/Requests/UpdateTenantRequest.php | 1 + .../Tenant/Resources/TenantResource.php | 1 + .../Ticket/Services/ScannerTicketService.php | 22 ++++++--- ...r_category_validation_to_tenants_table.php | 31 +++++++++++++ ...ScannerCategoryValidationToTenantsTest.php | 38 +++++++++++++++ tests/Feature/Staff/StaffControllerTest.php | 41 +++++++++++++++++ .../Ticket/ScannerTicketControllerTest.php | 46 +++++++++++++++++++ 12 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 database/migrations/2026_08_13_020000_add_scanner_category_validation_to_tenants_table.php create mode 100644 tests/Feature/Migrations/AddScannerCategoryValidationToTenantsTest.php diff --git a/app/Domains/Staff/Requests/StoreStaffRequest.php b/app/Domains/Staff/Requests/StoreStaffRequest.php index 5291b6e..152f923 100644 --- a/app/Domains/Staff/Requests/StoreStaffRequest.php +++ b/app/Domains/Staff/Requests/StoreStaffRequest.php @@ -15,12 +15,17 @@ class StoreStaffRequest extends FormRequest /** @return array */ public function rules(): array { + $categoryRules = $this->user()->tenant()->firstOrFail() + ->requiresScannerCategoryValidation() + ? ['required', 'array', 'min:1'] + : ['sometimes', 'array']; + return [ 'nombre_apellido' => ['required', 'string', 'max:255'], 'dni' => ['required', 'string', 'max:50'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], - 'category_ids' => ['required', 'array', 'min:1'], - 'category_ids.*' => ['required', 'integer', 'distinct', Rule::exists('categorias', 'id')], + 'category_ids' => $categoryRules, + 'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')], ]; } } diff --git a/app/Domains/Staff/Requests/UpdateStaffRequest.php b/app/Domains/Staff/Requests/UpdateStaffRequest.php index 0c8f652..cc1b6c4 100644 --- a/app/Domains/Staff/Requests/UpdateStaffRequest.php +++ b/app/Domains/Staff/Requests/UpdateStaffRequest.php @@ -16,6 +16,10 @@ class UpdateStaffRequest extends FormRequest public function rules(): array { $staffId = (int) $this->route('staff'); + $categoryRules = $this->user()->tenant()->firstOrFail() + ->requiresScannerCategoryValidation() + ? ['required', 'array', 'min:1'] + : ['sometimes', 'array']; return [ 'nombre_apellido' => ['required', 'string', 'max:255'], @@ -26,8 +30,8 @@ class UpdateStaffRequest extends FormRequest 'max:255', Rule::unique('users', 'email')->ignore($staffId), ], - 'category_ids' => ['required', 'array', 'min:1'], - 'category_ids.*' => ['required', 'integer', 'distinct', Rule::exists('categorias', 'id')], + 'category_ids' => $categoryRules, + 'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')], ]; } } diff --git a/app/Domains/Staff/Services/StaffService.php b/app/Domains/Staff/Services/StaffService.php index a73a48e..9d53310 100644 --- a/app/Domains/Staff/Services/StaffService.php +++ b/app/Domains/Staff/Services/StaffService.php @@ -48,9 +48,10 @@ class StaffService /** @param array $data */ public function create(Tenant $tenant, array $data): User { - $this->assertCategoriesBelongToTenant($tenant, $data['category_ids']); + $categoryIds = $this->categoryIdsFor($tenant, $data); + $this->assertCategoriesBelongToTenant($tenant, $categoryIds); - return DB::transaction(function () use ($tenant, $data): User { + return DB::transaction(function () use ($tenant, $data, $categoryIds): User { $staff = User::query()->create([ ...Arr::only($data, ['nombre_apellido', 'dni', 'email']), 'email' => mb_strtolower(trim((string) $data['email'])), @@ -58,7 +59,7 @@ class StaffService 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $tenant->codigo, ]); - $staff->scanCategories()->sync($data['category_ids']); + $staff->scanCategories()->sync($categoryIds); return $staff->load('role', 'scanCategories'); }); @@ -68,13 +69,14 @@ class StaffService public function update(Tenant $tenant, int $staffId, array $data): User { $staff = $this->find($tenant, $staffId); - $this->assertCategoriesBelongToTenant($tenant, $data['category_ids']); + $categoryIds = $this->categoryIdsFor($tenant, $data); + $this->assertCategoriesBelongToTenant($tenant, $categoryIds); - return DB::transaction(function () use ($staff, $data): User { + return DB::transaction(function () use ($staff, $data, $categoryIds): User { $attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']); $attributes['email'] = mb_strtolower(trim((string) $data['email'])); $staff->update($attributes); - $staff->scanCategories()->sync($data['category_ids']); + $staff->scanCategories()->sync($categoryIds); return $staff->load('role', 'scanCategories'); }); @@ -97,6 +99,19 @@ class StaffService ->where('rol_codigo', RoleCode::Scanner->value); } + /** + * @param array $data + * @return array + */ + private function categoryIdsFor(Tenant $tenant, array $data): array + { + if (! $tenant->requiresScannerCategoryValidation()) { + return []; + } + + return $data['category_ids']; + } + /** @param array $categoryIds */ private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds): void { diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index f2ddaf8..f8c07a2 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -38,6 +38,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'display_categories', 'display_seach_bar', 'display_cart', + 'scanner_category_validation_enabled', 'event_title', 'event_location', 'event_date_text', @@ -53,6 +54,7 @@ class Tenant extends Model 'display_categories' => true, 'display_seach_bar' => true, 'display_cart' => true, + 'scanner_category_validation_enabled' => true, ]; public function getRouteKeyName(): string @@ -60,6 +62,11 @@ class Tenant extends Model return 'codigo'; } + public function requiresScannerCategoryValidation(): bool + { + return $this->scanner_category_validation_enabled; + } + /** * Get the attributes that should be cast. * @@ -74,6 +81,7 @@ class Tenant extends Model 'display_categories' => 'boolean', 'display_seach_bar' => 'boolean', 'display_cart' => 'boolean', + 'scanner_category_validation_enabled' => 'boolean', ]; } diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index 91671df..704a474 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -80,6 +80,7 @@ class StoreTenantRequest extends FormRequest 'display_categories' => ['sometimes', 'boolean'], 'display_seach_bar' => ['sometimes', 'boolean'], 'display_cart' => ['sometimes', 'boolean'], + 'scanner_category_validation_enabled' => ['sometimes', 'boolean'], 'website_type_code' => [ 'required_with:extras', 'sometimes', diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index 5adf261..771941e 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -90,6 +90,7 @@ class UpdateTenantRequest extends FormRequest 'display_categories' => ['sometimes', 'boolean'], 'display_seach_bar' => ['sometimes', 'boolean'], 'display_cart' => ['sometimes', 'boolean'], + 'scanner_category_validation_enabled' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index dfb093a..75d626b 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -67,6 +67,7 @@ class TenantResource extends JsonResource 'display_categories' => $this->display_categories, 'display_seach_bar' => $this->display_seach_bar, 'display_cart' => $this->display_cart, + 'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled, 'social_media' => $this->whenLoaded( 'socialMedia', fn () => $this->socialMedia diff --git a/app/Domains/Ticket/Services/ScannerTicketService.php b/app/Domains/Ticket/Services/ScannerTicketService.php index 050bb2b..23c15c4 100644 --- a/app/Domains/Ticket/Services/ScannerTicketService.php +++ b/app/Domains/Ticket/Services/ScannerTicketService.php @@ -69,12 +69,14 @@ class ScannerTicketService public function detail(User $scanner, string $ticketUuid): Ticket { - $categoryIds = $this->scannerCategoryIds($scanner); - - return $this->baseQuery() + $query = $this->baseQuery() ->where('tenant_code', $scanner->tenant_codigo) - ->where('ticket', $ticketUuid) - ->where(function (Builder $query) use ($scanner, $categoryIds): void { + ->where('ticket', $ticketUuid); + + if ($scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation()) { + $categoryIds = $this->scannerCategoryIds($scanner); + + $query->where(function (Builder $query) use ($scanner, $categoryIds): void { $query ->where('scanner_user_id', $scanner->getKey()) ->orWhereHas( @@ -82,8 +84,10 @@ class ScannerTicketService fn (Builder $catalogItemQuery): Builder => $catalogItemQuery ->whereIn('category_id', $categoryIds) ); - }) - ->firstOrFail(); + }); + } + + return $query->firstOrFail(); } public function scan(User $scanner, string $ticketUuid): Ticket @@ -153,6 +157,10 @@ class ScannerTicketService private function scannerCanScan(User $scanner, Ticket $ticket): bool { + if (! $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation()) { + return true; + } + $categoryId = $ticket->sourceCatalogItem?->category_id; return $categoryId !== null diff --git a/database/migrations/2026_08_13_020000_add_scanner_category_validation_to_tenants_table.php b/database/migrations/2026_08_13_020000_add_scanner_category_validation_to_tenants_table.php new file mode 100644 index 0000000..f4cd67e --- /dev/null +++ b/database/migrations/2026_08_13_020000_add_scanner_category_validation_to_tenants_table.php @@ -0,0 +1,31 @@ +boolean('scanner_category_validation_enabled') + ->default(true) + ->after('display_cart'); + }); + + DB::table('tenants') + ->where('codigo', self::DESFILE_TENANT_CODE) + ->update(['scanner_category_validation_enabled' => false]); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn('scanner_category_validation_enabled'); + }); + } +}; diff --git a/tests/Feature/Migrations/AddScannerCategoryValidationToTenantsTest.php b/tests/Feature/Migrations/AddScannerCategoryValidationToTenantsTest.php new file mode 100644 index 0000000..4aae5b0 --- /dev/null +++ b/tests/Feature/Migrations/AddScannerCategoryValidationToTenantsTest.php @@ -0,0 +1,38 @@ +down(); + $migration->up(); + + $this->assertDatabaseHas('tenants', [ + 'codigo' => 'desfile_pura_tendencia', + 'scanner_category_validation_enabled' => false, + ]); + + DB::table('tenants')->insert([ + 'codigo' => 'scanner-validation-default', + 'nombre' => 'Scanner validation default', + 'dominio' => 'scanner-validation-default.test', + ]); + + $this->assertDatabaseHas('tenants', [ + 'codigo' => 'scanner-validation-default', + 'scanner_category_validation_enabled' => true, + ]); + } +} diff --git a/tests/Feature/Staff/StaffControllerTest.php b/tests/Feature/Staff/StaffControllerTest.php index 2b678a9..1192dc4 100644 --- a/tests/Feature/Staff/StaffControllerTest.php +++ b/tests/Feature/Staff/StaffControllerTest.php @@ -119,6 +119,47 @@ class StaffControllerTest extends TestCase ->assertJsonValidationErrors('category_ids'); } + public function test_categories_are_required_by_default_for_scanner_staff(): void + { + Sanctum::actingAs($this->admin); + + $this->postJson('/api/v1/adminapp/tenant/staff', [ + 'nombre_apellido' => 'Grace Hopper', + 'dni' => '87654321', + 'email' => 'grace@example.test', + 'category_ids' => [], + ])->assertUnprocessable() + ->assertJsonValidationErrors('category_ids'); + } + + public function test_admin_can_manage_staff_without_categories_when_tenant_disables_validation(): void + { + $this->tenant->update(['scanner_category_validation_enabled' => false]); + Sanctum::actingAs($this->admin); + + $response = $this->postJson('/api/v1/adminapp/tenant/staff', [ + 'nombre_apellido' => 'Grace Hopper', + 'dni' => '87654321', + 'email' => 'grace@example.test', + ])->assertCreated() + ->assertJsonCount(0, 'data.categories'); + + $staffId = $response->json('data.id'); + $category = $this->createCategory('Entradas'); + + $this->putJson("/api/v1/adminapp/tenant/staff/{$staffId}", [ + 'nombre_apellido' => 'Grace Murray Hopper', + 'dni' => '87654321', + 'email' => 'grace@example.test', + 'category_ids' => [$category->id], + ])->assertOk() + ->assertJsonCount(0, 'data.categories'); + + $this->assertDatabaseMissing('category_scanners', [ + 'user_id' => $staffId, + ]); + } + public function test_customer_cannot_manage_staff(): void { Sanctum::actingAs(User::factory()->create([ diff --git a/tests/Feature/Ticket/ScannerTicketControllerTest.php b/tests/Feature/Ticket/ScannerTicketControllerTest.php index 051785c..6054611 100644 --- a/tests/Feature/Ticket/ScannerTicketControllerTest.php +++ b/tests/Feature/Ticket/ScannerTicketControllerTest.php @@ -254,6 +254,52 @@ class ScannerTicketControllerTest extends TestCase $this->assertNull($ticket->fresh()->used_at); } + public function test_scanner_can_read_and_scan_any_category_when_tenant_disables_validation(): void + { + $this->tenant->update(['scanner_category_validation_enabled' => false]); + $this->scanner->scanCategories()->detach(); + $otherCategory = Category::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'nombre' => 'Comidas', + ]); + $ticket = $this->createTicket( + 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa', + [], + $otherCategory, + ); + Sanctum::actingAs($this->scanner); + + $this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}") + ->assertOk() + ->assertJsonPath('data.id', $ticket->id); + + $this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan") + ->assertOk() + ->assertJsonPath('data.scanner_user_id', $this->scanner->id); + } + + public function test_disabled_category_validation_does_not_allow_scanning_another_tenant(): void + { + $this->tenant->update(['scanner_category_validation_enabled' => false]); + $otherTenant = $this->createTenant('other'); + $foreignCategory = Category::query()->create([ + 'tenant_code' => $otherTenant->codigo, + 'nombre' => 'Externas', + ]); + $foreignTicket = $this->createTicket( + 'bbbbbbbb-1111-4111-8111-bbbbbbbbbbbb', + [], + $foreignCategory, + $otherTenant, + ); + Sanctum::actingAs($this->scanner); + + $this->getJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}") + ->assertNotFound(); + $this->postJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}/scan") + ->assertNotFound(); + } + /** @param array $attributes */ private function createTicket( string $uuid,