CaiWuXiTong/tests/Feature/Business/SupplierReconciliationDingTalkTest.php
苏木 026733e919 fix: 修正退货确认回执北京时间 (#869)
回执和机器人最近推送时间改用平台时区,避免 UTC 导致北京时间少 8 小时。
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-04 17:31:08 +08:00

878 lines
37 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\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,
];
}
}