user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); } public function test_group_robot_configuration_is_encrypted_and_rendered_without_secrets(): void { $this->loginAs() ->post(route('business.supplier-negative-alerts.dingtalk-group.save'), [ 'dingtalk_webhook' => self::WEBHOOK, 'dingtalk_secret' => self::SECRET, 'dingtalk_group_name' => '财务&采购沟通群', ]) ->assertStatus(302) ->assertSessionHas('success', '供应商负数预警群机器人已保存。'); $stored = (string) setting(SupplierNegativeAlertDingTalkWorkflow::SETTING_KEY); $this->assertStringNotContainsString('sna-test-token', $stored); $this->assertStringNotContainsString(self::SECRET, $stored); $this->loginAs() ->get($this->pageUrl()) ->assertOk() ->assertSeeText('群机器人已配置') ->assertSeeText('财务&采购沟通群') ->assertDontSeeText('sna-test-token') ->assertDontSeeText(self::SECRET); } public function test_group_robot_rejects_untrusted_webhook(): void { $this->withExceptionHandling(); $this->loginAs() ->post(route('business.supplier-negative-alerts.dingtalk-group.save'), [ 'dingtalk_webhook' => 'https://example.com/robot/send?access_token=unsafe', 'dingtalk_secret' => self::SECRET, 'dingtalk_group_name' => '财务&采购沟通群', ]) ->assertStatus(302) ->assertSessionHasErrors('dingtalk_webhook'); } public function test_send_to_dingtalk_group_and_public_supplier_form_returns_results_to_page(): void { $this->seedRows(); $this->bindSupplierManagement(); app(SupplierNegativeAlertDingTalkWorkflow::class)->saveConfiguration( self::WEBHOOK, self::SECRET, '财务&采购沟通群', ); Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); Storage::fake('uploads'); $this->loginAs() ->post(route('business.supplier-negative-alerts.send-dingtalk'), [ 'purchaser' => '采购甲', 'month' => '2026-09', ]) ->assertStatus(302) ->assertSessionHas('success', '已发送至财务&采购沟通群:采购 采购甲,2 家供应商、3 条预警。'); $assignment = DB::table('supplier_negative_alert_assignments')->first(); $this->assertNotNull($assignment); $this->assertSame('sent', $assignment->status); $this->assertSame(2, (int) $assignment->supplier_count); $this->assertSame(3, (int) $assignment->row_count); $taskUrl = null; Http::assertSent(function ($request) use (&$taskUrl): bool { $taskUrl = (string) ($request['actionCard']['singleURL'] ?? ''); return str_contains($request->url(), 'robot/send') && str_contains($request->url(), 'timestamp=') && str_contains($request->url(), 'sign=') && ($request['msgtype'] ?? '') === 'actionCard' && str_contains((string) ($request['actionCard']['text'] ?? ''), '采购:**采购甲**') && str_contains($taskUrl, '/supplier-negative-alert/task/'); }); preg_match('/\/supplier-negative-alert\/task\/([A-Za-z0-9]{64})$/', (string) $taskUrl, $matches); $this->assertNotEmpty($matches[1] ?? null); $token = $matches[1]; $response = $this->get(route('supplier-negative-alert.task', ['token' => $token])) ->assertOk() ->assertSeeText('供应商负数预警处理') ->assertSeeText('三洋木业') ->assertSeeText('久柯家具') ->assertSeeText('最新账期负数合计') ->assertSeeText('2026-09') ->assertSeeText('¥-230.00') ->assertSeeText('最晚处理时间') ->assertSeeText('2026-10-31 18:00') ->assertSeeText('该账期涉及') ->assertSeeText('3 条负数预警') ->assertSee('placeholder="请填写该供应商的核对、沟通或处理结果"', false) ->assertSee('name="screenshots[0][]"', false) ->assertSeeText('处理结果'); $this->assertSame(2, substr_count((string) $response->getContent(), 'name="results[]"')); $this->post(route('supplier-negative-alert.task.update', ['token' => $token]), [ 'results' => [ '三洋木业:已核对,等待供应商补货。', '久柯家具:已确认负数由历史退款导致。', ], 'screenshots' => [ 0 => [UploadedFile::fake()->image('sanyang-result.png', 800, 500)], 1 => [], ], ]) ->assertRedirect(route('supplier-negative-alert.task', ['token' => $token])) ->assertSessionHas('negative_alert_success'); $assignment = DB::table('supplier_negative_alert_assignments')->first(); $this->assertSame('completed', $assignment->status); $this->assertSame( '2026-09', DB::table('supplier_negative_alert_results')->where('supplier_name', '三洋木业')->value('period'), ); $attachment = DB::table('supplier_negative_alert_result_attachments')->first(); $this->assertNotNull($attachment); Storage::disk('uploads')->assertExists($attachment->path); $this->get(route('supplier-negative-alert.task.attachment', [ 'token' => $token, 'attachment' => $attachment->id, ]))->assertOk(); $this->assertEqualsCanonicalizing( ['久柯家具', '三洋木业'], DB::table('supplier_negative_alert_results')->orderBy('supplier_name')->pluck('supplier_name')->all(), ); $this->loginAs() ->get($this->pageUrl(['month' => '2026-09', 'purchaser' => '采购甲'])) ->assertOk() ->assertSeeText('三洋木业:已核对,等待供应商补货。') ->assertSeeText('久柯家具:已确认负数由历史退款导致。') ->assertDontSeeText('待采购处理'); } public function test_expired_task_link_is_unavailable(): void { $this->seedRows(); $this->bindSupplierManagement(); app(SupplierNegativeAlertDingTalkWorkflow::class)->saveConfiguration( self::WEBHOOK, self::SECRET, '财务&采购沟通群', ); Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); $this->loginAs()->post(route('business.supplier-negative-alerts.send-dingtalk'), [ 'purchaser' => '采购甲', 'month' => '2026-09', ]); $taskUrl = ''; Http::assertSent(function ($request) use (&$taskUrl): bool { $taskUrl = (string) ($request['actionCard']['singleURL'] ?? ''); return $taskUrl !== ''; }); preg_match('/\/([A-Za-z0-9]{64})$/', $taskUrl, $matches); DB::table('supplier_negative_alert_assignments')->update([ 'token_expires_at' => now()->subMinute(), ]); $this->get(route('supplier-negative-alert.task', ['token' => $matches[1]])) ->assertNotFound() ->assertSeeText('任务链接无效或已过期'); } public function test_supplier_results_can_be_saved_individually_for_daily_unhandled_filtering(): void { $this->seedRows(); $this->bindSupplierManagement(); app(SupplierNegativeAlertDingTalkWorkflow::class)->saveConfiguration( self::WEBHOOK, self::SECRET, '财务&采购沟通群', ); Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); $this->loginAs()->post(route('business.supplier-negative-alerts.send-dingtalk'), [ 'purchaser' => '采购甲', 'month' => '2026-09', ]); $token = $this->actionCardTokenFromLastMessage(); $this->post(route('supplier-negative-alert.task.update', ['token' => $token]), [ 'results' => ['三洋木业:已处理', ''], ]) ->assertRedirect(route('supplier-negative-alert.task', ['token' => $token])) ->assertSessionHas('negative_alert_success'); $assignment = DB::table('supplier_negative_alert_assignments')->first(); $this->assertSame('processing', $assignment->status); $this->assertSame( ['三洋木业'], app(SupplierNegativeAlertDingTalkWorkflow::class) ->handledSuppliersForPeriod((int) $this->company->id, '2026-09'), ); $this->post(route('supplier-negative-alert.task.update', ['token' => $token]), [ 'results' => ['', '久柯家具:已处理'], ]); $this->assertSame( 'completed', DB::table('supplier_negative_alert_assignments')->value('status'), ); $this->assertSame(2, DB::table('supplier_negative_alert_results')->count()); } public function test_latest_period_summary_only_uses_the_newest_accounting_month(): void { $summary = app(SupplierNegativeAlertDingTalkWorkflow::class)->latestPeriodSummary([ [ 'supplier' => '供应商甲', 'rows' => [ ['summary_month' => '2026-08', 'alert_amount' => -100], ['summary_month' => '2026-09', 'alert_amount' => -50], ], ], [ 'supplier' => '供应商乙', 'rows' => [ ['summary_month' => '2026-09', 'alert_amount' => -30], ], ], [ 'supplier' => '供应商丙', 'rows' => [ ['summary_month' => '2026-08', 'alert_amount' => -200], ], ], ]); $this->assertSame('2026-09', $summary['month']); $this->assertSame(-80.0, $summary['negative_amount']); $this->assertSame(2, $summary['supplier_count']); $this->assertSame(2, $summary['row_count']); $this->assertSame( '2026-10-31 18:00', app(SupplierNegativeAlertDingTalkWorkflow::class)->processingDeadline('2026-09'), ); } public function test_one_click_sends_all_purchasers_in_one_group_message(): void { $this->seedBulkRows(); $this->bindTwoPurchaserSupplierManagement(); app(SupplierNegativeAlertDingTalkWorkflow::class)->saveConfiguration( self::WEBHOOK, self::SECRET, '财务&采购沟通群', ); Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); $this->loginAs() ->post(route('business.supplier-negative-alerts.send-dingtalk'), [ 'month' => '2026-09', ]) ->assertStatus(302) ->assertSessionHas('success', '已一键发送至财务&采购沟通群:2 位采购,2 家供应商、2 条预警。'); $assignments = DB::table('supplier_negative_alert_assignments') ->orderBy('purchaser_name') ->get(); $this->assertCount(2, $assignments); $this->assertSame(['sent', 'sent'], $assignments->pluck('status')->all()); $this->assertSame(2, $assignments->sum('supplier_count')); $this->assertSame(2, $assignments->sum('row_count')); Http::assertSentCount(1); Http::assertSent(function ($request): bool { $text = (string) ($request['markdown']['text'] ?? ''); return ($request['msgtype'] ?? '') === 'markdown' && str_contains($text, '采购:**2** 位') && str_contains($text, '[采购甲 · 1 家供应商](') && str_contains($text, '[采购乙 · 1 家供应商]('); }); $this->get(route('supplier-negative-alert.task', [ 'token' => $this->tokenForPurchaser('采购甲'), ])) ->assertOk() ->assertSeeText('三洋木业') ->assertDontSeeText('久柯家具'); } public function test_daily_command_pushes_only_unhandled_latest_period_suppliers_once_per_day(): void { Cache::flush(); $this->seedBulkRows(); $this->bindTwoPurchaserSupplierManagement(); app(SupplierNegativeAlertDingTalkWorkflow::class)->saveConfiguration( self::WEBHOOK, self::SECRET, '财务&采购沟通群', ); Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); $now = now(); $handledAssignmentId = DB::table('supplier_negative_alert_assignments')->insertGetId([ 'company_id' => (int) $this->company->id, 'purchaser_name' => '采购甲', 'group_name' => '财务&采购沟通群', 'filters' => json_encode(['month' => '2026-09']), 'snapshot' => json_encode([[ 'supplier' => '三洋木业', 'row_count' => 1, 'months' => ['2026-09'], 'negative_reasons' => ['历史差异待核对'], 'alert_amount' => -100, 'rows' => [[ 'summary_month' => '2026-09', 'checker' => '李萌', 'settlement_method' => '订单合同', 'negative_reason' => '历史差异待核对', 'alert_amount' => -100, ]], ]], JSON_UNESCAPED_UNICODE), 'supplier_count' => 1, 'row_count' => 1, 'token_hash' => hash('sha256', 'already-handled-assignment'), 'token_expires_at' => $now->copy()->addDay(), 'status' => 'completed', 'sent_at' => $now, 'completed_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); DB::table('supplier_negative_alert_results')->insert([ 'assignment_id' => $handledAssignmentId, 'company_id' => (int) $this->company->id, 'purchaser_name' => '采购甲', 'supplier_name' => '三洋木业', 'period' => '2026-09', 'result_text' => '已处理', 'submitted_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); $this->artisan('supplier-negative-alert:push-dingtalk --date=2099-01-01') ->assertSuccessful(); $newAssignment = DB::table('supplier_negative_alert_assignments') ->where('id', '!=', $handledAssignmentId) ->first(); $this->assertNotNull($newAssignment); $this->assertSame('采购乙', $newAssignment->purchaser_name); $snapshot = json_decode((string) $newAssignment->snapshot, true); $this->assertSame(['久柯家具'], array_column($snapshot, 'supplier')); Http::assertSentCount(1); Http::assertSent(function ($request): bool { $text = (string) ($request['markdown']['text'] ?? ''); return str_contains($text, '[采购乙 · 1 家供应商](') && ! str_contains($text, '[采购甲 ·'); }); $this->artisan('supplier-negative-alert:push-dingtalk --date=2099-01-01') ->assertSuccessful(); Http::assertSentCount(1); $this->assertSame(2, DB::table('supplier_negative_alert_assignments')->count()); } private function seedRows(): void { $store = app(SupplierPayableOverrideStore::class); $dataset = PayablesFinalActions::DATASET_PREFIX . 'supplier:supplier-payables-report'; foreach ([ 'sanyang:1' => ['supplier' => '三洋木业', 'supplier_balance' => '-100.00'], 'sanyang:2' => ['supplier' => '三洋木业', 'supplier_balance' => '-50.00'], 'jiuke:1' => ['supplier' => '久柯家具', 'supplier_balance' => '-80.00'], ] as $key => $row) { $store->save($dataset, $key, $row + [ 'summary_month' => '2026-09', 'checker' => '李萌', 'settlement_method' => '订单合同', 'should_pay' => '0.00', 'estimated_price' => '0.00', 'negative_reason' => '历史差异待核对', ], (int) $this->company->id, (int) $this->user->id); } } private function seedBulkRows(): void { $store = app(SupplierPayableOverrideStore::class); $dataset = PayablesFinalActions::DATASET_PREFIX . 'supplier:supplier-payables-report'; foreach ([ 'bulk:sanyang' => ['supplier' => '三洋木业', 'supplier_balance' => '-100.00'], 'bulk:jiuke' => ['supplier' => '久柯家具', 'supplier_balance' => '-80.00'], ] as $key => $row) { $store->save($dataset, $key, $row + [ 'summary_month' => '2026-09', 'checker' => '李萌', 'settlement_method' => '订单合同', 'should_pay' => '0.00', 'estimated_price' => '0.00', 'negative_reason' => '历史差异待核对', ], (int) $this->company->id, (int) $this->user->id); } } private function bindSupplierManagement(): void { $supplierManagement = \Mockery::mock(SupplierManagementMirrorRepository::class); $supplierManagement->shouldReceive('suppliersByNames') ->andReturnUsing(static function (array $names): array { return array_map( static fn (string $name): object => (object) ['scmname' => $name, 'buyer' => '采购甲'], $names, ); }); $this->app->instance(SupplierManagementMirrorRepository::class, $supplierManagement); } private function bindTwoPurchaserSupplierManagement(): void { $supplierManagement = \Mockery::mock(SupplierManagementMirrorRepository::class); $supplierManagement->shouldReceive('suppliersByNames') ->andReturnUsing(static function (array $names): array { return array_map(static fn (string $name): object => (object) [ 'scmname' => $name, 'buyer' => $name === '久柯家具' ? '采购乙' : '采购甲', ], $names); }); $this->app->instance(SupplierManagementMirrorRepository::class, $supplierManagement); } /** @param array $query */ private function pageUrl(array $query = []): string { return route('business.pages.show', array_merge([ 'section' => 'negative-alerts', 'page' => 'supplier-negative-alert', ], $query)); } private function tokenForPurchaser(string $purchaser): string { return $this->taskTokenFromLastMessage($purchaser); } private function taskTokenFromLastMessage(string $purchaser): string { $token = ''; Http::assertSent(function ($request) use ($purchaser, &$token): bool { $text = (string) ($request['markdown']['text'] ?? ''); if (preg_match('/\[' . preg_quote($purchaser, '/') . '[^\]]*\]\(([^)]+)\)/u', $text, $matches)) { preg_match('/\/supplier-negative-alert\/task\/([A-Za-z0-9]{64})$/', $matches[1], $tokenMatch); $token = $tokenMatch[1] ?? ''; } return $token !== ''; }); return $token; } private function actionCardTokenFromLastMessage(): string { $url = ''; Http::assertSent(function ($request) use (&$url): bool { $url = (string) ($request['actionCard']['singleURL'] ?? ''); return $url !== ''; }); preg_match('/\/([A-Za-z0-9]{64})$/', $url, $matches); $this->assertNotEmpty($matches[1] ?? null); return $matches[1]; } }