user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); } public function test_finance_detail_shows_push_buttons_and_robot_configuration(): void { $this->seedReconciliation(); $this->loginAs()->get($this->pageUrl()) ->assertOk() ->assertSeeText('推送全部差异(3 条)') ->assertSeeText('推送选中(') ->assertSee('aria-label="选择全部差异"', false) ->assertSeeText('推送本条') ->assertSeeText('厂家钉钉群机器人') ->assertSeeText('未配置,配置后才能推送差异') ->assertDontSee(self::WEBHOOK); } public function test_robot_configuration_is_saved_and_secret_is_not_rendered(): void { $this->seedReconciliation(); $this->loginAs() ->post(route('business.supplier-reconciliation.dingtalk-robot.save'), [ 'supplier' => '晨丰', 'dingtalk_webhook' => self::WEBHOOK, 'dingtalk_secret' => 'SEC-test-123', 'dingtalk_at_all' => '1', ]) ->assertStatus(302) ->assertSessionHas('success', '晨丰 的钉钉群机器人已保存。'); $config = app(SupplierDingTalkNotifier::class)->configuration('晨丰'); $this->assertTrue($config['configured']); $this->assertTrue($config['at_all']); $this->assertStringContainsString('access_token=••••••', $config['webhook_hint']); $this->assertStringNotContainsString('supplier-test-token', (string) setting('supplier_reconciliation.dingtalk_robots')); $this->assertStringNotContainsString('SEC-test-123', (string) setting('supplier_reconciliation.dingtalk_robots')); $this->loginAs()->get($this->pageUrl()) ->assertOk() ->assertSeeText('已配置') ->assertDontSeeText('supplier-test-token') ->assertDontSeeText('SEC-test-123'); } public function test_invalid_robot_webhook_is_rejected(): void { $this->withExceptionHandling(); $this->loginAs() ->post(route('business.supplier-reconciliation.dingtalk-robot.save'), [ 'supplier' => '晨丰', 'dingtalk_webhook' => 'https://example.com/robot/send?access_token=unsafe', ]) ->assertStatus(302) ->assertSessionHasErrors('dingtalk_webhook'); } public function test_push_all_differences_sends_only_current_supplier_and_filtered_rows(): void { $this->seedReconciliation(); $this->saveRobot(); Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); $this->loginAs() ->post(route('business.supplier-reconciliation.push-differences'), [ 'supplier' => '晨丰', 'month' => '2026-09', 'reconciler' => '张三', 'scope' => 'all', ]) ->assertStatus(302) ->assertSessionHas('success', '已推送 3 条差异到 晨丰 钉钉群(1 条消息)。'); Http::assertSentCount(1); Http::assertSent(function ($request): bool { $text = (string) ($request['markdown']['text'] ?? ''); return str_contains($request->url(), 'oapi.dingtalk.com/robot/send') && str_contains($request->url(), 'timestamp=') && str_contains($request->url(), 'sign=') && str_contains($text, '货款对账差异通知 · 晨丰') && str_contains($text, 'B-1') && str_contains($text, 'C-1') && str_contains($text, 'D-1') && str_contains($text, '采购单号:编号-B-1') && str_contains($text, '日期:2026-09-30') && str_contains($text, '厂家数量少 1') && ! str_contains($text, '`A-1`') && ! str_contains($text, '鸣飞家具'); }); $config = app(SupplierDingTalkNotifier::class)->configuration('晨丰'); $this->assertNotSame('', $config['last_pushed_at']); $this->assertSame(3, $config['last_push_count']); } public function test_push_single_difference_only_sends_the_selected_row(): void { $this->seedReconciliation(); $this->saveRobot(); Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); $this->loginAs() ->post(route('business.supplier-reconciliation.push-differences'), [ 'supplier' => '晨丰', 'month' => '2026-09', 'reconciler' => '张三', 'scope' => 'row', 'purchase_no' => '编号-B-1', 'date' => '2026-09-30', 'barcode' => 'B-1', 'difference_type' => '数量/金额不一致', ]) ->assertStatus(302) ->assertSessionHas('success', '已推送 1 条差异到 晨丰 钉钉群(1 条消息)。'); Http::assertSent(function ($request): bool { $text = (string) ($request['markdown']['text'] ?? ''); return str_contains($text, 'B-1') && ! str_contains($text, '`C-1`') && ! str_contains($text, '`D-1`'); }); } public function test_push_selected_differences_only_sends_checked_rows(): void { $this->seedReconciliation(); $this->saveRobot(); Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); $this->loginAs() ->post(route('business.supplier-reconciliation.push-differences'), [ 'supplier' => '晨丰', 'month' => '2026-09', 'reconciler' => '张三', 'scope' => 'selected', 'selected_rows' => [ json_encode(['purchaseNo' => '编号-B-1', 'date' => '2026-09-30', 'barcode' => 'B-1', 'type' => '数量/金额不一致']), json_encode(['purchaseNo' => '编号-D-1', 'date' => '2026-09-30', 'barcode' => 'D-1', 'type' => '厂家多']), ], ]) ->assertStatus(302) ->assertSessionHas('success', '已推送 2 条差异到 晨丰 钉钉群(1 条消息)。'); Http::assertSent(function ($request): bool { $text = (string) ($request['markdown']['text'] ?? ''); return str_contains($text, 'B-1') && str_contains($text, 'D-1') && ! str_contains($text, '`C-1`'); }); } public function test_push_without_robot_configuration_is_rejected_without_http_call(): void { $this->seedReconciliation(); Http::fake(); $this->loginAs() ->post(route('business.supplier-reconciliation.push-differences'), [ 'supplier' => '晨丰', 'month' => '2026-09', 'reconciler' => '张三', 'scope' => 'all', ]) ->assertStatus(302) ->assertSessionHasErrors('dingtalk_push'); Http::assertNothingSent(); } public function test_large_difference_set_is_sent_in_multiple_robot_messages(): void { $this->saveRobot(); Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]); $rows = []; for ($index = 1; $index <= 31; $index++) { $rows[] = [ 'barcode' => sprintf('BULK-%02d', $index), 'supplierBarcode' => '', 'purchaseNo' => sprintf('PO-BULK-%02d', $index), 'date' => '2026-09-30', 'unitPrice' => 10, 'mineUnitPrice' => 10, 'theirsUnitPrice' => 10, 'mineQty' => 2, 'theirsQty' => 1, 'mineAmount' => 20, 'theirsAmount' => 10, 'qtyDifference' => -1, 'amountDifference' => -10, 'type' => '数量/金额不一致', 'settleAmount' => 10, 'note' => '厂家数量少 1;按金额低的一方结算', ]; } $result = app(SupplierDingTalkNotifier::class)->push('晨丰', $rows, [ 'month' => '2026-09', 'reconciler' => '张三', ]); $this->assertSame(31, $result['rows']); $this->assertSame(2, $result['messages']); Http::assertSentCount(2); } private function seedReconciliation(): void { $companyId = (int) $this->company->id; $store = app(SupplierPayableOverrideStore::class); foreach ([ 'mine:1' => ['skuCode' => 'A-1', 'qty' => '2', 'unitPrice' => '100', 'amount' => '200'], 'mine:2' => ['skuCode' => 'B-1', 'qty' => '2', 'unitPrice' => '50', 'amount' => '100'], 'mine:3' => ['skuCode' => 'C-1', 'qty' => '1', 'unitPrice' => '30', 'amount' => '30'], ] as $key => $row) { $store->save('final:supplier:payment-reconciliation', $key, $row + [ 'summary_month' => '2026-09', 'purchaseNo' => '编号-' . $row['skuCode'], 'date' => '2026-09-30', 'supplier' => '晨丰', 'reconciler' => '张三', ], $companyId, null); } app(SupplierStatementStore::class)->save('晨丰', [ $this->statementRow('A-1', '100', '2', '200'), $this->statementRow('B-1', '50', '1', '50'), $this->statementRow('D-1', '80', '1', '80'), ], 'statement.csv', $companyId, null); } private function saveRobot(): void { app(SupplierDingTalkNotifier::class)->save('晨丰', self::WEBHOOK, 'SEC-test-123', false); } private function pageUrl(): string { return route('business.pages.show', [ 'section' => 'supplier-reconciliation', 'page' => 'finance-data', 'supplier' => '晨丰', 'month' => '2026-09', 'reconciler' => '张三', ]); } private function statementRow(string $barcode, string $unitPrice, string $qty, string $amount): array { return [ 'expressName' => null, 'expressNo' => null, 'purchaseNo' => '编号-' . $barcode, 'createdAt' => '2026-09-30', 'supplier' => '晨丰', 'barcode' => $barcode, 'skuName' => '规格甲', 'qty' => $qty, 'unitPrice' => $unitPrice, 'amount' => $amount, ]; } }