CaiWuXiTong/tests/Feature/Business/SupplierReconciliationTest.php
苏木 7aacb20058 feat: 供应商对账改为四层顺序匹配
按采购单号+条码+日期、采购单号+条码、条码+数量、快递单号+条码的顺序逐层匹配。

- 每层只处理上一轮剩余原始行
- 后三层允许日期差异
- 跨采购单号匹配时展示双方原单号
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-09 15:23:32 +08:00

2030 lines
89 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\Jobs\Common\CreateContact;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Auth\SupplierPortalIdentity;
use App\Models\Auth\SupplierPortalSupplier;
use App\Models\Auth\User;
use App\Models\Common\Contact;
use App\Services\Auth\SupplierPortalAccess;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
use Tests\Feature\FeatureTestCase;
class SupplierReconciliationTest extends FeatureTestCase
{
private const HEADER = '快递名称,快递单号,单据编号(采购单号),创建时间,供应商,条码,规格名称,数量,单价,金额';
protected function setUp(): void
{
parent::setUp();
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
}
public function test_finance_page_renders_with_comparison_rules(): void
{
$this->seedStatement('晨丰', [
$this->statementRow('ZSJ-1', '128.00', '2', '256.00'),
]);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '晨丰',
]))
->assertOk()
->assertSeeText('供应商对账')
->assertSeeText('财务端数据')
->assertSeeText('厂家端数据')
->assertSeeText('厂家多')
->assertSeeText('取金额低的一方结算')
->assertSeeText('采购单号 + 商品编码 + 日期')
->assertSeeText('商品编码 + 数量')
->assertSeeText('快递单号 + 商品编码')
->assertSee('list="finance-supplier-options"', false)
->assertSeeText('对账人')
->assertSeeText('下载对账单模板');
}
public function test_comparison_uses_express_fallback_after_earlier_match_layers(): void
{
$companyId = (int) $this->company->id;
app(SupplierPayableOverrideStore::class)->save(
'final:supplier:payment-reconciliation',
'express:000001',
[
'summary_month' => '2026-10',
'expressNo' => '@SF123456',
'purchaseNo' => 'MINE-PO',
'date' => '2026-10-01',
'supplier' => '晨丰',
'skuCode' => 'CZ000927',
'productCode' => 'CZ000927N1201V02',
'qty' => '7',
'unitPrice' => '86',
'amount' => '602',
],
$companyId,
null,
);
$this->seedStatement('晨丰', [
array_merge($this->statementRow('CZ000927N1201V01', '86.00', '8', '688.00'), [
'expressNo' => 'SF123456',
'purchaseNo' => null,
'createdAt' => '2026-10-09',
]),
]);
$detail = app(SupplierReconciliationPageData::class)->financeDetailForFilters(
'晨丰',
$companyId,
['month' => '2026-10'],
);
$this->assertSame(0, $detail['summary']['match']);
$this->assertSame(0, $detail['summary']['supplierExtra']);
$this->assertSame(0, $detail['summary']['supplierMissing']);
$this->assertSame(1, $detail['summary']['mismatch']);
$this->assertSame('SF123456', $detail['rows'][0]['expressNo']);
$this->assertSame(4, $detail['rows'][0]['matchedByLayer']);
$this->assertStringContainsString('快递单号+商品编码匹配', $detail['rows'][0]['note']);
}
public function test_finance_supplier_list_shows_return_confirmation_progress(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
foreach (['晨丰', '鸣飞家具', '无退货厂家'] as $index => $supplier) {
$store->save('final:supplier:payment-reconciliation', 'supplier:' . $supplier, [
'summary_month' => '2026-10',
'purchaseNo' => 'PO-' . $index,
'date' => '2026-10-01',
'supplier' => $supplier,
'skuCode' => 'SKU-' . $index,
'qty' => '1',
'unitPrice' => '10',
'amount' => '10',
'reconciler' => '张三',
], $companyId, null);
}
foreach ([
'return:cf-pending' => '晨丰',
'return:cf-confirmed' => '晨丰',
'return:cf-disputed' => '晨丰',
'return:mf-confirmed-1' => '鸣飞家具',
'return:mf-confirmed-2' => '鸣飞家具',
] as $key => $supplier) {
$store->save('final:supplier:return-reconciliation', $key, [
'summary_month' => '2026-10',
'send_time' => '2026-10-08 10:00:00',
'supplier' => $supplier,
'barcode' => strtoupper($key),
'sku_name' => '退货进度测试',
'qty' => '1',
'unit_price' => '10',
'amount' => '10',
'warehouse' => '一号仓',
], $companyId, null);
}
$store->save('final:supplier:return-reconciliation', 'return:cf-september', [
'summary_month' => '2026-09',
'send_time' => '2026-09-30 10:00:00',
'supplier' => '晨丰',
'barcode' => 'RETURN:CF-SEPTEMBER',
'sku_name' => '其他月份退货',
'qty' => '1',
'unit_price' => '10',
'amount' => '10',
'warehouse' => '一号仓',
], $companyId, null);
$confirmations = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class);
$confirmations->mark('晨丰', 'return:cf-confirmed', \Modules\EcommerceFinance\Services\SupplierReturnConfirmation::STATUS_CONFIRMED, $companyId, null);
$confirmations->mark('晨丰', 'return:cf-disputed', \Modules\EcommerceFinance\Services\SupplierReturnConfirmation::STATUS_DISPUTED, $companyId, null);
$confirmations->mark('晨丰', 'return:cf-september', \Modules\EcommerceFinance\Services\SupplierReturnConfirmation::STATUS_CONFIRMED, $companyId, null);
$confirmations->mark('鸣飞家具', 'return:mf-confirmed-1', \Modules\EcommerceFinance\Services\SupplierReturnConfirmation::STATUS_CONFIRMED, $companyId, null);
$confirmations->mark('鸣飞家具', 'return:mf-confirmed-2', \Modules\EcommerceFinance\Services\SupplierReturnConfirmation::STATUS_CONFIRMED, $companyId, null);
$response = $this->loginAs()
->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'month' => '2026-10',
]))
->assertOk()
->assertSeeText('厂家确认退货完成情况')
->assertDontSee('sr-return-confirmation__count', false);
$html = (string) $response->getContent();
$this->assertStringContainsString('data-return-confirmation-status="pending">待确认</span>', $html);
$this->assertStringContainsString('data-return-confirmation-status="confirmed">已确认</span>', $html);
$this->assertStringContainsString('data-return-confirmation-status="none">无退货</span>', $html);
}
public function test_finance_page_filters_suppliers_by_reconciler(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1', 'date' => '2026-09-30',
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
], $companyId, null);
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
'summary_month' => '2026-09', 'purchaseNo' => '编号-SJ-1', 'date' => '2026-09-30',
'supplier' => '鸣飞家具', 'skuCode' => 'SJ-1',
'qty' => '1', 'unitPrice' => '96', 'amount' => '96', 'reconciler' => '李四',
], $companyId, null);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'reconciler' => '张三',
]))
->assertOk()
->assertSeeText('对账人')
->assertSeeText('张三')
->assertSeeText('晨丰')
->assertDontSeeText('鸣飞家具');
}
public function test_comparison_detail_view_replaces_the_supplier_list(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$store->save('final:supplier:payment-reconciliation', 'line:000001', [
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-1N1097V01', 'date' => '2026-09-30',
'supplier' => '晨丰', 'skuCode' => 'ZSJ-1',
'qty' => '2', 'unitPrice' => '128', 'amount' => '256', 'reconciler' => '张三',
], $companyId, null);
$store->save('final:supplier:payment-reconciliation', 'line:000002', [
'summary_month' => '2026-09', 'purchaseNo' => '编号-ZSJ-2N1097V01', 'date' => '2026-09-30',
'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三',
], $companyId, null);
$this->seedStatement('晨丰', [
$this->statementRow('ZSJ-1N1097V01', '128.00', '3', '384.00'),
$this->statementRow('ZSJ-3N1097V01', '80.00', '1', '80.00'),
]);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '晨丰',
]))
->assertOk()
->assertSeeText('比对明细')
->assertSeeText('返回供应商列表')
->assertSeeText('结算金额')
->assertSeeText('采购单号')
->assertSeeText('日期')
->assertSeeText('数量差异(厂家-我方)')
->assertSeeText('厂家多 1.00')
->assertSeeText('厂家多 ¥128.00')
->assertSeeText('编号-ZSJ-1N1097V01')
->assertSeeText('导出当前筛选')
->assertSee('sr-comparison-scroll', false)
->assertSeeText('每页 15 条')
->assertSeeText('ZSJ-3')
->assertSeeText('ZSJ-2')
->assertDontSeeText('各厂家上传情况');
}
public function test_finance_comparison_filters_supplier_over_and_under_amounts(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
foreach ([
'mine:over' => ['purchaseNo' => 'PO-OVER', 'skuCode' => 'A-1'],
'mine:under' => ['purchaseNo' => 'PO-UNDER', 'skuCode' => 'B-1'],
'mine:match' => ['purchaseNo' => 'PO-MATCH', 'skuCode' => 'C-1'],
] as $key => $row) {
$store->save('final:supplier:payment-reconciliation', $key, $row + [
'summary_month' => '2026-09',
'date' => '2026-09-30',
'supplier' => '晨丰',
'qty' => '1',
'unitPrice' => '100',
'amount' => '100',
'reconciler' => '张三',
], $companyId, null);
}
$this->seedStatement('晨丰', [
array_merge($this->statementRow('A-1N1097V01', '100.00', '2', '200.00'), [
'purchaseNo' => 'PO-OVER',
]),
array_merge($this->statementRow('C-1N1097V01', '100.00', '1', '100.00'), [
'purchaseNo' => 'PO-MATCH',
]),
array_merge($this->statementRow('D-1N1097V01', '100.00', '1', '100.00'), [
'purchaseNo' => 'PO-EXTRA',
]),
]);
$detail = app(SupplierReconciliationPageData::class)->financeDetailForFilters(
'晨丰',
$companyId,
['month' => '2026-09', 'comparison_filter' => 'supplier_over'],
);
$this->assertSame(4, $detail['all_total_rows']);
$this->assertSame(2, $detail['total_rows']);
$this->assertSame(2, $detail['difference_count']);
$this->assertSame(2, $detail['comparison_counts']['supplier_over']);
$this->assertSame(1, $detail['comparison_counts']['supplier_under']);
$pageUrl = fn (string $filter): string => route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '晨丰',
'month' => '2026-09',
'comparison_filter' => $filter,
]);
$this->loginAs()->get($pageUrl('supplier_over'))
->assertOk()
->assertSeeText('厂家多算')
->assertSeeText('厂家少算')
->assertSeeText('当前显示 2 / 4 条')
->assertSeeText('A-1')
->assertSeeText('D-1')
->assertDontSeeText('B-1')
->assertDontSeeText('C-1')
->assertSeeText('推送全部差异(2 条)');
$this->loginAs()->get($pageUrl('supplier_under'))
->assertOk()
->assertSeeText('当前显示 1 / 4 条')
->assertSeeText('B-1')
->assertDontSeeText('A-1')
->assertDontSeeText('D-1')
->assertDontSeeText('C-1');
$this->loginAs()->get($pageUrl('not-a-filter'))
->assertOk()
->assertSeeText('当前显示 4 / 4 条')
->assertSeeText('A-1')
->assertSeeText('B-1')
->assertSeeText('C-1')
->assertSeeText('D-1');
$export = $this->loginAs()->get(route('business.supplier-reconciliation.comparison.export', [
'supplier' => '晨丰',
'month' => '2026-09',
'comparison_filter' => 'supplier_over',
]));
$content = $export->streamedContent();
$export->assertOk()->assertDownload();
$this->assertStringContainsString('A-1', $content);
$this->assertStringContainsString('D-1', $content);
$this->assertStringNotContainsString('B-1', $content);
$this->assertStringNotContainsString('C-1', $content);
}
public function test_finance_comparison_keeps_all_source_rows_and_shows_raw_totals(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
foreach (['921979', '941828'] as $purchaseNo) {
$store->save(
'final:supplier:payment-reconciliation',
'mine:' . $purchaseNo,
[
'summary_month' => '2026-10',
'purchaseNo' => $purchaseNo,
'date' => '2026-10-10',
'supplier' => '亿之航',
'skuCode' => 'DSG004820',
'productCode' => 'DSG004820N1019V01',
'qty' => '30',
'unitPrice' => '83.5',
'amount' => '2505',
'reconciler' => '陈亲园',
],
$companyId,
null,
);
}
$this->seedStatement('亿之航', [
array_merge($this->statementRow('DSG004820N1019V01', '83.50', '30', '2505.00'), [
'purchaseNo' => '941828',
'createdAt' => '2026-10-10',
'supplier' => '亿之航',
]),
]);
$detail = app(SupplierReconciliationPageData::class)->financeDetailForFilters(
'亿之航',
$companyId,
['month' => '2026-10', 'reconciler' => '陈亲园'],
);
$this->assertSame(2, $detail['mineRows']);
$this->assertSame(1, $detail['theirsRows']);
$this->assertSame(['mineQty' => 60.0, 'mineAmount' => 5010.0, 'theirsQty' => 30.0, 'theirsAmount' => 2505.0], $detail['sourceTotals']);
$this->assertSame(1, $detail['summary']['match']);
$this->assertSame(1, $detail['summary']['supplierMissing']);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '亿之航',
'month' => '2026-10',
'reconciler' => '陈亲园',
]))
->assertOk()
->assertSeeText('双方原始合计')
->assertSeeText('我方件数合计')
->assertSeeText('厂家件数合计')
->assertSeeText('5,010.00')
->assertSeeText('2,505.00')
->assertSeeText('921979')
->assertSeeText('厂家少');
}
public function test_finance_comparison_matches_crossed_purchase_numbers_by_barcode_and_quantity(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
foreach ([
'958052' => 'DSG004815N1019V01',
'972032' => 'DSG004816N1019V01',
] as $purchaseNo => $barcode) {
$store->save(
'final:supplier:payment-reconciliation',
'crossed:' . $purchaseNo,
[
'summary_month' => '2026-10',
'purchaseNo' => $purchaseNo,
'date' => '2026-10-26',
'supplier' => '亿之航',
'skuCode' => substr($barcode, 0, 10),
'productCode' => $barcode,
'qty' => '30',
'unitPrice' => '69',
'amount' => '2070',
'reconciler' => '陈亲园',
],
$companyId,
null,
);
}
$this->seedStatement('亿之航', [
array_merge($this->statementRow('DSG004816N1019V01', '69.00', '30', '2070.00'), [
'purchaseNo' => '958052',
'createdAt' => '2026-10-26',
'supplier' => '亿之航',
]),
array_merge($this->statementRow('DSG004815N1019V01', '69.00', '30', '2070.00'), [
'purchaseNo' => '972032',
'createdAt' => '2026-10-26',
'supplier' => '亿之航',
]),
]);
$detail = app(SupplierReconciliationPageData::class)->financeDetailForFilters(
'亿之航',
$companyId,
['month' => '2026-10', 'reconciler' => '陈亲园'],
);
$rows = collect($detail['rows'])->keyBy('minePurchaseNo');
$this->assertSame(2, $detail['summary']['match']);
$this->assertSame(0, $detail['summary']['supplierExtra']);
$this->assertSame(0, $detail['summary']['supplierMissing']);
$this->assertSame(0, $detail['summary']['mismatch']);
$this->assertSame(3, $rows['958052']['matchedByLayer']);
$this->assertSame('972032', $rows['958052']['theirsPurchaseNo']);
$this->assertSame(3, $rows['972032']['matchedByLayer']);
$this->assertSame('958052', $rows['972032']['theirsPurchaseNo']);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '亿之航',
'month' => '2026-10',
'reconciler' => '陈亲园',
]))
->assertOk()
->assertSeeText('厂家:972032')
->assertSeeText('厂家:958052')
->assertSeeText('采购单号不一致')
->assertSeeText('商品编码+数量匹配');
}
public function test_supplier_without_upload_does_not_generate_or_push_missing_differences(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$store->save('final:supplier:payment-reconciliation', 'line:no-upload', [
'summary_month' => '2026-10',
'purchaseNo' => '923969',
'date' => '2026-10-02',
'supplier' => '彩虹',
'skuCode' => 'X6013282N24V02',
'qty' => '21',
'unitPrice' => '159',
'amount' => '3339',
'reconciler' => '陈亲园',
], $companyId, null);
$detail = app(SupplierReconciliationPageData::class)->financeDetailForFilters(
'彩虹',
$companyId,
['month' => '2026-10', 'reconciler' => '陈亲园'],
);
$this->assertTrue($detail['awaitingUpload']);
$this->assertSame([], $detail['rows']);
$this->assertSame(1, $detail['mineRows']);
$this->assertSame(0, $detail['theirsRows']);
$this->assertSame(0, $detail['summary']['supplierMissing']);
$this->assertSame(3339.0, $detail['summary']['mineAmount']);
$this->assertSame(0.0, $detail['summary']['differenceAmount']);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '彩虹',
'month' => '2026-10',
'reconciler' => '陈亲园',
]))
->assertOk()
->assertSeeText('该厂家还没有上传对账单')
->assertSeeText('厂家少(按厂家结算)')
->assertDontSeeText('推送全部差异')
->assertDontSeeText('923969');
Http::fake();
$this->loginAs()
->post(route('business.supplier-reconciliation.push-differences'), [
'supplier' => '彩虹',
'month' => '2026-10',
'reconciler' => '陈亲园',
'scope' => 'all',
])
->assertSessionHasErrors([
'dingtalk_push' => '厂家尚未上传对账单,暂不能推送差异。',
]);
Http::assertNothingSent();
}
public function test_finance_supplier_filter_requires_an_exact_searchable_name(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
foreach (['晨丰', '鸣飞家具'] as $supplier) {
$store->save('final:supplier:payment-reconciliation', 'supplier:' . $supplier, [
'summary_month' => '2026-09',
'purchaseNo' => 'PO-' . $supplier,
'date' => '2026-09-30',
'supplier' => $supplier,
'skuCode' => 'A-1',
'qty' => '1',
'unitPrice' => '10',
'amount' => '10',
'reconciler' => '张三',
], $companyId, null);
}
$pageUrl = route('business.pages.show', [
'company_id' => $companyId,
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
]);
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('晨丰'))
->assertOk()
->assertSeeText('晨丰 · 比对明细')
->assertSee('value="晨丰"', false);
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('不存在的厂家'))
->assertOk()
->assertSeeText('未找到名为「不存在的厂家」的供应商')
->assertDontSeeText('晨丰 · 比对明细')
->assertDontSeeText('鸣飞家具 · 比对明细');
}
public function test_finance_comparison_rows_are_paginated_fifteen_per_page(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
$statementRows = [];
for ($i = 1; $i <= 20; $i++) {
$purchaseNo = sprintf('PO-%02d', $i);
$barcode = sprintf('A-%02d', $i);
$store->save('final:supplier:payment-reconciliation', 'compare:' . $i, [
'summary_month' => '2026-09',
'purchaseNo' => $purchaseNo,
'date' => '2026-09-30',
'supplier' => '晨丰',
'skuCode' => $barcode,
'qty' => '1',
'unitPrice' => '10',
'amount' => '10',
'reconciler' => '张三',
], $companyId, null);
$statementRows[] = array_merge(
$this->statementRow($barcode . 'N1097V01', '10.00', '1', '10.00'),
['purchaseNo' => $purchaseNo],
);
}
$this->seedStatement('晨丰', $statementRows);
$pageUrl = fn (int $page): string => route('business.pages.show', [
'company_id' => $companyId,
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '晨丰',
'compare_page' => $page,
]);
$this->loginAs()->get($pageUrl(1))
->assertOk()
->assertSeeText('第 1 / 2 页')
->assertSeeText('每页 15 条')
->assertSeeText('A-01')
->assertSeeText('A-15')
->assertDontSeeText('A-16')
->assertDontSeeText('A-20');
$this->loginAs()->get($pageUrl(2))
->assertOk()
->assertSeeText('第 2 / 2 页')
->assertSeeText('A-16')
->assertSeeText('A-20')
->assertDontSeeText('A-15')
->assertDontSeeText('A-01');
}
public function test_finance_comparison_export_includes_summary_and_all_filtered_rows(): void
{
$companyId = (int) $this->company->id;
$store = app(SupplierPayableOverrideStore::class);
foreach ([
'mine:1' => ['purchaseNo' => 'PO-1', 'skuCode' => 'A-1', 'qty' => '2', 'unitPrice' => '100', 'amount' => '200', 'reconciler' => '张三'],
'mine:2' => ['purchaseNo' => 'PO-2', 'skuCode' => 'B-1', 'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三'],
'mine:other' => ['purchaseNo' => 'PO-OTHER', 'skuCode' => 'C-1', 'qty' => '1', 'unitPrice' => '30', 'amount' => '30', 'reconciler' => '李四'],
] as $key => $row) {
$store->save('final:supplier:payment-reconciliation', $key, $row + [
'summary_month' => $key === 'mine:other' ? '2026-10' : '2026-09',
'date' => '2026-09-30',
'supplier' => '晨丰',
], $companyId, null);
}
$this->seedStatement('晨丰', [
array_merge($this->statementRow('A-1N1097V01', '100.00', '2', '200.00'), ['purchaseNo' => 'PO-1']),
array_merge($this->statementRow('B-1N1097V01', '50.00', '2', '100.00'), ['purchaseNo' => 'PO-2']),
array_merge($this->statementRow('D-1N1097V01', '80.00', '1', '80.00'), ['purchaseNo' => 'PO-D']),
]);
$response = $this->loginAs()->get(route('business.supplier-reconciliation.comparison.export', [
'supplier' => '晨丰',
'month' => '2026-09',
'reconciler' => '张三',
]));
$content = $response->streamedContent();
$response->assertOk()
->assertDownload()
->assertHeader('content-type', 'text/csv; charset=UTF-8');
foreach ([
'供应商,晨丰',
'月份,2026-09',
'对账人,张三',
'双方一致,1',
'厂家多,1',
'数量/金额不一致,1',
'厂家少,0',
'采购单号,日期,我方商品编码,厂家条码,比对结果',
'PO-1',
'PO-2',
'PO-D',
'厂家多 1',
'厂家多 ¥50.00',
] as $expected) {
$this->assertStringContainsString($expected, $content);
}
$this->assertStringNotContainsString('PO-OTHER', $content);
}
public function test_supplier_page_shows_uploads_and_return_rows(): void
{
$this->seedStatement('晨丰', [
$this->statementRow('ZSJ-1', '128.00', '2', '256.00'),
]);
app(SupplierPayableOverrideStore::class)->save(
'final:supplier:return-reconciliation',
'line:000001',
['summary_month' => '2026-09', 'send_time' => '2026-09-30', 'supplier' => '晨丰', 'barcode' => 'ZSJ-1',
'sku_name' => '款式十一', 'qty' => '1', 'unit_price' => '128', 'amount' => '128'],
(int) $this->company->id,
null,
);
$response = $this->loginAs()
->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '晨丰',
]))
->assertOk()
->assertSeeText('厂家对账单 · 上传与实时数据')
->assertSeeText('上传记录')
->assertSeeText('退货扣减确认')
->assertSeeText('退货金额')
->assertSeeText('全部确认无异议')
->assertSeeText('待确认')
->assertSeeText('条形码')
->assertSeeText('商品名称')
->assertSeeText('发货时间')
->assertSeeText('ZSJ-1')
->assertSeeText('查询')
->assertSeeText('导出当前筛选结果')
->assertDontSeeText('厂家上传成功后,下面的数据与「财务端数据」的比对结果立即更新。');
$response->assertSee('sr-return-scroll', false);
$html = (string) $response->getContent();
$uploadHistoryPosition = strpos($html, ' · 上传记录');
$returnPanelPosition = strpos($html, '退货扣减确认');
$this->assertNotFalse($uploadHistoryPosition, '内部页面应保留上传记录。');
$this->assertNotFalse($returnPanelPosition, '内部页面应正常渲染退货面板。');
$this->assertGreaterThan($returnPanelPosition, $uploadHistoryPosition, '上传记录应移动到页面最下面。');
$tableStart = strpos($html, '<table class="sr-return-table">');
$tableEnd = $tableStart === false ? false : strpos($html, '</table>', $tableStart);
$this->assertNotFalse($tableStart, '厂家退货表应正常渲染。');
$this->assertNotFalse($tableEnd, '厂家退货表应正常结束。');
$returnTable = substr($html, $tableStart, $tableEnd - $tableStart);
$this->assertSame(14, substr_count($returnTable, '<th scope="col">'));
foreach (['月份', '预估单价', '预估金额', '供应商条码', '标记多标签', '创建人', '对账人'] as $hiddenColumn) {
$this->assertStringNotContainsString('<th scope="col">' . $hiddenColumn . '</th>', $returnTable);
}
}
public function test_supplier_page_uses_a_searchable_exact_supplier_picker(): void
{
$this->seedStatement('晨丰', [
$this->statementRow('ZSJ-1', '128.00', '2', '256.00'),
]);
$this->seedStatement('鸣飞家具', [
array_merge($this->statementRow('SJ-1', '96.00', '1', '96.00'), ['supplier' => '鸣飞家具']),
]);
$pageUrl = route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
]);
$this->loginAs()->get($pageUrl)
->assertOk()
->assertSee('name="supplier"', false)
->assertSee('list="supplier-name-options"', false)
->assertSeeText('查询厂家')
->assertSee('value="晨丰"', false)
->assertSee('value="鸣飞家具"', false);
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('鸣飞家具'))
->assertOk()
->assertSeeText('鸣飞家具 · 上传记录')
->assertDontSeeText('晨丰 · 上传记录');
$this->loginAs()->get($pageUrl . '?supplier=' . urlencode('不存在的厂家'))
->assertOk()
->assertSeeText('未找到名为「不存在的厂家」的厂家')
->assertDontSeeText('晨丰 · 上传记录')
->assertDontSeeText('鸣飞家具 · 上传记录');
}
public function test_supplier_portal_is_locked_to_its_own_page_and_blocks_core_routes(): void
{
$this->seedStatement('晨丰', [
$this->statementRow('ZSJ-1', '128.00', '2', '256.00'),
]);
$supplierUser = $this->createSupplierPortalUser('晨丰');
$pageUrl = route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '鸣飞家具',
]);
$this->actingAsSupplierPortal($supplierUser)->get($pageUrl)
->assertOk()
->assertSeeText('当前厂家(由钉钉组织自动确定)')
->assertSeeText('晨丰')
->assertDontSee('list="supplier-name-options"', false)
->assertDontSeeText('权限管理')
->assertDontSeeText('数据库管理')
->assertDontSeeText('鸣飞家具 · 上传记录');
$this->withExceptionHandling();
$this->actingAsSupplierPortal($supplierUser)
->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
]))
->assertForbidden();
$this->actingAsSupplierPortal($supplierUser)
->get(route('business.supplier-reconciliation.comparison.export', ['company_id' => $this->company->id]))
->assertForbidden();
$this->actingAsSupplierPortal($supplierUser)
->get(route('dashboard', ['company_id' => $this->company->id]))
->assertRedirect(route('business.supplier-portal.home', [
'company_id' => $this->company->id,
]));
$this->actingAsSupplierPortal($supplierUser)
->get(route('business.supplier-portal.home', ['company_id' => $this->company->id]))
->assertRedirect(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
]));
}
public function test_supplier_portal_user_can_choose_between_their_suppliers(): void
{
config(['app.throttles.import' => 100]);
$this->seedStatement('晨丰', [
$this->statementRow('CF-1', '128.00', '2', '256.00'),
]);
$this->seedStatement('鸣飞家具', [
array_merge($this->statementRow('MF-1', '96.00', '1', '96.00'), ['supplier' => '鸣飞家具']),
]);
$supplierUser = $this->createSupplierPortalUser('晨丰');
$this->bindSupplierPortalSupplier($supplierUser, '鸣飞家具', 'MF-001', 2);
$pageUrl = route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
]);
$this->actingAsSupplierPortal($supplierUser)->get($pageUrl)
->assertOk()
->assertSeeText('请选择厂家')
->assertSeeText('当前钉钉账号可操作 2 家供应商')
->assertSeeText('晨丰')
->assertSeeText('鸣飞家具')
->assertDontSeeText('当前厂家(由钉钉组织自动确定)');
$this->actingAsSupplierPortal($supplierUser)
->get($pageUrl . '?supplier=' . urlencode('鸣飞家具'))
->assertOk()
->assertSeeText('当前厂家(由钉钉组织自动确定)')
->assertSeeText('鸣飞家具')
->assertSeeText('切换厂家')
->assertSeeText('鸣飞家具 · 上传的对账数据')
->assertDontSeeText('晨丰 · 上传的对账数据');
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'supplier' => '鸣飞家具',
'import' => UploadedFile::fake()->createWithContent(
'statement.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,MF-UPLOAD,2026-09-30,鸣飞家具,MF-2,规格乙,1,96.00,96.00\n",
),
])
->assertStatus(302)
->assertSessionHas('success', '对账单已保存:鸣飞家具 共 1 行。');
$this->withExceptionHandling();
$this->actingAsSupplierPortal($supplierUser)
->get($pageUrl . '?supplier=' . urlencode('未授权厂家'))
->assertForbidden();
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'supplier' => '未授权厂家',
'import' => UploadedFile::fake()->createWithContent(
'statement.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,OTHER-UPLOAD,2026-09-30,未授权厂家,OTHER-1,规格丙,1,10.00,10.00\n",
),
])
->assertForbidden();
}
public function test_supplier_portal_page_hides_internal_sidebar_search_and_links(): void
{
config(['sidebar_links' => [
['title' => '内部常用网址', 'url' => 'https://example.com/internal-link'],
]]);
$supplierUser = $this->createSupplierPortalUser('晨丰');
$this->actingAsSupplierPortal($supplierUser)
->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
]))
->assertOk()
->assertDontSee('id="prototype-menu-filter"', false)
->assertDontSee('data-sidebar-links-toggle', false)
->assertDontSee('data-sidebar-links', false)
->assertDontSeeText('内部常用网址')
->assertDontSee('https://example.com/internal-link', false);
}
public function test_supplier_portal_upload_and_return_actions_ignore_spoofed_supplier(): void
{
$supplierUser = $this->createSupplierPortalUser('晨丰');
$file = UploadedFile::fake()->createWithContent(
'statement.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,PORTAL-UPLOAD,2026-09-30,晨丰,ZSJ-1,规格甲,2,128.00,256.00\n",
);
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'supplier' => '鸣飞家具',
'import' => $file,
])
->assertStatus(302)
->assertSessionHas('success', '对账单已保存:晨丰 共 1 行。');
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$store->save('final:supplier:return-reconciliation', 'portal:own-confirm', [
'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰',
'barcode' => 'OWN-CONFIRM', 'qty' => '1', 'unit_price' => '100', 'amount' => '100',
], $companyId, null);
$store->save('final:supplier:return-reconciliation', 'portal:own-dispute', [
'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰',
'barcode' => 'OWN-DISPUTE', 'qty' => '1', 'unit_price' => '100', 'amount' => '100',
], $companyId, null);
$store->save('final:supplier:return-reconciliation', 'portal:foreign', [
'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '鸣飞家具',
'barcode' => 'FOREIGN-RETURN', 'qty' => '1', 'unit_price' => '100', 'amount' => '100',
], $companyId, null);
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [
'supplier' => '鸣飞家具',
'key' => 'portal:own-confirm',
])
->assertStatus(302)
->assertSessionHas('success', '已确认该行退货扣减。');
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.return.dispute', ['company_id' => $companyId]), [
'supplier' => '鸣飞家具',
'key' => 'portal:own-dispute',
])
->assertStatus(302)
->assertSessionHas('success', '已提交异议,复核通过前该行暂不扣减。');
$this->withExceptionHandling();
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [
'key' => 'portal:foreign',
])
->assertForbidden();
$this->assertCount(1, app(SupplierStatementStore::class)->rows('晨丰', $companyId));
$this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', $companyId));
}
public function test_supplier_statement_upload_sends_a_dingtalk_receipt(): void
{
$supplierUser = $this->createSupplierPortalUser('晨丰');
app(SupplierDingTalkNotifier::class)->save(
'晨丰',
'https://oapi.dingtalk.com/robot/send?access_token=statement-upload-test',
'',
false,
);
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$file = UploadedFile::fake()->createWithContent(
'晨丰_对账单_202609.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,CF-UPLOAD,2026-09-30,晨丰,ZSJ-UPLOAD,规格甲,2,128.00,256.00\n",
);
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'supplier' => '晨丰',
'import' => $file,
])
->assertStatus(302)
->assertSessionHas(
'success',
'对账单已保存:晨丰 共 1 行;上传回执已发送到该厂家钉钉群。',
);
Http::assertSentCount(1);
Http::assertSent(fn ($request): bool => str_contains(
(string) ($request['markdown']['text'] ?? ''),
'供应商对账单上传回执 · 晨丰',
) && str_contains(
(string) ($request['markdown']['text'] ?? ''),
'对账月份:**2026-09**',
) && str_contains(
(string) ($request['markdown']['text'] ?? ''),
'上传文件:晨丰_对账单_202609.csv',
) && str_contains(
(string) ($request['markdown']['text'] ?? ''),
'上传行数:**1** 行',
) && str_contains(
(string) ($request['markdown']['text'] ?? ''),
'数据状态:**已进入财务核对**',
));
}
public function test_vendor_portal_statement_upload_sends_a_dingtalk_receipt(): void
{
$vendor = $this->createVendorPortalUser('晨丰');
app(SupplierDingTalkNotifier::class)->save(
'晨丰',
'https://oapi.dingtalk.com/robot/send?access_token=portal-statement-upload-test',
'',
false,
);
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$file = UploadedFile::fake()->createWithContent(
'portal_202609.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,PORTAL-RECEIPT,2026-09-30,晨丰,ZSJ-PORTAL,规格甲,1,100.00,100.00\n",
);
$this->loginAs($vendor)
->post(route('portal.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'import' => $file,
])
->assertStatus(302)
->assertSessionHas(
'success',
'对账单已保存:晨丰 共 1 行;上传回执已发送到该厂家钉钉群。',
);
Http::assertSentCount(1);
Http::assertSent(fn ($request): bool => str_contains(
(string) ($request['markdown']['text'] ?? ''),
'供应商对账单上传回执 · 晨丰',
) && str_contains(
(string) ($request['markdown']['text'] ?? ''),
'上传文件:portal_202609.csv',
));
}
public function test_dingtalk_receipt_failure_does_not_roll_back_the_statement_upload(): void
{
$supplierUser = $this->createSupplierPortalUser('晨丰');
app(SupplierDingTalkNotifier::class)->save(
'晨丰',
'https://oapi.dingtalk.com/robot/send?access_token=statement-failure-test',
'',
false,
);
Http::fake(['*' => Http::response(['errcode' => 500, 'errmsg' => 'robot failed'], 500)]);
$file = UploadedFile::fake()->createWithContent(
'receipt_failure.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,RECEIPT-FAILURE,2026-09-30,晨丰,ZSJ-FAILURE,规格甲,1,100.00,100.00\n",
);
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'supplier' => '晨丰',
'import' => $file,
])
->assertStatus(302)
->assertSessionHas(
'success',
'对账单已保存:晨丰 共 1 行;上传回执发送失败,对账单已正常保存。',
);
Http::assertSentCount(1);
$this->assertCount(
1,
app(SupplierStatementStore::class)->rows('晨丰', (int) $this->company->id),
);
}
public function test_vendor_portal_shows_only_all_returns_for_the_logged_in_supplier(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$this->seedStatement('鸣飞家具', [
array_merge($this->statementRow('OTHER-SUPPLIER', '100.00', '1', '100.00'), ['supplier' => '鸣飞家具']),
]);
for ($i = 1; $i <= 20; $i++) {
$store->save('final:supplier:return-reconciliation', sprintf('portal:%02d', $i), [
'summary_month' => '2026-09',
'send_time' => '2026-09-20',
'supplier' => '晨丰',
'barcode' => sprintf('PORTAL-%02d', $i),
'sku_name' => '门户退货商品',
'qty' => '1',
'unit_price' => '100',
'amount' => '100',
], $companyId, null);
}
$store->save('final:supplier:return-reconciliation', 'portal:other', [
'summary_month' => '2026-09',
'send_time' => '2026-09-20',
'supplier' => '鸣飞家具',
'barcode' => 'OTHER-SUPPLIER-RETURN',
'qty' => '1',
'unit_price' => '100',
'amount' => '100',
], $companyId, null);
$vendor = $this->createVendorPortalUser('晨丰');
$portalUrl = route('portal.supplier-reconciliation.index', ['company_id' => $companyId]);
$this->post(route('login'), [
'email' => $vendor->email,
'password' => 'portal-password',
])
->assertRedirect($portalUrl);
$this->loginAs($vendor)->get($portalUrl)
->assertOk()
->assertSeeText('供应商对账')
->assertSeeText('晨丰 · 厂家对账')
->assertSeeText('退货明细(全部退货数据)')
->assertSeeText('PORTAL-01')
->assertDontSeeText('PORTAL-20')
->assertDontSeeText('OTHER-SUPPLIER-RETURN')
->assertDontSeeText('鸣飞家具')
->assertDontSeeText('最近上传')
->assertDontSee('supplier-name-options', false)
->assertSee('sp-return-scroll', false)
->assertSee('id="portal-return-date-from"', false)
->assertSee('data-return-date-range="last-month"', false)
->assertDontSeeText('预估单价');
$this->loginAs($vendor)->get($portalUrl . '?return_page=2')
->assertOk()
->assertSeeText('PORTAL-20')
->assertDontSeeText('PORTAL-01')
->assertDontSeeText('OTHER-SUPPLIER-RETURN');
}
public function test_non_vendor_portal_account_cannot_open_supplier_return_page(): void
{
$this->withExceptionHandling();
$customer = Contact::customer()->whereNotNull('user_id')->firstOrFail();
$this->loginAs($customer->user)
->get(route('portal.supplier-reconciliation.index', ['company_id' => $this->company->id]))
->assertForbidden();
}
public function test_vendor_portal_export_and_confirmation_are_scoped_to_the_bound_supplier(): void
{
$this->withExceptionHandling();
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$store->save('final:supplier:return-reconciliation', 'portal:own', [
'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰',
'barcode' => 'OWN-RETURN', 'qty' => '1', 'unit_price' => '100', 'amount' => '100',
], $companyId, null);
$store->save('final:supplier:return-reconciliation', 'portal:foreign', [
'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '鸣飞家具',
'barcode' => 'FOREIGN-RETURN', 'qty' => '1', 'unit_price' => '100', 'amount' => '100',
], $companyId, null);
$vendor = $this->createVendorPortalUser('晨丰');
$response = $this->loginAs($vendor)->get(route('portal.supplier-reconciliation.returns.export', [
'company_id' => $companyId,
'supplier' => '鸣飞家具',
]));
$content = $response->streamedContent();
$response->assertOk()->assertDownload();
$this->assertStringContainsString('OWN-RETURN', $content);
$this->assertStringNotContainsString('FOREIGN-RETURN', $content);
$this->assertStringNotContainsString('预估单价', $content);
$this->loginAs($vendor)
->post(route('portal.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [
'key' => 'portal:foreign',
])
->assertForbidden();
$this->loginAs($vendor)
->post(route('portal.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [
'key' => 'portal:own',
])
->assertStatus(302)
->assertSessionHas('success', '已确认该行退货扣减。');
$statuses = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class)
->statuses('晨丰', $companyId);
$this->assertSame('confirmed', $statuses['portal:own']);
$this->assertArrayNotHasKey('portal:foreign', $statuses);
}
public function test_vendor_portal_upload_always_uses_the_bound_supplier(): void
{
$vendor = $this->createVendorPortalUser('晨丰');
$file = UploadedFile::fake()->createWithContent(
'statement.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,PORTAL-UPLOAD,2026-09-30,晨丰,ZSJ-1,规格甲,2,128.00,256.00\n",
);
$this->loginAs($vendor)
->post(route('portal.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'supplier' => '鸣飞家具',
'import' => $file,
])
->assertStatus(302)
->assertSessionHas('success', '对账单已保存:晨丰 共 1 行。');
$this->assertCount(1, app(SupplierStatementStore::class)->rows('晨丰', (int) $this->company->id));
$this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id));
}
public function test_vendor_portal_rejects_a_statement_for_another_supplier(): void
{
$this->withExceptionHandling();
$vendor = $this->createVendorPortalUser('晨丰');
$file = UploadedFile::fake()->createWithContent(
'statement.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,PORTAL-WRONG-SUPPLIER,2026-09-30,鸣飞家具,ZSJ-1,规格甲,2,128.00,256.00\n",
);
$this->loginAs($vendor)
->post(route('portal.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'import' => $file,
])
->assertSessionHasErrors([
'import' => '第 2 行:供应商必须填写「晨丰」,不能提交其他厂家的数据。',
]);
$this->assertCount(0, app(SupplierStatementStore::class)->rows('晨丰', (int) $this->company->id));
$this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id));
}
public function test_return_bookkeeping_note_is_identical_on_finance_and_supplier_views(): void
{
$companyId = (int) $this->company->id;
app(SupplierPayableOverrideStore::class)->save(
'final:supplier:payment-reconciliation',
'mine:logistics:1',
[
'summary_month' => '2026-09',
'purchaseNo' => 'PO-LOGISTICS-1',
'date' => '2026-09-27',
'supplier' => '曹县宇诺',
'reconciler' => '陈亲园',
'skuCode' => 'CZ008407',
'qty' => '1',
'unitPrice' => '259',
'amount' => '259',
],
$companyId,
null,
);
app(SupplierPayableOverrideStore::class)->save(
'final:supplier:return-reconciliation',
'return:logistics:1',
[
'summary_month' => '2026-09',
'send_time' => '2026-09-27',
'supplier' => '曹县宇诺',
'barcode' => 'CZ008407N1178V01',
'sku_name' => '款式二 160/80/75 黑胡桃色',
'qty' => '1',
'unit_price' => '259',
'amount' => '259',
'remark' => '原数量2件,CZ008407N1178V01*1记账物流',
'title' => '常州松宇货运',
'express_no' => '202609270079',
'reconciler' => '陈亲园',
],
$companyId,
null,
);
$finance = $this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'finance-data',
'supplier' => '曹县宇诺',
'month' => '2026-09',
'reconciler' => '陈亲园',
]));
$finance->assertOk()
->assertSeeText('退货扣减 · 财务与厂家同步')
->assertSeeText('转物流记账')
->assertSeeText('常州松宇货运')
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流')
->assertSeeText('259.00');
$supplier = $this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '曹县宇诺',
]));
$supplier->assertOk()
->assertSeeText('转物流记账')
->assertSeeText('不扣本厂家,转常州松宇货运记账')
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流');
$vendor = $this->createVendorPortalUser('曹县宇诺');
$this->loginAs($vendor)->get(route('portal.supplier-reconciliation.index', [
'company_id' => $companyId,
]))
->assertOk()
->assertSeeText('转物流记账')
->assertSeeText('不扣本厂家,转常州松宇货运记账')
->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流');
}
public function test_statement_rows_are_paginated_fifteen_per_page(): void
{
$rows = [];
for ($i = 1; $i <= 20; $i++) {
$rows[] = $this->statementRow(sprintf('ST-%02d', $i), '10.00', '1', '10.00');
}
$this->seedStatement('晨丰', $rows);
$pageUrl = fn (int $page): string => route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '晨丰',
'rows_page' => $page,
]);
$this->loginAs()->get($pageUrl(1))
->assertOk()
->assertSeeText('每页 15 行')
->assertSeeText('ST-01')
->assertDontSeeText('ST-20');
$this->loginAs()->get($pageUrl(2))
->assertOk()
->assertSeeText('ST-20')
->assertDontSeeText('ST-01');
}
public function test_return_rows_show_all_rows_with_pagination(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
for ($i = 1; $i <= 20; $i++) {
$store->save('final:supplier:return-reconciliation', sprintf('return:%02d', $i), [
'summary_month' => '2026-09', 'send_time' => '2026-09-18', 'supplier' => '晨丰',
'barcode' => sprintf('RT-%02d', $i), 'qty' => '1', 'unit_price' => (string) (100 + $i),
'amount' => (string) (100 + $i), 'warehouse' => '客退临时上架仓',
], $companyId, null);
}
$this->seedStatement('晨丰', [$this->statementRow('ZSJ-1', '128.00', '2', '256.00')]);
$pageUrl = fn (int $page): string => route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '晨丰',
'return_page' => $page,
]);
$this->loginAs()->get($pageUrl(1))
->assertOk()
->assertSeeText('每页 10 行')
->assertSeeText('第 1 / 2 页')
->assertSeeText('RT-01')
->assertSeeText('RT-10')
->assertDontSeeText('RT-11')
->assertDontSeeText('RT-20');
$this->loginAs()->get($pageUrl(2))
->assertOk()
->assertSeeText('第 2 / 2 页')
->assertSeeText('RT-11')
->assertSeeText('RT-20')
->assertDontSeeText('RT-10')
->assertDontSeeText('RT-01');
}
public function test_supplier_return_rows_can_be_queried_and_pagination_keeps_filters(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
for ($i = 1; $i <= 20; $i++) {
$store->save('final:supplier:return-reconciliation', sprintf('match:%02d', $i), [
'summary_month' => '2026-09',
'send_time' => $i <= 15 ? '2026-09-20 10:00:00' : '2026-09-21 10:00:00',
'supplier' => '晨丰',
'barcode' => sprintf('MATCH-%02d', $i),
'sku_name' => '查询商品',
'qty' => '1',
'unit_price' => '100',
'amount' => '100',
'warehouse' => '客退临时上架仓',
'express_no' => sprintf('SF%018d', $i),
], $companyId, null);
}
$store->save('final:supplier:return-reconciliation', 'other:01', [
'summary_month' => '2026-09',
'send_time' => '2026-09-20 09:00:00',
'supplier' => '晨丰',
'barcode' => 'OTHER-01',
'sku_name' => '其他商品',
'qty' => '1',
'unit_price' => '100',
'amount' => '100',
'warehouse' => '其他仓',
], $companyId, null);
app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class)
->mark('晨丰', 'match:20', 'confirmed', $companyId, null);
$this->seedStatement('晨丰', [$this->statementRow('ZSJ-1', '128.00', '1', '128.00')]);
$filters = [
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '晨丰',
'return_keyword' => 'MATCH',
'return_date_from' => '2026-09-20',
'return_date_to' => '2026-09-21',
'return_warehouse' => '客退临时上架仓',
'return_status' => 'pending',
];
$this->loginAs()->get(route('business.pages.show', $filters))
->assertOk()
->assertSeeText('当前筛选 19 件,退货金额 ¥1,900.00')
->assertSee('value="2026-09-20"', false)
->assertSee('value="2026-09-21"', false)
->assertSee('data-return-date-range="last-month"', false)
->assertSeeText('MATCH-01')
->assertDontSeeText('MATCH-20')
->assertDontSeeText('OTHER-01')
->assertSeeText('导出当前筛选结果');
$this->loginAs()->get(route('business.pages.show', $filters + ['return_page' => 2]))
->assertOk()
->assertSeeText('MATCH-19')
->assertDontSeeText('MATCH-01')
->assertSee('return_keyword=MATCH', false)
->assertSee('return_status=pending', false);
}
public function test_return_summary_uses_quantity_and_amount_without_row_counts(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
$rows = [
'summary:1' => ['qty' => '2', 'amount' => '200'],
'summary:2' => ['qty' => '3', 'amount' => '300'],
'summary:3' => ['qty' => '1', 'amount' => '50'],
'summary:4' => ['qty' => '4', 'amount' => '400'],
];
foreach ($rows as $key => $values) {
$store->save('final:supplier:return-reconciliation', $key, $values + [
'summary_month' => '2026-09',
'send_time' => '2026-09-20',
'supplier' => '晨丰',
'barcode' => strtoupper($key),
'unit_price' => '100',
], $companyId, null);
}
$confirmations = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class);
$confirmations->mark('晨丰', 'summary:1', 'confirmed', $companyId, null);
$confirmations->mark('晨丰', 'summary:3', 'disputed', $companyId, null);
$vendor = $this->createVendorPortalUser('晨丰');
$portal = $this->loginAs($vendor)->get(route('portal.supplier-reconciliation.index', [
'company_id' => $companyId,
]));
$portal->assertOk()
->assertSeeText('退货件数')
->assertSeeText('退货金额')
->assertSee('10<small>件</small>', false)
->assertSeeText('¥950.00')
->assertSee('2<small>件</small>', false)
->assertSeeText('¥200.00')
->assertSee('1<small>件</small>', false)
->assertSeeText('¥50.00')
->assertSee('7<small>件</small>', false)
->assertSeeText('¥700.00')
->assertDontSeeText('退货合计')
->assertDontSeeText('扣减合计')
->assertDontSeeText('已确认 2 行')
->assertDontSeeText('异议 1 行')
->assertDontSeeText('待确认 3 行');
$this->loginAs()->get(route('business.pages.show', [
'company_id' => $companyId,
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '晨丰',
]))
->assertOk()
->assertSeeText('退货件数')
->assertSeeText('退货金额')
->assertSee('10<small>件</small>', false)
->assertSeeText('¥950.00')
->assertDontSeeText('退货合计')
->assertDontSeeText('扣减合计');
}
public function test_confirm_all_only_applies_to_the_current_return_filter(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
foreach ([
'return:filtered' => 'FILTER-ONE',
'return:other' => 'FILTER-TWO',
] as $key => $barcode) {
$store->save('final:supplier:return-reconciliation', $key, [
'summary_month' => '2026-09',
'send_time' => '2026-09-20',
'supplier' => '晨丰',
'barcode' => $barcode,
'qty' => '1',
'unit_price' => '100',
'amount' => '100',
], $companyId, null);
}
$this->seedStatement('晨丰', [$this->statementRow('ZSJ-1', '128.00', '1', '128.00')]);
$this->loginAs()
->post(route('business.supplier-reconciliation.return.confirm-all'), [
'supplier' => '晨丰',
'return_keyword' => 'FILTER-ONE',
])
->assertStatus(302)
->assertSessionHas('success', '已全部确认:1 行退货扣减生效(异议行保持暂不扣减)。');
$statuses = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class)
->statuses('晨丰', $companyId);
$this->assertSame('confirmed', $statuses['return:filtered']);
$this->assertArrayNotHasKey('return:other', $statuses);
}
public function test_supplier_return_export_includes_all_filtered_rows_without_finance_columns(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
for ($i = 1; $i <= 20; $i++) {
$store->save('final:supplier:return-reconciliation', sprintf('export:%02d', $i), [
'summary_month' => '2026-09',
'send_time' => '2026-09-20 10:00:00',
'supplier' => '晨丰',
'barcode' => sprintf('EXPORT-%02d', $i),
'sku_name' => '导出商品',
'qty' => '2',
'unit_price' => '50',
'amount' => '100',
'supplier_barcode' => sprintf('SUPPLIER-%02d', $i),
'warehouse' => '客退临时上架仓',
'express_no' => sprintf('SF%018d', $i),
'creator' => '财务甲',
'reconciler' => '财务乙',
'labels' => '财务标记',
], $companyId, null);
}
$store->save('final:supplier:return-reconciliation', 'export:other', [
'summary_month' => '2026-10',
'send_time' => '2026-10-01',
'supplier' => '晨丰',
'barcode' => 'EXPORT-OTHER',
'qty' => '1',
'unit_price' => '50',
'amount' => '50',
'warehouse' => '其他仓',
], $companyId, null);
$response = $this->loginAs()->get(route('business.supplier-reconciliation.returns.export', [
'supplier' => '晨丰',
'return_keyword' => 'EXPORT-',
'return_date_from' => '2026-09-01',
'return_date_to' => '2026-09-30',
'return_warehouse' => '客退临时上架仓',
]));
$content = $response->streamedContent();
$response->assertOk()
->assertDownload()
->assertHeader('content-type', 'text/csv; charset=UTF-8');
$lines = preg_split('/\r\n|\r|\n/', trim(substr($content, 3)));
$this->assertCount(21, $lines, '导出应包含全部 20 条筛选结果及 1 行表头。');
$this->assertSame([
'发货时间', '明细供应商', '条形码', '商品名称', '发货量', '单价', '金额',
'供应商条码', '备注', '标题', 'wms配货单号', '仓库名称', '快递单号', '类型', '确认状态',
], str_getcsv($lines[0]));
$this->assertStringContainsString('EXPORT-01', $content);
$this->assertStringContainsString('EXPORT-20', $content);
$this->assertStringNotContainsString('EXPORT-OTHER', $content);
foreach (['月份', '预估单价', '预估金额', '标记多标签', '创建人', '对账人', '财务甲', '财务乙', '财务标记'] as $financeValue) {
$this->assertStringNotContainsString($financeValue, $content);
}
}
public function test_return_rows_can_be_confirmed_disputed_and_confirmed_all(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
foreach ([
'return:1' => ['barcode' => 'CJ019316N1097V01', 'qty' => '7', 'unit_price' => '196.50', 'amount' => '1375.50'],
'return:2' => ['barcode' => 'DNZ016720N1097V01', 'qty' => '7', 'unit_price' => '182.50', 'amount' => '1277.50'],
] as $key => $row) {
$store->save('final:supplier:return-reconciliation', $key, $row + [
'summary_month' => '2026-09', 'send_time' => '2026-09-13 09:10:24', 'supplier' => '晨丰',
'warehouse' => '客退临时上架仓', 'title' => '', 'remark' => '',
], $companyId, null);
}
$this->seedStatement('晨丰', [$this->statementRow('ZSJ-1', '128.00', '2', '256.00')]);
$page = fn () => $this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '晨丰',
]))->assertOk();
$page()->assertSeeText('退货金额')->assertSeeText('¥2,653.00');
$this->loginAs()
->post(route('business.supplier-reconciliation.return.confirm'), ['supplier' => '晨丰', 'key' => 'return:1'])
->assertStatus(302)
->assertSessionHas('success', '已确认该行退货扣减。');
$page()->assertSeeText('已确认')->assertSeeText('¥1,375.50');
$this->loginAs()
->post(route('business.supplier-reconciliation.return.dispute'), ['supplier' => '晨丰', 'key' => 'return:2'])
->assertStatus(302)
->assertSessionHas('success', '已提交异议,复核通过前该行暂不扣减。');
$this->loginAs()
->post(route('business.supplier-reconciliation.return.confirm-all'), ['supplier' => '晨丰'])
->assertStatus(302)
->assertSessionHas('success', '已全部确认:1 行退货扣减生效(异议行保持暂不扣减)。');
$statuses = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class)
->statuses('晨丰', $companyId);
$this->assertSame('confirmed', $statuses['return:1']);
$this->assertSame('disputed', $statuses['return:2'], '一键确认不应覆盖已提交的异议');
}
public function test_statement_template_downloads_the_bundled_file(): void
{
$this->loginAs()
->get(route('business.supplier-reconciliation.template'))
->assertOk()
->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
}
public function test_upload_accepts_a_strictly_formatted_statement(): void
{
$file = UploadedFile::fake()->createWithContent(
'statement.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,编号-1,2026-09-30,晨丰,ZSJ-1,规格甲,2,128.00,256.00\n",
);
$this->loginAs()
->post(route('business.supplier-reconciliation.statement.upload'), [
'supplier' => '晨丰',
'import' => $file,
])
->assertStatus(302)
->assertSessionHas('success', '对账单已保存:晨丰 共 1 行。');
$rows = app(SupplierStatementStore::class)->rows('晨丰', (int) $this->company->id);
$this->assertCount(1, $rows);
$this->assertSame('ZSJ-1', $rows[0]['barcode']);
$this->assertCount(1, app(SupplierStatementStore::class)->uploads('晨丰', (int) $this->company->id));
}
public function test_supplier_statement_upload_allows_retry_within_one_minute(): void
{
$supplierUser = $this->createSupplierPortalUser('晨丰');
$content = "\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,RETRY-UPLOAD,2026-09-30,晨丰,ZSJ-RETRY,规格甲,2,128.00,256.00\n";
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'supplier' => '晨丰',
'import' => UploadedFile::fake()->createWithContent('retry-first.csv', $content),
])
->assertStatus(302);
$this->actingAsSupplierPortal($supplierUser)
->post(route('business.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'supplier' => '晨丰',
'import' => UploadedFile::fake()->createWithContent('retry-second.csv', $content),
])
->assertStatus(302);
}
public function test_vendor_portal_statement_upload_allows_retry_within_one_minute(): void
{
$vendor = $this->createVendorPortalUser('晨丰');
$content = "\xEF\xBB\xBF" . self::HEADER . "\n"
. ",,PORTAL-RETRY,2026-09-30,晨丰,ZSJ-PORTAL-RETRY,规格甲,1,100.00,100.00\n";
$this->loginAs($vendor)
->post(route('portal.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'import' => UploadedFile::fake()->createWithContent('portal-first.csv', $content),
])
->assertStatus(302);
$this->loginAs($vendor)
->post(route('portal.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [
'import' => UploadedFile::fake()->createWithContent('portal-second.csv', $content),
])
->assertStatus(302);
}
public function test_upload_rejects_statements_that_do_not_match_the_template(): void
{
$file = UploadedFile::fake()->createWithContent(
'statement.csv',
"\xEF\xBB\xBF快递名称,快递单号,单据编号,创建时间,供应商,条码,规格名称,数量,单价,金额\n"
. ",,编号-1,2026-09-30,晨丰,ZSJ-1,规格甲,2,128.00,256.00\n",
);
/** @var ValidationException $exception */
$exception = null;
try {
app(SupplierStatementTemplate::class)->parse($file, '晨丰');
} catch (ValidationException $caught) {
$exception = $caught;
}
$this->assertNotNull($exception, '表头不一致必须整份失败。');
$this->assertStringContainsString('表头与模板不一致', $exception->errors()['import'][0]);
}
public function test_template_validation_requires_filled_numeric_and_supplier_columns(): void
{
$files = [
'missing-required.csv' => [",,,2026-09-30,晨丰,ZSJ-1,规格甲,,128.00,256.00", '「数量」不能为空'],
'not-numeric.csv' => [",,,2026-09-30,晨丰,ZSJ-1,规格甲,两件,128.00,256.00", '「数量」必须是数字'],
'other-supplier.csv' => [",,,2026-09-30,鸣飞家具,ZSJ-1,规格甲,2,128.00,256.00", '不能提交其他厂家的数据'],
];
foreach ($files as $name => [$row, $expectedMessage]) {
$file = UploadedFile::fake()->createWithContent(
$name,
"\xEF\xBB\xBF" . self::HEADER . "\n" . $row . "\n",
);
try {
app(SupplierStatementTemplate::class)->parse($file, '晨丰');
$this->fail("{$name} 应当校验失败。");
} catch (ValidationException $exception) {
$this->assertStringContainsString($expectedMessage, $exception->errors()['import'][0]);
}
}
}
public function test_matcher_applies_the_agreed_settlement_rules(): void
{
$mine = [
['purchaseNo' => 'PO-A-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
['purchaseNo' => 'PO-B-1', 'date' => '2026-09-30', 'skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'],
['purchaseNo' => 'PO-C-1', 'date' => '2026-09-30', 'skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'],
];
$theirs = [
['purchaseNo' => 'PO-A-1', 'createdAt' => '2026-09-30', 'barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
['purchaseNo' => 'PO-B-1', 'createdAt' => '2026-09-30', 'barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
['purchaseNo' => 'PO-D-1', 'createdAt' => '2026-09-30', 'barcode' => 'D-1', 'unitPrice' => '80', 'qty' => '1', 'amount' => '80'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame(1, $result['summary']['mismatch']);
$this->assertSame(1, $result['summary']['supplierMissing']);
$this->assertSame(1, $result['summary']['supplierExtra']);
$this->assertSame(330.0, $result['summary']['mineAmount']);
$this->assertSame(330.0, $result['summary']['theirsAmount']);
$this->assertSame(80.0, $result['summary']['supplierOverAmount']);
$this->assertSame(250.0, $result['summary']['settleAmount']);
$this->assertSame(80.0, $result['summary']['differenceAmount']);
$byBarcode = collect($result['rows'])->keyBy('barcode');
$this->assertSame(50.0, $byBarcode['B-1']['settleAmount']);
$this->assertSame(-1.0, $byBarcode['B-1']['qtyDifference']);
$this->assertSame(-50.0, $byBarcode['B-1']['amountDifference']);
$this->assertSame('PO-B-1', $byBarcode['B-1']['purchaseNo']);
$this->assertSame('2026-09-30', $byBarcode['B-1']['date']);
$this->assertSame(0.0, $byBarcode['C-1']['settleAmount']);
$this->assertSame(0.0, $byBarcode['D-1']['settleAmount']);
}
public function test_matcher_normalizes_supplier_barcode_suffix(): void
{
// 真实场景:我方商品编码与厂家条码的厂家编号/版本后缀不同,去掉后缀后应一致。
$mine = [
['purchaseNo' => 'PO-CJ-1', 'date' => '2026-09-30', 'skuCode' => 'CJ019312', 'productCode' => 'CJ019312N1178V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['purchaseNo' => 'PO-DNZ-1', 'date' => '2026-09-30', 'skuCode' => 'DNZ037602', 'productCode' => 'DNZ037602N1178V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
];
$theirs = [
['purchaseNo' => 'PO-CJ-1', 'createdAt' => '2026-09-30', 'barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['purchaseNo' => 'PO-DNZ-1', 'createdAt' => '2026-09-30', 'barcode' => 'DNZ037602N1097V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$this->assertSame(2, $result['summary']['match'], '去掉厂家后缀后应全部一致');
$this->assertSame(0, $result['summary']['supplierExtra']);
$this->assertSame(0, $result['summary']['supplierMissing']);
$this->assertSame(0, $result['summary']['mismatch']);
$this->assertSame(24751.0, $result['summary']['settleAmount']);
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode'] ?? null);
}
public function test_matcher_uses_product_code_instead_of_sku_code(): void
{
$mine = [[
'purchaseNo' => 'PO-CODE-1',
'date' => '2026-09-30',
'skuCode' => 'SKU-ONLY',
'productCode' => 'CJ019312N1178V01',
'unitPrice' => '182.5',
'qty' => '2',
'amount' => '365',
]];
$theirs = [[
'purchaseNo' => 'PO-CODE-1',
'createdAt' => '2026-09-30',
'barcode' => 'CJ019312N1097V01',
'unitPrice' => '182.5',
'qty' => '2',
'amount' => '365',
]];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame('CJ019312N1178V01', $result['rows'][0]['barcode']);
$this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode']);
}
public function test_matcher_uses_purchase_number_date_and_barcode_as_the_unique_key(): void
{
$mine = [
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
];
$theirs = [
['purchaseNo' => 'PO-1', 'createdAt' => '2026/9/30 09:30:00', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
['purchaseNo' => 'PO-3', 'createdAt' => '2026-10-01', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$rows = collect($result['rows'])->keyBy('purchaseNo');
$this->assertSame(SupplierStatementMatcher::TYPE_MISMATCH, $rows['PO-1']['type']);
$this->assertSame('2026-09-30', $rows['PO-1']['date']);
$this->assertSame(1.0, $rows['PO-1']['qtyDifference']);
$this->assertSame(100.0, $rows['PO-1']['amountDifference']);
$this->assertStringContainsString('厂家数量多 1', $rows['PO-1']['note']);
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_MISSING, $rows['PO-2']['type']);
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_EXTRA, $rows['PO-3']['type']);
$this->assertSame(3.0, $result['summary']['supplierOverQty']);
$this->assertSame(300.0, $result['summary']['supplierOverAmount']);
}
public function test_matcher_matches_missing_purchase_number_by_barcode_and_quantity(): void
{
$mine = [
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$theirs = [
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame('PO-1', $result['rows'][0]['purchaseNo']);
$this->assertSame(3, $result['rows'][0]['matchedByLayer']);
$this->assertStringContainsString('商品编码+数量匹配', $result['rows'][0]['note']);
}
public function test_matcher_does_not_guess_when_multiple_purchase_numbers_cannot_match_by_barcode_quantity(): void
{
$mine = [
['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'],
['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
];
$theirs = [
['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'],
];
$result = app(SupplierStatementMatcher::class)->compare($mine, $theirs);
$this->assertSame(0, $result['summary']['match']);
$this->assertSame(1, $result['summary']['supplierExtra']);
$this->assertSame(2, $result['summary']['supplierMissing']);
$this->assertSame(0, $result['summary']['mismatch']);
}
public function test_header_mismatch_message_lists_the_differences(): void
{
$file = UploadedFile::fake()->createWithContent(
'statement.csv',
"\xEF\xBB\xBF单据编号(采购单号),创建时间,供应商,条码,数量,单价,金额\n"
. "编号-1,2026-09-30,晨丰,ZSJ-1,2,128.00,256.00\n",
);
try {
app(SupplierStatementTemplate::class)->parse($file, '晨丰');
$this->fail('表头不一致应当整份失败。');
} catch (ValidationException $exception) {
$message = $exception->errors()['import'][0];
$this->assertStringContainsString('缺少「快递名称」「快递单号」「规格名称」', $message);
}
}
public function test_template_completes_missing_unit_price_or_amount(): void
{
$file = UploadedFile::fake()->createWithContent(
'statement.csv',
"\xEF\xBB\xBF" . self::HEADER . "\n"
. ',,编号-1,2026-09-30,晨丰,ZSJ-1,规格甲,24,,2916' . "\n" // 单价空 → 2916/24
. ',,编号-2,2026-09-30,晨丰,ZSJ-2,规格甲,3,10,' . "\n", // 金额空 → 3×10
);
$records = app(SupplierStatementTemplate::class)->parse($file, '晨丰');
$this->assertCount(2, $records);
$this->assertSame('121.5', $records[0]['unitPrice']);
$this->assertSame('2916', $records[0]['amount']);
$this->assertSame('10', $records[1]['unitPrice']);
$this->assertSame('30', $records[1]['amount']);
}
private function createVendorPortalUser(string $supplier): User
{
$email = Str::lower(Str::random(12)) . '@vendor.test';
$contact = $this->dispatch(new CreateContact([
'type' => Contact::VENDOR_TYPE,
'name' => $supplier,
'email' => $email,
'currency_code' => default_currency(),
'password' => 'portal-password',
'password_confirmation' => 'portal-password',
'company_id' => (int) $this->company->id,
'enabled' => '1',
'create_user' => 'true',
'created_from' => 'core::test',
]));
$this->assertInstanceOf(User::class, $contact->user);
return $contact->user;
}
private function createSupplierPortalUser(string $supplier): User
{
$supplierUser = User::create([
'name' => '供应商测试人员',
'email' => Str::lower(Str::random(12)) . '@supplier.test',
'password' => Str::random(32),
'locale' => 'zh-CN',
'enabled' => true,
'created_from' => SupplierPortalAccess::CREATED_FROM,
'landing_page' => 'business.supplier-portal.home',
'created_by' => 0,
]);
$supplierUser->roles()->attach(Role::where('name', 'user')->firstOrFail()->id);
$supplierUser->permissions()->attach(
Permission::where('name', 'read-admin-panel')->firstOrFail()->id
);
$supplierUser->companies()->attach($this->company->id);
DingTalkIdentity::create([
'user_id' => $supplierUser->id,
'union_id' => 'union-supplier-fixture',
'dingtalk_user_id' => 'ding-supplier-fixture',
]);
SupplierPortalIdentity::create([
'company_id' => $this->company->id,
'user_id' => $supplierUser->id,
'supplier_code' => 'SUPPLIER-TEST',
'supplier_name' => $supplier,
'dingtalk_group_id' => 1,
'dingtalk_group_name' => $supplier,
'last_verified_at' => now(),
]);
$this->bindSupplierPortalSupplier($supplierUser, $supplier, 'SUPPLIER-TEST', 1);
return $supplierUser;
}
private function bindSupplierPortalSupplier(
User $user,
string $supplier,
string $supplierCode,
int $dingtalkGroupId,
): SupplierPortalSupplier {
return SupplierPortalSupplier::create([
'company_id' => $this->company->id,
'user_id' => $user->id,
'supplier_code' => $supplierCode,
'supplier_name' => $supplier,
'dingtalk_group_id' => $dingtalkGroupId,
'dingtalk_group_name' => $supplier,
'last_verified_at' => now(),
]);
}
private function actingAsSupplierPortal(User $user): self
{
$identity = DingTalkIdentity::query()->where('user_id', $user->id)->firstOrFail();
return $this->loginAs($user)->withSession([
'dingtalk_identity_id' => $identity->id,
'dingtalk_roster_checked_at' => now()->timestamp,
]);
}
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,
];
}
/** @param list<array<string,mixed>> $rows */
private function seedStatement(string $supplier, array $rows): void
{
app(SupplierStatementStore::class)->save($supplier, $rows, 'statement.csv', (int) $this->company->id, null);
}
}