diff --git a/app/Domains/Staff/Controllers/AdminAppStaffController.php b/app/Domains/Staff/Controllers/AdminAppStaffController.php index 5d82f36..f6ef5c4 100644 --- a/app/Domains/Staff/Controllers/AdminAppStaffController.php +++ b/app/Domains/Staff/Controllers/AdminAppStaffController.php @@ -6,6 +6,9 @@ use App\Domains\Staff\Requests\StoreStaffRequest; use App\Domains\Staff\Requests\UpdateStaffRequest; use App\Domains\Staff\Resources\StaffResource; use App\Domains\Staff\Services\StaffService; +use App\Domains\Ticket\Requests\ScanAttemptIndexRequest; +use App\Domains\Ticket\Resources\Scanner\ScanAttemptResource; +use App\Domains\Ticket\Services\ScannerTicketService; use App\Http\Controllers\Controller; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -13,7 +16,10 @@ use Symfony\Component\HttpFoundation\Response; class AdminAppStaffController extends Controller { - public function __construct(private readonly StaffService $staffService) {} + public function __construct( + private readonly StaffService $staffService, + private readonly ScannerTicketService $scannerTicketService, + ) {} public function index(Request $request): AnonymousResourceCollection { @@ -46,4 +52,18 @@ class AdminAppStaffController extends Controller return response()->noContent(); } + + public function scanAttempts( + ScanAttemptIndexRequest $request, + int $staff, + ): AnonymousResourceCollection { + $scanner = $this->staffService->find( + $request->user()->tenant()->firstOrFail(), + $staff, + ); + + return ScanAttemptResource::collection( + $this->scannerTicketService->attemptsByStaff($scanner, $request->validated()) + ); + } } diff --git a/app/Domains/Staff/routes/api.php b/app/Domains/Staff/routes/api.php index ad3481a..470f1fc 100644 --- a/app/Domains/Staff/routes/api.php +++ b/app/Domains/Staff/routes/api.php @@ -6,5 +6,6 @@ use Illuminate\Support\Facades\Route; Route::prefix('v1/adminapp/tenant') ->middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { + Route::get('staff/{staff}/scan-attempts', [AdminAppStaffController::class, 'scanAttempts']); Route::apiResource('staff', AdminAppStaffController::class)->except('show'); }); diff --git a/app/Domains/Ticket/Resources/Scanner/ScanAttemptResource.php b/app/Domains/Ticket/Resources/Scanner/ScanAttemptResource.php index 661f7e5..87f7a2a 100644 --- a/app/Domains/Ticket/Resources/Scanner/ScanAttemptResource.php +++ b/app/Domains/Ticket/Resources/Scanner/ScanAttemptResource.php @@ -18,6 +18,7 @@ class ScanAttemptResource extends JsonResource 'data' => $this->data, 'ticket_id' => $this->ticket_id, 'ticket' => $this->ticket?->ticket, + 'category' => $this->ticket?->sourceCatalogItem?->category?->nombre, 'attempted_at' => $this->created_at, 'resolved_at' => $this->resolved_at, 'result' => $this->result->value, diff --git a/app/Domains/Ticket/Services/ScannerTicketService.php b/app/Domains/Ticket/Services/ScannerTicketService.php index d4579a1..f5dc2bb 100644 --- a/app/Domains/Ticket/Services/ScannerTicketService.php +++ b/app/Domains/Ticket/Services/ScannerTicketService.php @@ -27,7 +27,7 @@ class ScannerTicketService $search = trim((string) ($filters['q'] ?? '')); return ScanAttempt::query() - ->with('ticket') + ->with('ticket.sourceCatalogItem.category') ->where('tenant_code', $scanner->tenant_codigo) ->where('scanner_user_id', $scanner->getKey()) ->when($search !== '', function (Builder $query) use ($search): void { @@ -51,6 +51,58 @@ class ScannerTicketService ->withQueryString(); } + /** + * @param array{q?: string|null, page?: int, per_page?: int} $filters + * @return LengthAwarePaginator + */ + public function attemptsByStaff(User $scanner, array $filters = []): LengthAwarePaginator + { + $search = trim((string) ($filters['q'] ?? '')); + + return ScanAttempt::query() + ->with('ticket.sourceCatalogItem.category') + ->where('tenant_code', $scanner->tenant_codigo) + ->where('scanner_user_id', $scanner->getKey()) + ->when($search !== '', function (Builder $query) use ($search): void { + $attemptedAtDate = $this->parseSearchDate($search); + $attemptedAtDayMonth = $this->parseSearchDayMonth($search); + + $query->where(function (Builder $searchQuery) use ( + $search, + $attemptedAtDate, + $attemptedAtDayMonth, + ): void { + $searchQuery + ->whereHas( + 'ticket.sourceCatalogItem.category', + fn (Builder $categoryQuery): Builder => $categoryQuery + ->where('nombre', 'like', "%{$search}%") + ) + ->orWhere('created_at', 'like', "%{$search}%"); + + if (ctype_digit($search)) { + $searchQuery->orWhere('ticket_id', (int) $search); + } + + if ($attemptedAtDate !== null) { + $searchQuery->orWhereDate('created_at', $attemptedAtDate); + } + + if ($attemptedAtDayMonth !== null) { + $searchQuery->orWhere(function (Builder $dateQuery) use ($attemptedAtDayMonth): void { + $dateQuery + ->whereDay('created_at', $attemptedAtDayMonth['day']) + ->whereMonth('created_at', $attemptedAtDayMonth['month']); + }); + } + }); + }) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginateFromRequest() + ->withQueryString(); + } + public function scanAttemptDetail(User $scanner, int $scanAttemptId): ScanAttempt { $scanAttempt = ScanAttempt::query() @@ -88,6 +140,19 @@ class ScannerTicketService return null; } + /** @return array{day: int, month: int}|null */ + private function parseSearchDayMonth(string $search): ?array + { + if (preg_match('/^(\d{1,2})\/(\d{1,2})$/', $search, $matches) !== 1) { + return null; + } + + $day = (int) $matches[1]; + $month = (int) $matches[2]; + + return checkdate($month, $day, 2000) ? compact('day', 'month') : null; + } + public function detail(User $scanner, string $ticketUuid): Ticket { $query = $this->baseQuery() diff --git a/tests/Feature/Staff/StaffControllerTest.php b/tests/Feature/Staff/StaffControllerTest.php index 9f4ebd0..b8bfb89 100644 --- a/tests/Feature/Staff/StaffControllerTest.php +++ b/tests/Feature/Staff/StaffControllerTest.php @@ -7,10 +7,13 @@ use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; +use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; +use App\Domains\Ticket\Enums\ScanAttemptResult; +use App\Domains\Ticket\Models\ScanAttempt; use App\Domains\Ticket\Models\Ticket; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -264,6 +267,159 @@ class StaffControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/staff')->assertForbidden(); } + public function test_adminapp_can_search_staff_scan_attempts_by_ticket_id_category_and_date(): void + { + $scanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $otherScanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $category = $this->createCategory('Alojamiento'); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'category_id' => $category->id, + 'slug' => 'alojamiento-test', + 'nombre' => 'Hotel', + 'precio' => 100, + ]); + $ticket = Ticket::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'ticket' => (string) Str::uuid(), + 'source_catalog_item_id' => $catalogItem->id, + 'user_id' => $this->admin->id, + ]); + $matching = $this->createScanAttempt($scanner, 'matching-qr', [ + 'ticket_id' => $ticket->id, + 'created_at' => '2026-08-11 14:30:00', + ]); + $this->createScanAttempt($scanner, 'another-qr', [ + 'created_at' => '2026-08-22 22:22:22', + ]); + $this->createScanAttempt($otherScanner, 'matching-qr', [ + 'ticket_id' => $ticket->id, + 'created_at' => '2026-08-11 14:30:00', + ]); + Sanctum::actingAs($this->admin); + + $this->getJson( + "/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q={$ticket->id}" + )->assertOk() + ->assertJsonFragment([ + 'id' => $matching->id, + 'ticket_id' => $ticket->id, + ]); + + $assertSingleMatchingAttempt = function (string $search) use ($scanner, $matching, $ticket): void { + $this->getJson( + "/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q=".urlencode($search).'&per_page=1' + ) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id) + ->assertJsonPath('data.0.ticket_id', $ticket->id) + ->assertJsonPath('data.0.category', 'Alojamiento') + ->assertJsonPath('data.0.result_label', 'Verificado') + ->assertJsonPath('meta.current_page', 1) + ->assertJsonPath('meta.per_page', 1) + ->assertJsonPath('meta.total', 1); + }; + + $assertSingleMatchingAttempt('alojamiento'); + $assertSingleMatchingAttempt('11/08/26'); + } + + public function test_adminapp_cannot_list_scan_attempts_for_non_scanner_staff(): void + { + $customer = User::factory()->create([ + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + Sanctum::actingAs($this->admin); + + $this->getJson("/api/v1/adminapp/tenant/staff/{$customer->id}/scan-attempts") + ->assertNotFound(); + } + + public function test_adminapp_can_search_staff_scan_attempts_by_day_and_month_across_years(): void + { + $scanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $firstMatch = $this->createScanAttempt($scanner, 'first-match', [ + 'created_at' => '2024-09-07 10:00:00', + ]); + $secondMatch = $this->createScanAttempt($scanner, 'second-match', [ + 'created_at' => '2026-09-07 10:00:00', + ]); + $this->createScanAttempt($scanner, 'different-day', [ + 'created_at' => '2026-09-08 10:00:00', + ]); + Sanctum::actingAs($this->admin); + + $this->getJson("/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q=07%2F09") + ->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $secondMatch->id) + ->assertJsonPath('data.1.id', $firstMatch->id) + ->assertJsonPath('meta.total', 2); + } + + public function test_adminapp_datetime_search_matches_text_in_any_datetime_component(): void + { + $scanner = User::factory()->create([ + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $this->tenant->codigo, + ]); + $matches = [ + $this->createScanAttempt($scanner, 'year-match', [ + 'created_at' => '2007-11-12 10:00:08', + ]), + $this->createScanAttempt($scanner, 'month-match', [ + 'created_at' => '2026-07-12 10:00:08', + ]), + $this->createScanAttempt($scanner, 'day-match', [ + 'created_at' => '2026-11-07 10:00:08', + ]), + $this->createScanAttempt($scanner, 'seconds-match', [ + 'created_at' => '2026-11-12 10:00:07', + ]), + ]; + $this->createScanAttempt($scanner, 'no-match', [ + 'created_at' => '2026-11-12 10:00:08', + ]); + Sanctum::actingAs($this->admin); + + $response = $this->getJson( + "/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q=07" + )->assertOk() + ->assertJsonCount(4, 'data') + ->assertJsonPath('meta.total', 4); + + foreach ($matches as $match) { + $response->assertJsonFragment(['id' => $match->id]); + } + } + + /** @param array $attributes */ + private function createScanAttempt(User $scanner, string $data, array $attributes = []): ScanAttempt + { + $attempt = new ScanAttempt; + $attempt->forceFill(array_merge([ + 'tenant_code' => $this->tenant->codigo, + 'scanner_user_id' => $scanner->id, + 'data' => $data, + 'result' => ScanAttemptResult::Accepted, + 'resolved_at' => now(), + ], $attributes)); + $attempt->save(); + + return $attempt; + } + private function createAttachment(string $filename): Attachment { return Attachment::query()->create([