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('