1000]); config(['database.connections.business_mirror' => [ 'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', ]]); DB::purge('business_mirror'); Schema::connection('business_mirror')->create('store_violation_records', function ($table): void { $table->bigIncrements('id'); $table->string('platform', 32); $table->string('shop_name', 191)->default(''); $table->string('owner_name', 64)->nullable(); $table->string('violation_type', 500)->nullable(); $table->string('violation_category', 64)->nullable(); $table->string('violation_reason', 500)->nullable(); $table->string('disposition', 500)->nullable(); $table->string('violation_no', 100); $table->date('violation_date')->nullable(); $table->decimal('points_deducted', 10, 2)->nullable(); $table->string('status', 64)->nullable(); $table->date('penalty_date')->nullable(); $table->date('appeal_deadline')->nullable(); $table->dateTime('handling_deadline')->nullable(); $table->string('handling_status', 32)->default('未处理'); $table->string('ignore_reason', 191)->nullable(); $table->string('source', 16)->default('crawl'); $table->string('window_name', 191)->nullable(); $table->date('crawl_date')->nullable(); $table->dateTime('synced_at')->nullable(); $table->boolean('needs_alert')->default(false); $table->string('alert_level', 8)->nullable(); $table->unsignedBigInteger('alert_rule_id')->nullable(); $table->string('alert_action', 500)->nullable(); $table->string('alert_channel', 64)->nullable(); $table->unsignedInteger('handling_hours')->nullable(); $table->string('push_status', 16)->default('未推送'); $table->dateTime('last_pushed_at')->nullable(); $table->timestamps(); $table->unique(['platform', 'violation_no']); }); Schema::connection('business_mirror')->create('store_violation_rules', function ($table): void { $table->bigIncrements('id'); $table->string('platform', 32); $table->string('violation_category', 64)->nullable(); $table->string('violation_type', 500)->nullable(); $table->string('match_keywords', 500)->nullable(); $table->string('alert_level', 8)->nullable(); $table->boolean('needs_alert')->default(true); $table->string('suggested_action', 500)->nullable(); $table->unsignedInteger('handling_hours')->nullable(); $table->string('remind_channel', 191)->nullable(); $table->unsignedInteger('sort')->default(0); $table->boolean('enabled')->default(true); $table->string('remark', 191)->nullable(); $table->timestamps(); }); Schema::connection('business_mirror')->create('ops_fys_shop_info', function ($table): void { $table->bigIncrements('id'); $table->string('shopName', 191)->nullable(); $table->string('shopHead', 64)->nullable(); }); Schema::connection('business_mirror')->create('store_violation_push_logs', function ($table): void { $table->bigIncrements('id'); $table->string('push_no', 32)->unique(); $table->dateTime('pushed_at')->nullable(); $table->string('channel', 32)->default('dingtalk_robot'); $table->string('message_type', 16)->default('instant'); $table->string('platform', 32)->nullable(); $table->string('violation_no', 100)->nullable(); $table->string('message_digest', 500)->nullable(); $table->string('mentioned', 191)->nullable(); $table->string('result', 16)->default('success'); $table->string('error', 500)->nullable(); $table->timestamps(); }); config(['store_violations.push' => [ 'enabled' => true, 'channel' => 'dingtalk_robot', 'webhook' => 'https://oapi.dingtalk.com/robot/send?access_token=test-token', 'secret' => '', 'window_start' => '09:00', 'window_end' => '21:00', 'instant_levels' => ['P0', 'P1'], 'digest_time' => '10:00', 'at_all' => false, ]]); Schema::connection('business_mirror')->create('business_mirror_table_states', function ($table): void { $table->string('dataset_key', 191)->primary(); $table->string('source_key', 64)->default(''); $table->string('source_object', 191)->default(''); $table->string('target_table', 64)->default(''); $table->string('status', 24)->default('pending'); $table->dateTime('last_attempted_at')->nullable(); $table->dateTime('last_succeeded_at')->nullable(); $table->unsignedBigInteger('mirror_row_count')->default(0); $table->string('last_error_code', 120)->nullable(); $table->dateTime('initial_completed_at')->nullable(); $table->timestamps(); }); DB::connection('business_mirror')->table('store_violation_rules')->insert([ ['id' => 1, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '缺货/无货', 'match_keywords' => null, 'alert_level' => 'P1', 'needs_alert' => true, 'suggested_action' => '联系客户补发', 'handling_hours' => null, 'remind_channel' => null, 'sort' => 0, 'enabled' => true, 'remark' => null, 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], ['id' => 2, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '订单延迟发货', 'match_keywords' => null, 'alert_level' => 'P1', 'needs_alert' => true, 'suggested_action' => '致电客户协商', 'handling_hours' => 24, 'remind_channel' => null, 'sort' => 10, 'enabled' => true, 'remark' => null, 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], ['id' => 3, 'platform' => 'jingxi', 'violation_category' => '发票与考核', 'violation_type' => '发票违规', 'match_keywords' => null, 'alert_level' => 'P0', 'needs_alert' => true, 'suggested_action' => '立即补开发票', 'handling_hours' => null, 'remind_channel' => null, 'sort' => 0, 'enabled' => true, 'remark' => null, 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], ['id' => 4, 'platform' => 'jd', 'violation_category' => '发货履约', 'violation_type' => '订单存在揽收超时行为', 'match_keywords' => null, 'alert_level' => null, 'needs_alert' => true, 'suggested_action' => null, 'handling_hours' => null, 'remind_channel' => null, 'sort' => 20, 'enabled' => true, 'remark' => null, 'created_at' => '2026-10-13 09:00:00', 'updated_at' => '2026-10-13 09:00:00'], ]); DB::connection('business_mirror')->table('ops_fys_shop_info')->insert([ ['shopName' => '京东-亿家达全屋旗舰店', 'shopHead' => '张三'], ['shopName' => '京东-卓禾卧室家具旗舰店', 'shopHead' => '李四'], ['shopName' => '京东-纪辰家具旗舰店', 'shopHead' => '王五'], ['shopName' => '京喜-久伴吾家具京喜自营工厂店', 'shopHead' => '幽竹'], ]); DB::connection('business_mirror')->table('store_violation_records')->insert([ [ 'platform' => 'jd', 'shop_name' => '京东-亿家达全屋旗舰店', 'owner_name' => '张三', 'violation_type' => '延迟发货/缺货无货', 'violation_category' => '发货履约', 'violation_reason' => '订单存在延迟发货、缺货无货', 'disposition' => '警告', 'violation_no' => 'JD202610040001', 'violation_date' => '2026-10-04', 'points_deducted' => 2, 'status' => '待处理', 'penalty_date' => null, 'appeal_deadline' => '2026-10-11', 'handling_deadline' => '2026-10-12 23:59:59', 'handling_status' => '未处理', 'ignore_reason' => null, 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null, 'needs_alert' => true, 'alert_level' => 'P1', 'alert_rule_id' => 1, 'alert_action' => '联系客户补发', 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '未推送', 'last_pushed_at' => null, 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], [ 'platform' => 'jd', 'shop_name' => '京东-卓禾卧室家具旗舰店', 'owner_name' => '李四', 'violation_type' => '订单存在揽收超时行为', 'violation_category' => '发货履约', 'violation_reason' => '订单存在揽收超时行为', 'disposition' => null, 'violation_no' => 'JD202610040002', 'violation_date' => '2026-10-03', 'points_deducted' => null, 'status' => '超期未申诉', 'penalty_date' => '2026-10-05', 'appeal_deadline' => '2026-10-08', 'handling_deadline' => '2026-10-14 23:59:59', 'handling_status' => '处理中', 'ignore_reason' => null, 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null, 'needs_alert' => true, 'alert_level' => 'P2', 'alert_rule_id' => 4, 'alert_action' => null, 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '已推送', 'last_pushed_at' => '2026-10-13 09:10:00', 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], [ 'platform' => 'jd', 'shop_name' => '京东-无提醒示例店', 'owner_name' => null, 'violation_type' => '普通通知事项', 'violation_category' => '其他', 'violation_reason' => null, 'disposition' => null, 'violation_no' => 'JD202610040003', 'violation_date' => '2026-10-05', 'points_deducted' => null, 'status' => '待处理', 'penalty_date' => null, 'appeal_deadline' => null, 'handling_deadline' => null, 'handling_status' => '未处理', 'ignore_reason' => null, 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-05', 'synced_at' => null, 'needs_alert' => false, 'alert_level' => null, 'alert_rule_id' => null, 'alert_action' => null, 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '未推送', 'last_pushed_at' => null, 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], [ 'platform' => 'jingxi', 'shop_name' => '京喜-久伴吾家具京喜自营工厂店', 'owner_name' => '幽竹', 'violation_type' => '发票违规', 'violation_category' => '发票与考核', 'violation_reason' => null, 'disposition' => '维持原判', 'violation_no' => 'JX202610040001', 'violation_date' => '2026-10-04', 'points_deducted' => null, 'status' => '已处置', 'penalty_date' => null, 'appeal_deadline' => null, 'handling_deadline' => '2026-10-13 23:59:59', 'handling_status' => '处理完成', 'ignore_reason' => null, 'source' => 'crawl', 'window_name' => null, 'crawl_date' => '2026-10-04', 'synced_at' => null, 'needs_alert' => true, 'alert_level' => 'P0', 'alert_rule_id' => 3, 'alert_action' => '立即补开发票', 'alert_channel' => null, 'handling_hours' => null, 'push_status' => '已推送', 'last_pushed_at' => '2026-10-13 09:10:00', 'created_at' => '2026-10-05 08:00:00', 'updated_at' => '2026-10-05 08:00:00', ], ]); DB::connection('business_mirror')->table('business_mirror_table_states')->insert([ 'dataset_key' => StoreViolationRecordRepository::DATASET_KEY, 'source_key' => 'operations_replica', 'source_object' => 'rpt.crawl_shop_violation', 'target_table' => StoreViolationRecordRepository::TABLE, 'status' => 'succeeded', 'last_succeeded_at' => '2026-10-05 05:30:00', 'mirror_row_count' => 3, 'created_at' => '2026-10-05 05:30:00', 'updated_at' => '2026-10-05 05:30:00', ]); } public function test_page_renders_unified_headers_and_platform_rows(): void { $this->allowPage(); $page = $this->loginAs()->get(route('business.pages.show', [ 'section' => 'financial-risk-control', 'page' => 'store-violation-alert', 'platform' => 'jd', ])); $page->assertOk() ->assertSee('店铺名') ->assertSee('预警级别') ->assertSee('违规类型') ->assertSee('违规分类') ->assertSee('违规原因') ->assertSee('处置方式') ->assertSee('违规单号') ->assertSee('违规日期') ->assertSee('扣分') ->assertSee('责任人') ->assertSee('申诉截止日期') ->assertSee('处理截止') ->assertSee('是否超时') ->assertSee('推送状态') ->assertSee('处理状态') ->assertSee('操作') ->assertSee('P1-较重') ->assertSee('张三') ->assertSee('已超时') ->assertSee('未推送') ->assertSee('默认视图') ->assertSee('订单存在延迟发货、缺货无货') ->assertSee('JD202610040001') ->assertSee('京东-亿家达全屋旗舰店') ->assertSee('data-platform-panel="jingxi"', false); } public function test_dingtalk_entry_shows_only_this_page_without_the_global_menu(): void { $this->allowPage(); // 钉钉工作台入口:整个「财务经营风险管控」目录都隐藏全局菜单, // 只保留目录自己的页签(店铺违规信息预警 / 流程统计)。 $this->loginAs() ->withHeaders(['User-Agent' => 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 DingTalk(9.0.1-macOS-arm64) webDt/PC']) ->get(route('business.pages.show', [ 'section' => 'financial-risk-control', 'page' => 'store-violation-alert', ])) ->assertOk() ->assertDontSee('id="sidenav-main"', false) ->assertDontSee('data-open-tabs', false) ->assertSeeText('店铺违规信息预警'); // 同目录下的另一个页面(店铺流程)在钉钉里同样是单页模式。 $this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 DingTalk(9.0.1-macOS-arm64) webDt/PC']) ->get(route('business.pages.show', [ 'section' => 'financial-risk-control', 'page' => 'process-statistics', ])) ->assertOk() ->assertDontSee('id="sidenav-main"', false) ->assertSeeText('店铺流程'); // 网页端(非钉钉)照旧显示完整菜单。 $this->withHeaders(['User-Agent' => 'Mozilla/5.0 (Macintosh) Safari/605.1.15']) ->get(route('business.pages.show', [ 'section' => 'financial-risk-control', 'page' => 'store-violation-alert', ])) ->assertOk() ->assertSee('id="sidenav-main"', false); } public function test_page_filters_by_keyword(): void { $this->allowPage(); $page = $this->loginAs()->get(route('business.pages.show', [ 'section' => 'financial-risk-control', 'page' => 'store-violation-alert', 'platform' => 'jd', 'keyword' => '卓禾', 'view' => 'all', ])); $page->assertOk() ->assertSee('JD202610040002') ->assertDontSee('JD202610040001') ->assertDontSee('JX202610040001'); } public function test_default_view_only_shows_alert_and_pending_records(): void { $this->allowPage(); $default = $this->loginAs()->get(route('business.pages.show', [ 'section' => 'financial-risk-control', 'page' => 'store-violation-alert', 'platform' => 'jd', ])); $default->assertOk() ->assertSee('JD202610040001') ->assertDontSee('JD202610040002') ->assertDontSee('JD202610040003'); $all = $this->loginAs()->get(route('business.pages.show', [ 'section' => 'financial-risk-control', 'page' => 'store-violation-alert', 'platform' => 'jd', 'view' => 'all', ])); $all->assertOk() ->assertSee('JD202610040001') ->assertSee('JD202610040002') ->assertSee('JD202610040003') ->assertSee('不提醒'); } public function test_export_streams_unified_headers(): void { $this->allowPage(); $response = $this->loginAs()->get(route('business.store-violation-alert.export', [ 'platform' => 'jd', 'keyword' => '亿家达', ])); $response->assertOk(); $this->assertStringContainsString( 'spreadsheetml', (string) $response->headers->get('content-type'), ); $binary = $response->streamedContent(); $this->assertNotSame('', $binary); $temp = tempnam(sys_get_temp_dir(), 'store-violation-export'); file_put_contents($temp, $binary); $sheet = IOFactory::load($temp)->getActiveSheet(); $headers = $sheet->rangeToArray('A1:R1')[0]; $this->assertSame( [ '店铺名', '预警级别', '违规类型', '违规分类', '违规原因', '处置方式', '违规单号', '违规日期', '扣分', '责任人', '状态', '处罚日期', '申诉截止日期', '处理截止', '是否超时', '推送状态', '处理状态', '忽略原因', ], $headers, ); $this->assertSame('P1-较重', $sheet->getCell('B2')->getValue()); $this->assertSame('订单存在延迟发货、缺货无货', $sheet->getCell('E2')->getValue()); $this->assertSame('警告', $sheet->getCell('F2')->getValue()); $this->assertSame('JD202610040001', $sheet->getCell('G2')->getValue()); $this->assertSame('张三', $sheet->getCell('J2')->getValue()); $this->assertSame('已超时', $sheet->getCell('O2')->getValue()); unlink($temp); } public function test_import_preview_maps_headers_and_commit_upserts(): void { $this->allowPage(); $file = $this->makeWorkbook([ ['店铺', '违规编号', '违规内容', '违规日期', '扣分'], ['京东-纪辰家具旗舰店', 'JD202610040099', '订单延迟发货', '2026-10-02', 1], ['京东-亿家达全屋旗舰店', 'JD202610040001', '延迟发货/缺货无货', '2026-10-04', 2], ['缺少单号的店铺', '', '无效行', '2026-10-02', ''], ]); $preview = $this->loginAs()->post(route('business.store-violation-alert.import.preview'), [ 'platform' => 'jd', 'import' => $file, ]); $preview->assertRedirect(); $payload = $preview->getSession()->get('store_violation_preview'); $this->assertIsArray($payload); $this->assertSame(1, $payload['summary']['new']); $this->assertSame(1, $payload['summary']['same']); $this->assertSame(1, $payload['summary']['invalid']); $this->assertNotEmpty($payload['token']); $commit = $this->loginAs()->post(route('business.store-violation-alert.import.commit'), [ 'preview_token' => $payload['token'], ]); $commit->assertRedirect(); $record = DB::connection('business_mirror') ->table('store_violation_records') ->where('platform', 'jd') ->where('violation_no', 'JD202610040099') ->first(); $this->assertNotNull($record); $this->assertSame('发货履约', $record->violation_category); $this->assertSame('京东-纪辰家具旗舰店', $record->shop_name); $this->assertSame('import', $record->source); } public function test_import_preview_requires_unified_required_columns(): void { $this->allowPage(); $file = $this->makeWorkbook([ ['店铺名', '违规类型'], ['京东-纪辰家具旗舰店', '订单延迟发货'], ]); $response = $this->loginAs()->post(route('business.store-violation-alert.import.preview'), [ 'platform' => 'jd', 'import' => $file, ]); $response->assertRedirect(); $response->assertSessionHasErrors('import'); } public function test_import_maps_reason_and_disposition_columns(): void { $this->allowPage(); $file = $this->makeWorkbook([ ['店铺名', '违规单号', '违规原因', '处置方式'], ['京东-纪辰家具旗舰店', 'JD202610040088', '订单存在揽收超时行为', '警告;整改'], ]); $preview = $this->loginAs()->post(route('business.store-violation-alert.import.preview'), [ 'platform' => 'jd', 'import' => $file, ]); $preview->assertRedirect(); $payload = $preview->getSession()->get('store_violation_preview'); $this->assertIsArray($payload); $this->assertSame(1, $payload['summary']['new']); $commit = $this->loginAs()->post(route('business.store-violation-alert.import.commit'), [ 'preview_token' => $payload['token'], ]); $commit->assertRedirect(); $record = DB::connection('business_mirror') ->table('store_violation_records') ->where('platform', 'jd') ->where('violation_no', 'JD202610040088') ->first(); $this->assertNotNull($record); $this->assertSame('订单存在揽收超时行为', $record->violation_reason); $this->assertSame('警告;整改', $record->disposition); // 未提供违规类型时,违规分类按违规原因关键词自动归类。 $this->assertSame('发货履约', $record->violation_category); // 预警派生字段按字典自动计算(无级别配置的规则默认 P2)。 $this->assertTrue((bool) $record->needs_alert); $this->assertSame('P2', $record->alert_level); } public function test_import_commit_calculates_alert_fields_from_dictionary(): void { $this->allowPage(); $file = $this->makeWorkbook([ ['店铺名', '违规单号', '违规类型', '违规日期'], ['京东-纪辰家具旗舰店', 'JD202610040099', '订单延迟发货', '2026-10-02'], ]); $preview = $this->loginAs()->post(route('business.store-violation-alert.import.preview'), [ 'platform' => 'jd', 'import' => $file, ]); $preview->assertRedirect(); $payload = $preview->getSession()->get('store_violation_preview'); $this->loginAs()->post(route('business.store-violation-alert.import.commit'), [ 'preview_token' => $payload['token'], ])->assertRedirect(); $record = DB::connection('business_mirror') ->table('store_violation_records') ->where('platform', 'jd') ->where('violation_no', 'JD202610040099') ->first(); $this->assertNotNull($record); $this->assertTrue((bool) $record->needs_alert); $this->assertSame('P1', $record->alert_level); $this->assertSame('王五', $record->owner_name); // 字典时效 24 小时:违规日期 2026-10-02 起算。 $this->assertSame('2026-10-03 00:00:00', (string) $record->handling_deadline); } public function test_update_handling_status_requires_reason_when_ignored(): void { $this->allowPage(); $this->withExceptionHandling(); $record = DB::connection('business_mirror')->table('store_violation_records') ->where('violation_no', 'JD202610040001')->first(); // 已忽略缺原因 → 报错且状态不变 $this->loginAs()->post(route('business.store-violation-alert.update'), [ 'id' => $record->id, 'handling_status' => '已忽略', ])->assertSessionHasErrors('ignore_reason'); $this->assertSame('未处理', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status')); // 认领 → 处理中 $this->loginAs()->post(route('business.store-violation-alert.update'), [ 'id' => $record->id, 'handling_status' => '处理中', ])->assertRedirect(); $this->assertSame('处理中', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status')); // 忽略(带原因) $this->loginAs()->post(route('business.store-violation-alert.update'), [ 'id' => $record->id, 'handling_status' => '已忽略', 'ignore_reason' => '误报,无需处理', ])->assertRedirect(); $updated = DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->first(); $this->assertSame('已忽略', $updated->handling_status); $this->assertSame('误报,无需处理', $updated->ignore_reason); } public function test_claim_route_marks_record_processing_via_signed_link(): void { $this->allowPage(); $this->withExceptionHandling(); $this->loginAs(); $record = DB::connection('business_mirror')->table('store_violation_records') ->where('violation_no', 'JD202610040001')->first(); $url = URL::signedRoute('business.store-violation-alert.claim', [ 'record' => $record->id, 'company_id' => $this->company->id, ]); $this->get($url)->assertRedirect(); $this->assertSame('处理中', DB::connection('business_mirror')->table('store_violation_records')->where('id', $record->id)->value('handling_status')); // 幂等:再次访问不报错。 $this->get($url)->assertRedirect(); // 无签名 → 403。 $this->get(route('business.store-violation-alert.claim', [ 'record' => $record->id, 'company_id' => $this->company->id, ])) ->assertStatus(403); } public function test_preview_statistics_page_renders_kpi_cards_and_pending_rows(): void { $this->allowPage(); $response = $this->loginAs()->get(route('business.pages.show', [ 'section' => 'financial-risk-control', 'page' => 'preview-statistics', 'platform' => 'jd', ])); $response->assertOk() ->assertSeeText('预览统计') ->assertSeeText('今日新增') ->assertSeeText('待处理总数') ->assertSeeText('P0 未处理') ->assertSeeText('已超时') ->assertSeeText('待处理 1 条') ->assertSee('JD202610040001') ->assertDontSee('JD202610040002') ->assertDontSee('JD202610040003') ->assertSee('data-risk-preview-tabs', false); } public function test_push_instant_sends_p0_p1_and_marks_pushed(): void { $this->allowPage(); Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); $this->artisan('store-violations:push-instant')->assertExitCode(0); $record = DB::connection('business_mirror')->table('store_violation_records') ->where('violation_no', 'JD202610040001')->first(); $this->assertSame('已推送', $record->push_status); $this->assertNotNull($record->last_pushed_at); $log = DB::connection('business_mirror')->table('store_violation_push_logs') ->where('message_type', 'instant')->first(); $this->assertNotNull($log); $this->assertSame('JD202610040001', $log->violation_no); $this->assertSame('success', $log->result); $this->assertSame('张三', $log->mentioned); Http::assertSent(function ($request): bool { $text = (string) ($request['markdown']['text'] ?? ''); return str_contains($request->url(), 'oapi.dingtalk.com/robot/send') && str_contains($text, 'P1-较重') && str_contains($text, '去认领'); }); } public function test_push_digest_sends_daily_summary(): void { $this->allowPage(); Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); $this->artisan('store-violations:push-digest')->assertExitCode(0); $log = DB::connection('business_mirror')->table('store_violation_push_logs') ->where('message_type', 'daily')->first(); $this->assertNotNull($log); $this->assertSame('success', $log->result); Http::assertSent(function ($request): bool { $text = (string) ($request['markdown']['text'] ?? ''); return str_contains($text, '每日汇总') && str_contains($text, '已超时未认领'); }); } /** @param list> $rows */ private function makeWorkbook(array $rows): UploadedFile { $spreadsheet = new Spreadsheet(); $spreadsheet->getActiveSheet()->fromArray($rows, null, 'A1'); $path = tempnam(sys_get_temp_dir(), 'store-violation-import') . '.xlsx'; (new Xlsx($spreadsheet))->save($path); $binary = (string) file_get_contents($path); unlink($path); return UploadedFile::fake()->createWithContent('店铺违规信息.xlsx', $binary); } private function allowPage(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); } }