CaiWuXiTong/tests/Feature/Business/SupplierReconciliationDingTalkTest.php
苏木 9aa8547930 fix: 统一财务退货表与厂家端表头 (#834)
财务端退货表改为与厂家端一致的表头和数据顺序,去掉财务专用扣减/物流列,确认状态保留在操作列。
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-02 22:32:10 +08:00

487 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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\SupplierReturnConfirmation;
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);
}
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_return_table_uses_the_supplier_data_headers(): void
{
$this->seedReconciliation();
$html = $this->loginAs()->get($this->pageUrl())->assertOk()->getContent();
$tableStart = strpos($html, 'data-finance-return-table');
$this->assertNotFalse($tableStart, '财务退货表应正常渲染。');
$headStart = strpos($html, '<thead>', $tableStart);
$headEnd = strpos($html, '</thead>', $headStart);
$this->assertNotFalse($headStart);
$this->assertNotFalse($headEnd);
preg_match_all('/<th scope="col">(.*?)<\/th>/s', substr($html, $headStart, $headEnd - $headStart), $matches);
$headers = array_map(
static fn (string $value): string => html_entity_decode(strip_tags(trim($value))),
$matches[1],
);
$this->assertSame([
'发货时间',
'明细供应商',
'条形码',
'商品名称',
'发货量',
'单价',
'金额',
'供应商条码',
'备注',
'标题',
'wms配货单号',
'仓库名称',
'快递单号',
'类型',
'操作',
], $headers);
}
public function test_monthly_return_push_sends_all_statuses_and_only_the_selected_supplier_month(): void
{
$this->saveRobot();
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
foreach ([
'return:pending' => [
'send_time' => '2026-09-03 10:00:00', 'barcode' => 'RETURN-PENDING',
'sku_name' => '待确认退货', 'qty' => '1', 'unit_price' => '100', 'amount' => '100',
],
'return:confirmed' => [
'send_time' => '2026-09-12 10:00:00', 'barcode' => 'RETURN-CONFIRMED',
'sku_name' => '已确认退货', 'qty' => '2', 'unit_price' => '50', 'amount' => '100',
],
'return:disputed' => [
'send_time' => '2026-09-22 10:00:00', 'barcode' => 'RETURN-DISPUTED',
'sku_name' => '异议中退货', 'qty' => '3', 'unit_price' => '10', 'amount' => '30',
],
'return:october' => [
'send_time' => '2026-10-01 10:00:00', 'barcode' => 'RETURN-OCTOBER',
'sku_name' => '其他月份退货', 'qty' => '9', 'unit_price' => '10', 'amount' => '90',
],
'return:foreign' => [
'send_time' => '2026-09-15 10:00:00', 'supplier' => '鸣飞家具', 'barcode' => 'RETURN-FOREIGN',
'sku_name' => '其他厂家退货', 'qty' => '8', 'unit_price' => '10', 'amount' => '80',
],
] as $key => $row) {
$store->save('final:supplier:return-reconciliation', $key, $row + [
'summary_month' => '2026-09',
'supplier' => '晨丰',
'warehouse' => '一号仓',
'wms_pick_no' => 'WMS-' . strtoupper(substr($key, 7)),
'express_no' => 'SF-' . strtoupper(substr($key, 7)),
], $companyId, null);
}
$confirmations = app(SupplierReturnConfirmation::class);
$confirmations->mark('晨丰', 'return:confirmed', SupplierReturnConfirmation::STATUS_CONFIRMED, $companyId, null);
$confirmations->mark('晨丰', 'return:disputed', SupplierReturnConfirmation::STATUS_DISPUTED, $companyId, null);
$this->loginAs()
->post(route('business.supplier-reconciliation.push-returns'), [
'supplier' => '晨丰',
'month' => '2026-09',
])
->assertStatus(302)
->assertSessionHas('success', '已推送 3 条退货到 晨丰 钉钉群(2026-09,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($text, '供应商退货对账通知 · 晨丰')
&& str_contains($text, '业务类型:货款对账 / 退货明细')
&& str_contains($text, '- 对账月份:**2026-09**')
&& str_contains($text, '待确认 1 / 已确认 1 / 异议中 1')
&& str_contains($text, 'RETURN-PENDING')
&& str_contains($text, 'RETURN-CONFIRMED')
&& str_contains($text, 'RETURN-DISPUTED')
&& str_contains($text, '状态:已确认')
&& str_contains($text, '状态:异议中')
&& str_contains($text, '不按确认状态筛选')
&& ! str_contains($text, 'RETURN-OCTOBER')
&& ! str_contains($text, 'RETURN-FOREIGN');
});
}
public function test_return_messages_are_split_by_size_instead_of_a_fixed_row_count(): void
{
$this->saveRobot();
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$rows = [];
for ($index = 1; $index <= 31; $index++) {
$rows[] = $this->returnNotificationRow($index);
}
$result = app(SupplierDingTalkNotifier::class)->pushReturns('晨丰', $rows, '2026-09');
$this->assertSame(31, $result['rows']);
$this->assertSame(1, $result['messages']);
Http::assertSentCount(1);
Http::assertSent(function ($request): bool {
$text = (string) ($request['markdown']['text'] ?? '');
return str_contains($text, 'RETURN-BULK-01')
&& str_contains($text, 'RETURN-BULK-31');
});
}
public function test_monthly_return_push_has_no_application_row_or_message_cap(): void
{
$this->saveRobot();
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$rows = [];
for ($index = 1; $index <= 601; $index++) {
$rows[] = $this->returnNotificationRow($index);
}
$result = app(SupplierDingTalkNotifier::class)->pushReturns('晨丰', $rows, '2026-09');
$this->assertSame(601, $result['rows']);
$this->assertGreaterThan(1, $result['messages']);
Http::assertSentCount($result['messages']);
foreach (Http::recorded() as [$request]) {
$this->assertLessThanOrEqual(20_000, strlen((string) ($request['markdown']['text'] ?? '')));
}
}
public function test_monthly_return_push_rejects_an_empty_month_without_http_call(): void
{
$this->saveRobot();
Http::fake();
$this->loginAs()
->post(route('business.supplier-reconciliation.push-returns'), [
'supplier' => '晨丰',
'month' => '2026-08',
])
->assertStatus(302)
->assertSessionHasErrors([
'dingtalk_return_push' => '该厂家在 2026-08 没有可推送的退货数据。',
]);
Http::assertNothingSent();
}
private function returnNotificationRow(int $index): array
{
return [
'send_time' => '2026-09-' . str_pad((string) (($index - 1) % 28 + 1), 2, '0', STR_PAD_LEFT) . ' 10:00:00',
'barcode' => sprintf('RETURN-BULK-%02d', $index),
'sku_name' => '批量退货',
'qty' => '1',
'unit_price' => '10',
'amount' => '10',
'_supplier_qty' => 1.0,
'_supplier_amount' => 10.0,
'_status' => '',
'_accounting_label' => '厂家扣减',
'_accounting_note' => '按厂家退货扣减',
];
}
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,
];
}
}