create([ 'username' => 'phase15_super', 'name' => 'Phase15', 'email' => null, 'password' => Hash::make('secret-strong'), 'status' => 0, ]); grantSuperAdminRole($admin); return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; } test('report job create list show and audit log index work for super admin', function (): void { AuditLogger::record('system', 0, 'bootstrap', 'test', null, null, null, null); $token = phase15SuperToken(); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/audit-logs?per_page=5') ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value); $create = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/report-jobs', [ 'report_type' => 'wallet_txns_daily', 'export_format' => 'csv', 'filter_json' => ['currency_code' => 'NPR'], ]); $create->assertOk()->assertJsonPath('code', ErrorCode::Success->value); $id = (int) $create->json('data.id'); expect($id)->toBeGreaterThan(0); expect(ReportJob::query()->whereKey($id)->exists())->toBeTrue(); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/report-jobs/'.$id) ->assertOk() ->assertJsonPath('data.report_type', 'wallet_txns_daily'); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/report-jobs?per_page=10') ->assertOk() ->assertJsonPath('code', ErrorCode::Success->value); expect(AuditLog::query()->where('module_code', 'report_jobs')->exists())->toBeTrue(); }); test('report jobs support module 13 report types and downloadable csv with bom', function (): void { $token = phase15SuperToken(); $create = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/report-jobs', [ 'report_type' => 'daily_profit_summary', 'export_format' => 'csv', 'parameters' => [ 'date_from' => '2026-05-01', 'date_to' => '2026-05-07', ], ]); $create->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.export_format', 'csv'); $id = (int) $create->json('data.id'); expect($id)->toBeGreaterThan(0); $row = ReportJob::query()->whereKey($id)->firstOrFail(); expect($row->output_path)->toContain('每日盈亏汇总_2026-05-01_2026-05-07') ->and($row->output_path)->toEndWith('.csv'); $download = $this->withHeader('Authorization', 'Bearer '.$token) ->get('/api/v1/admin/report-jobs/'.$id.'/download'); $download->assertOk() ->assertHeader('content-type', 'text/csv; charset=UTF-8'); $content = $download->streamedContent(); expect(substr($content, 0, 3))->toBe("\xEF\xBB\xBF"); }); test('report jobs support xlsx export filename convention', function (): void { $token = phase15SuperToken(); $create = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/report-jobs', [ 'report_type' => 'audit_operation_report', 'export_format' => 'xlsx', 'parameters' => [ 'date_from' => '2026-05-01', 'date_to' => '2026-05-31', ], ]); $create->assertOk() ->assertJsonPath('code', ErrorCode::Success->value) ->assertJsonPath('data.export_format', 'xlsx'); $id = (int) $create->json('data.id'); $row = ReportJob::query()->whereKey($id)->firstOrFail(); expect($row->output_path)->toContain('后台操作审计报表_2026-05-01_2026-05-31') ->and($row->output_path)->toEndWith('.xlsx'); $this->withHeader('Authorization', 'Bearer '.$token) ->get('/api/v1/admin/report-jobs/'.$id.'/download') ->assertOk() ->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); }); test('reconcile job create with items and nested items index', function (): void { $token = phase15SuperToken(); $resp = $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/reconcile-jobs', [ 'reconcile_type' => 'wallet_transfer', 'period_start' => '2026-05-01T00:00:00Z', 'period_end' => '2026-05-02T00:00:00Z', 'items' => [ ['side_a_ref' => 'TO-1', 'side_b_ref' => 'MAIN-1', 'difference_amount' => 100, 'status' => 'mismatch'], ['side_a_ref' => 'TO-2', 'side_b_ref' => 'MAIN-2', 'difference_amount' => 0, 'status' => 'matched'], ], ]); $resp->assertOk(); $id = (int) $resp->json('data.id'); expect($id)->toBeGreaterThan(0); $job = ReconcileJob::query()->whereKey($id)->firstOrFail(); expect((int) $job->admin_user_id)->toBeGreaterThan(0); expect($job->items()->count())->toBe(2); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/reconcile-jobs/'.$id.'/items') ->assertOk() ->assertJsonPath('data.meta.total', 2); }); test('admin without report permission receives 403 on report-jobs', function (): void { $role = AdminRole::query()->create(['slug' => 'auditor_test', 'name' => 'Auditor Test']); $ids = DB::table('admin_menu_actions') ->whereIn('permission_code', AdminPermissionBridge::menuActionCodesForLegacy('prd.audit.finance')) ->where('status', 1) ->pluck('id'); foreach ($ids as $mid) { DB::table('admin_role_menu_actions')->insert([ 'role_id' => $role->id, 'menu_action_id' => (int) $mid, ]); } $user = AdminUser::query()->create([ 'username' => 'auditor_only', 'name' => 'Auditor', 'email' => null, 'password' => Hash::make('pw-audit'), 'status' => 0, ]); $siteId = AdminUser::defaultAdminSiteId(); $user->roles()->sync([ (int) $role->id => [ 'site_id' => $siteId, 'granted_at' => now(), ], ]); $token = $user->createToken('test', ['*'], now()->addDay())->plainTextToken; $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/audit-logs') ->assertOk(); $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/v1/admin/report-jobs', ['report_type' => 'x']) ->assertStatus(403) ->assertJsonPath('code', ErrorCode::AdminForbidden->value); });