CaiWuXiTong/tests/Feature/Business/StoreViolationAlertPageTest.php
苏木 0eb2c844d8 店铺违规预警:预警级别/责任人/处理截止/超时/推送状态 + 处理状态四档 + 群推送接口 + 预览统计页
- 新增违规字典表 store_violation_rules 与推送日志表 store_violation_push_logs;统一表增加预警派生字段(迁移,全部可空带索引)
- 预警计算:按平台+违规类型匹配字典(去括号后 相等/前缀/分词/宽松/关键词),匹配不到=不提醒;责任人取店铺账号镜像运营负责人并回退店铺档案;处理截止=申诉截止优先,否则级别天数(P0+1/P1+2/P2+3,当天23:59:59)
- 页面:新增预警级别(红/橙/绿标签)、责任人、处理截止、是否超时、推送状态5列;级别/责任人(含我的待办)/超时筛选;默认视图(需提醒&未处理)与全部数据切换;行内 认领/完成/忽略(忽略必填原因)
- 处理状态扩为 未处理/处理中/处理完成/已忽略,导入兼容旧值(已处理/无需处理)
- 新增「预览统计」页面:平台Tab联动 + 4张KPI卡(今日新增/待处理/P0未处理/已超时)+ 需提醒未处理预览列表与认领
- 群推送先留接口:P0/P1 每天09:00起每5分钟即时推送(责任人、处理建议、去认领签名链接);P2每日10:00汇总(含超时未认领点名);防重与推送日志;新命令 push-instant/push-digest
- 同步与导入自动计算派生字段;新增 store-violations:recalc-alerts 重算命令;生产 cron 已登记推送任务
- 测试:StoreViolation 系列 + 部署调度 + 导航 共42项/453断言通过
2026-10-13 19:52:44 +08:00

670 lines
31 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\Feature\FeatureTestCase;
/**
* 财务经营风险管控 › 店铺违规信息预警(统一表头 + 查询 + 导入导出)。
*/
class StoreViolationAlertPageTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
// 导入接口带 throttle:import 中间件,测试内多次上传需要放宽限制。
config(['app.throttles.import' => 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<list<mixed>> $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()
);
}
}