回执和机器人最近推送时间改用平台时区,避免 UTC 导致北京时间少 8 小时。 Co-authored-by: 苏木 <594326199@qq.com> Co-committed-by: 苏木 <594326199@qq.com>
878 lines
37 KiB
PHP
878 lines
37 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature\Business;
|
||
|
||
use App\Models\Auth\Permission;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Routing\Exceptions\InvalidSignatureException;
|
||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
|
||
use Modules\EcommerceFinance\Services\SupplierReturnShareLink;
|
||
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_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, '</table>', $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, '</table>', $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('<option value="李萌"', false)
|
||
->assertSee('<option value="陈亲园"', false)
|
||
->assertSeeText('共 1 家')
|
||
->assertSeeText('晨丰')
|
||
->assertDontSeeText('鸣飞家具');
|
||
}
|
||
|
||
public function test_batch_return_push_sends_each_selected_supplier_to_its_own_robot(): void
|
||
{
|
||
$companyId = (int) $this->company->id;
|
||
$store = app(SupplierPayableOverrideStore::class);
|
||
$notifier = app(SupplierDingTalkNotifier::class);
|
||
foreach (['晨丰', '鸣飞家具'] as $index => $supplier) {
|
||
$notifier->save($supplier, self::WEBHOOK, 'SEC-test-' . $index, false);
|
||
$store->save('final:supplier:return-reconciliation', 'return:' . $index, [
|
||
'summary_month' => '2026-10',
|
||
'send_time' => '2026-10-0' . ($index + 1) . ' 10:00:00',
|
||
'supplier' => $supplier,
|
||
'barcode' => 'RETURN-BATCH-' . $index,
|
||
'sku_name' => '批量推送退货',
|
||
'qty' => '1',
|
||
'unit_price' => '10',
|
||
'amount' => '10',
|
||
], $companyId, null);
|
||
}
|
||
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
|
||
|
||
$this->loginAs()
|
||
->post(route('business.supplier-reconciliation.push-returns-batch'), [
|
||
'selected_returns' => [
|
||
json_encode(['supplier' => '晨丰', 'month' => '2026-10']),
|
||
json_encode(['supplier' => '鸣飞家具', 'month' => '2026-10']),
|
||
],
|
||
])
|
||
->assertStatus(302)
|
||
->assertSessionHas('success', '已批量推送 2 家供应商,共 2 条退货(2 条钉钉消息)。');
|
||
|
||
Http::assertSentCount(2);
|
||
Http::assertSent(fn ($request): bool => str_contains(
|
||
(string) ($request['markdown']['text'] ?? ''),
|
||
'供应商退货对账通知 · 晨丰',
|
||
));
|
||
Http::assertSent(fn ($request): bool => str_contains(
|
||
(string) ($request['markdown']['text'] ?? ''),
|
||
'供应商退货对账通知 · 鸣飞家具',
|
||
));
|
||
}
|
||
|
||
public function test_batch_return_push_reports_suppliers_without_robots(): void
|
||
{
|
||
$companyId = (int) $this->company->id;
|
||
$store = app(SupplierPayableOverrideStore::class);
|
||
app(SupplierDingTalkNotifier::class)->save('晨丰', self::WEBHOOK, 'SEC-test-123', false);
|
||
foreach (['晨丰', '鸣飞家具'] as $index => $supplier) {
|
||
$store->save('final:supplier:return-reconciliation', 'return:partial:' . $index, [
|
||
'summary_month' => '2026-10',
|
||
'send_time' => '2026-10-0' . ($index + 1) . ' 10:00:00',
|
||
'supplier' => $supplier,
|
||
'barcode' => 'RETURN-PARTIAL-' . $index,
|
||
'sku_name' => '部分推送退货',
|
||
'qty' => '1',
|
||
'unit_price' => '10',
|
||
'amount' => '10',
|
||
], $companyId, null);
|
||
}
|
||
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
|
||
|
||
$this->loginAs()
|
||
->post(route('business.supplier-reconciliation.push-returns-batch'), [
|
||
'selected_returns' => [
|
||
json_encode(['supplier' => '晨丰', 'month' => '2026-10']),
|
||
json_encode(['supplier' => '鸣飞家具', 'month' => '2026-10']),
|
||
],
|
||
])
|
||
->assertStatus(302)
|
||
->assertSessionHas('success', function (string $message): bool {
|
||
return str_contains($message, '已批量推送 1 家供应商,共 1 条退货(1 条钉钉消息)')
|
||
&& str_contains($message, '未推送 1 家:鸣飞家具(该厂家还没有配置钉钉群机器人 Webhook。)');
|
||
});
|
||
|
||
Http::assertSentCount(1);
|
||
}
|
||
|
||
public function test_return_months_are_available_and_default_to_the_latest_imported_month(): void
|
||
{
|
||
$this->seedReconciliation();
|
||
$this->saveRobot();
|
||
|
||
app(SupplierPayableOverrideStore::class)->save(
|
||
'final:supplier:return-reconciliation',
|
||
'return:october-entry',
|
||
[
|
||
'summary_month' => '2026-10',
|
||
'send_time' => '2026-10-08 10:00:00',
|
||
'supplier' => '晨丰',
|
||
'barcode' => 'RETURN-OCTOBER-ENTRY',
|
||
'sku_name' => '十月退货',
|
||
'qty' => '1',
|
||
'unit_price' => '10',
|
||
'amount' => '10',
|
||
],
|
||
(int) $this->company->id,
|
||
null,
|
||
);
|
||
|
||
$this->loginAs()->get(route('business.pages.show', [
|
||
'section' => 'supplier-reconciliation',
|
||
'page' => 'finance-data',
|
||
'supplier' => '晨丰',
|
||
]))
|
||
->assertOk()
|
||
->assertSee('<option value="2026-10"', false)
|
||
->assertSeeText('一键推送整月退货')
|
||
->assertSee('name="month" value="2026-10" required', 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, '[查看退货明细表格](')
|
||
&& str_contains($text, '全部确认无异议')
|
||
&& str_contains($text, '无需厂家登录')
|
||
&& ! str_contains($text, 'RETURN-PENDING')
|
||
&& ! str_contains($text, 'RETURN-CONFIRMED')
|
||
&& ! str_contains($text, 'RETURN-DISPUTED')
|
||
&& ! str_contains($text, 'RETURN-OCTOBER')
|
||
&& ! str_contains($text, 'RETURN-FOREIGN');
|
||
});
|
||
}
|
||
|
||
public function test_return_share_page_displays_table_and_download_links_without_login(): void
|
||
{
|
||
$companyId = (int) $this->company->id;
|
||
$store = app(SupplierPayableOverrideStore::class);
|
||
foreach ([
|
||
'return:share-pending' => ['barcode' => 'SHARE-PENDING', 'qty' => '2', 'amount' => '20'],
|
||
'return:share-confirmed' => ['barcode' => 'SHARE-CONFIRMED', 'qty' => '3', 'amount' => '30'],
|
||
] as $key => $row) {
|
||
$store->save('final:supplier:return-reconciliation', $key, $row + [
|
||
'summary_month' => '2026-10',
|
||
'send_time' => '2026-10-08 10:00:00',
|
||
'supplier' => '晨丰',
|
||
'sku_name' => '共享明细商品',
|
||
'unit_price' => '10',
|
||
'warehouse' => '一号仓',
|
||
'wms_pick_no' => 'WMS-SHARE',
|
||
'express_no' => 'SF-SHARE',
|
||
], $companyId, null);
|
||
}
|
||
|
||
$url = app(SupplierReturnShareLink::class)->pageUrl($companyId, '晨丰', '2026-10');
|
||
$response = $this->get($url)
|
||
->assertOk()
|
||
->assertHeader('X-Robots-Tag', 'noindex, nofollow, noarchive')
|
||
->assertHeader('Referrer-Policy', 'no-referrer');
|
||
$response->assertSeeText('晨丰 · 2026-10 退货明细')
|
||
->assertSeeText('全部确认无异议(2 行)')
|
||
->assertSeeText('下载 Excel')
|
||
->assertSeeText('下载 CSV')
|
||
->assertSeeText('发货时间')
|
||
->assertSeeText('wms配货单号')
|
||
->assertSeeText('SHARE-PENDING')
|
||
->assertSeeText('SHARE-CONFIRMED')
|
||
->assertDontSeeText('本月退货行数')
|
||
->assertDontSeeText('供应商条码');
|
||
$response->assertSee('table-layout: fixed', false)
|
||
->assertSee('overflow-x: hidden', false)
|
||
->assertSee('<colgroup>', false)
|
||
->assertDontSee('min-width: 1580px', false);
|
||
|
||
$html = (string) $response->getContent();
|
||
$tableStart = strpos($html, '<table>');
|
||
$tableEnd = strpos($html, '</table>', $tableStart);
|
||
$this->assertNotFalse($tableStart);
|
||
$this->assertNotFalse($tableEnd);
|
||
$tableHtml = substr($html, $tableStart, $tableEnd - $tableStart);
|
||
$this->assertSame(14, substr_count($tableHtml, '<th scope="col">'));
|
||
$summaryStart = strpos($html, '<section class="share-summary">');
|
||
$summaryEnd = strpos($html, '</section>', $summaryStart);
|
||
$this->assertNotFalse($summaryStart);
|
||
$this->assertNotFalse($summaryEnd);
|
||
$summaryHtml = substr($html, $summaryStart, $summaryEnd - $summaryStart);
|
||
$this->assertGreaterThan(
|
||
strpos($summaryHtml, '确认状态'),
|
||
strpos($summaryHtml, '全部确认无异议'),
|
||
);
|
||
$this->assertMatchesRegularExpression(
|
||
'/href="([^"]*supplier-returns[^"]*\/excel[^"]*)"/',
|
||
$html,
|
||
);
|
||
preg_match('/href="([^"]*supplier-returns[^"]*\/excel[^"]*)"/', $html, $matches);
|
||
$excelUrl = html_entity_decode($matches[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
|
||
$this->get($excelUrl)
|
||
->assertOk()
|
||
->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||
|
||
preg_match('/href="([^"]*supplier-returns[^"]*\/csv[^"]*)"/', $html, $matches);
|
||
$this->assertNotEmpty($matches);
|
||
$csvUrl = html_entity_decode($matches[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
$this->get($csvUrl)
|
||
->assertOk()
|
||
->assertHeader('content-type', 'text/csv; charset=UTF-8');
|
||
}
|
||
|
||
public function test_manufacturer_confirmation_from_signed_link_is_saved_to_the_system(): void
|
||
{
|
||
$companyId = (int) $this->company->id;
|
||
$store = app(SupplierPayableOverrideStore::class);
|
||
foreach ([
|
||
'return:confirm-first' => 'CONFIRM-FIRST',
|
||
'return:confirm-disputed' => 'CONFIRM-DISPUTED',
|
||
] as $key => $barcode) {
|
||
$store->save('final:supplier:return-reconciliation', $key, [
|
||
'summary_month' => '2026-10',
|
||
'send_time' => '2026-10-08 10:00:00',
|
||
'supplier' => '晨丰',
|
||
'barcode' => $barcode,
|
||
'sku_name' => '厂家确认测试',
|
||
'qty' => '1',
|
||
'unit_price' => '10',
|
||
'amount' => '10',
|
||
], $companyId, null);
|
||
}
|
||
app(SupplierReturnConfirmation::class)->mark(
|
||
'晨丰',
|
||
'return:confirm-disputed',
|
||
SupplierReturnConfirmation::STATUS_DISPUTED,
|
||
$companyId,
|
||
null,
|
||
);
|
||
$this->saveRobot();
|
||
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
|
||
config([
|
||
'app.timezone' => 'UTC',
|
||
'app.platform_timezone' => 'Asia/Shanghai',
|
||
]);
|
||
$this->travelTo(Carbon::parse('2026-11-04 07:09:02', 'UTC'));
|
||
|
||
$pageUrl = app(SupplierReturnShareLink::class)->pageUrl($companyId, '晨丰', '2026-10');
|
||
$page = $this->get($pageUrl)->assertOk();
|
||
$page->assertSeeText('全部确认无异议(1 行)');
|
||
$html = (string) $page->getContent();
|
||
preg_match('/action="([^"]*supplier-returns[^"]*\/confirm-all[^"]*)"/', $html, $matches);
|
||
$this->assertNotEmpty($matches);
|
||
$confirmUrl = html_entity_decode($matches[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
|
||
$redirect = $this->post($confirmUrl)
|
||
->assertStatus(302)
|
||
->assertSessionHas(
|
||
'success',
|
||
'已全部确认:1 行退货扣减生效(1 行异议保持原状态,暂不扣减);确认回执已发送到该厂家钉钉群。',
|
||
);
|
||
|
||
Http::assertSentCount(1);
|
||
Http::assertSent(fn ($request): bool => str_contains(
|
||
(string) ($request['markdown']['text'] ?? ''),
|
||
'供应商退货确认回执 · 晨丰',
|
||
) && str_contains(
|
||
(string) ($request['markdown']['text'] ?? ''),
|
||
'系统状态:**已回传财务系统**',
|
||
) && str_contains(
|
||
(string) ($request['markdown']['text'] ?? ''),
|
||
'确认时间:2026-11-04 15:09:02',
|
||
));
|
||
|
||
$statuses = app(SupplierReturnConfirmation::class)->statuses('晨丰', $companyId);
|
||
$this->assertSame(SupplierReturnConfirmation::STATUS_CONFIRMED, $statuses['return:confirm-first'] ?? null);
|
||
$this->assertSame(SupplierReturnConfirmation::STATUS_DISPUTED, $statuses['return:confirm-disputed'] ?? null);
|
||
|
||
$confirmationRows = collect($store->listRows(
|
||
SupplierReturnConfirmation::PREFIX . '晨丰',
|
||
$companyId,
|
||
))->keyBy('_action_key');
|
||
$this->assertSame(
|
||
'manufacturer_link',
|
||
$confirmationRows['return:confirm-first']['marked_via'] ?? null,
|
||
);
|
||
|
||
$this->get($redirect->headers->get('Location'))
|
||
->assertOk()
|
||
->assertSeeText('已全部确认:1 行退货扣减生效')
|
||
->assertSeeText('待 0 · 已 1 · 异 1');
|
||
}
|
||
|
||
public function test_return_share_link_rejects_a_tampered_supplier_token(): void
|
||
{
|
||
$companyId = (int) $this->company->id;
|
||
app(SupplierPayableOverrideStore::class)->save(
|
||
'final:supplier:return-reconciliation',
|
||
'return:share-security',
|
||
[
|
||
'summary_month' => '2026-10',
|
||
'send_time' => '2026-10-08 10:00:00',
|
||
'supplier' => '晨丰',
|
||
'barcode' => 'SHARE-SECURITY',
|
||
'sku_name' => '签名校验商品',
|
||
'qty' => '1',
|
||
'unit_price' => '10',
|
||
'amount' => '10',
|
||
],
|
||
$companyId,
|
||
null,
|
||
);
|
||
|
||
$url = app(SupplierReturnShareLink::class)->pageUrl($companyId, '晨丰', '2026-10');
|
||
$tampered = preg_replace_callback(
|
||
'/supplier-returns\/([^?\/]+)/',
|
||
static fn (array $matches): string => 'supplier-returns/'
|
||
. ($matches[1][0] === 'A' ? 'B' : 'A') . substr($matches[1], 1),
|
||
$url,
|
||
1,
|
||
);
|
||
|
||
$this->assertNotSame($url, $tampered);
|
||
$this->expectException(InvalidSignatureException::class);
|
||
$this->get($tampered);
|
||
}
|
||
|
||
public function test_large_monthly_return_push_uses_one_summary_message_without_chat_details(): 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', (int) $this->company->id);
|
||
|
||
$this->assertSame(601, $result['rows']);
|
||
$this->assertSame(1, $result['messages']);
|
||
Http::assertSentCount(1);
|
||
Http::assertSent(function ($request): bool {
|
||
$text = (string) ($request['markdown']['text'] ?? '');
|
||
|
||
return str_contains($text, '本月全部退货:**601** 行')
|
||
&& str_contains($text, '[查看退货明细表格](')
|
||
&& ! str_contains($text, 'RETURN-BULK-01')
|
||
&& ! str_contains($text, 'RETURN-BULK-601');
|
||
});
|
||
}
|
||
|
||
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,
|
||
];
|
||
}
|
||
}
|