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); } public function test_finance_page_shows_monthly_return_push_entry(): void { $this->seedReconciliation(); $this->loginAs()->get($this->pageUrl()) ->assertOk() ->assertSeeText('一键推送整月退货') ->assertSeeText('推送该月全部退货,包含待确认、已确认和异议中记录,不按确认状态筛选。') ->assertSee('name="month"', false); } public function test_finance_supplier_list_is_paginated_fifteen_per_page_with_return_push_action(): void { $companyId = (int) $this->company->id; $store = app(SupplierPayableOverrideStore::class); for ($index = 1; $index <= 16; $index++) { $supplier = sprintf('分页厂家%02d', $index); $store->save('final:supplier:return-reconciliation', 'return:' . $index, [ 'summary_month' => '2026-10', 'send_time' => '2026-10-' . str_pad((string) $index, 2, '0', STR_PAD_LEFT) . ' 10:00:00', 'supplier' => $supplier, 'barcode' => 'RETURN-PAGE-' . $index, 'sku_name' => '分页退货', 'qty' => '1', 'unit_price' => '10', 'amount' => '10', ], $companyId, null); } app(SupplierDingTalkNotifier::class)->save('分页厂家01', self::WEBHOOK, 'SEC-test-123', false); $pageUrl = route('business.pages.show', [ 'company_id' => $companyId, 'section' => 'supplier-reconciliation', 'page' => 'finance-data', ]); $firstPage = $this->loginAs()->get($pageUrl)->assertOk(); $firstPage->assertSeeText('共 16 家') ->assertSeeText('每页 15 家') ->assertSeeText('第 1 / 2 页') ->assertSee('id="select-all-return-push"', false) ->assertSee('class="js-batch-supplier-checkbox"', false) ->assertSeeText('一键推送退货') ->assertSee('name="month" value="2026-10"', false); $firstHtml = (string) $firstPage->getContent(); $firstTableStart = strpos($firstHtml, 'data-finance-supplier-list'); $firstTableEnd = strpos($firstHtml, '', $firstTableStart); $this->assertNotFalse($firstTableStart); $this->assertNotFalse($firstTableEnd); $firstTable = substr($firstHtml, $firstTableStart, $firstTableEnd - $firstTableStart); $this->assertStringContainsString('分页厂家01', $firstTable); $this->assertStringContainsString('分页厂家15', $firstTable); $this->assertStringNotContainsString('分页厂家16', $firstTable); $secondPage = $this->loginAs()->get($pageUrl . '?supplier_page=2')->assertOk(); $secondPage->assertSeeText('第 2 / 2 页'); $secondHtml = (string) $secondPage->getContent(); $secondTableStart = strpos($secondHtml, 'data-finance-supplier-list'); $secondTableEnd = strpos($secondHtml, '', $secondTableStart); $this->assertNotFalse($secondTableStart); $this->assertNotFalse($secondTableEnd); $secondTable = substr($secondHtml, $secondTableStart, $secondTableEnd - $secondTableStart); $this->assertStringContainsString('分页厂家16', $secondTable); $this->assertStringNotContainsString('分页厂家01', $secondTable); } public function test_reconciler_options_include_finance_names_when_month_has_only_returns(): void { $companyId = (int) $this->company->id; $store = app(SupplierPayableOverrideStore::class); $store->save('final:supplier:payment-reconciliation', 'payment:september', [ 'summary_month' => '2026-09', 'purchaseNo' => 'PO-SEPTEMBER', 'date' => '2026-09-30', 'supplier' => '晨丰', 'skuCode' => 'SKU-1', 'qty' => '1', 'unitPrice' => '10', 'amount' => '10', 'reconciler' => '李萌', ], $companyId, null); $store->save('final:supplier:return-reconciliation', 'return:october', [ 'summary_month' => '2026-10', 'send_time' => '2026-10-08 10:00:00', 'supplier' => '晨丰', 'barcode' => 'RETURN-OCTOBER', 'sku_name' => '十月退货', 'qty' => '1', 'unit_price' => '10', 'amount' => '10', 'reconciler' => '陈亲园', ], $companyId, null); $store->save('final:supplier:return-reconciliation', 'return:october-foreign', [ 'summary_month' => '2026-10', 'send_time' => '2026-10-09 10:00:00', 'supplier' => '鸣飞家具', 'barcode' => 'RETURN-OCTOBER-FOREIGN', 'sku_name' => '其他对账人退货', 'qty' => '1', 'unit_price' => '10', 'amount' => '10', 'reconciler' => '李萌', ], $companyId, null); $this->loginAs()->get(route('business.pages.show', [ 'company_id' => $companyId, 'section' => 'supplier-reconciliation', 'page' => 'finance-data', 'month' => '2026-10', 'reconciler' => '陈亲园', ])) ->assertOk() ->assertSee('