289 lines
11 KiB
PHP
289 lines
11 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature\Business;
|
||
|
||
use App\Models\Auth\Permission;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||
use Modules\EcommerceFinance\Services\SupplierStatementStore;
|
||
use Tests\Feature\FeatureTestCase;
|
||
|
||
class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||
{
|
||
private const WEBHOOK = 'https://oapi.dingtalk.com/robot/send?access_token=supplier-test-token';
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
|
||
$this->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,
|
||
];
|
||
}
|
||
}
|