CaiWuXiTong/tests/Feature/Business/SupplierReconciliationTest.php
renshen 8162afa969 供应商对账:财务比对筛选与分页优化 (#406)
财务端增加供应商精确搜索、比对明细吸顶表头和每页15条分页。
2026-10-20 17:03:27 +08:00

1133 lines
50 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\Permission;
use App\Models\Auth\User;
use App\Models\Common\Contact;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
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('采购单号 + 日期 + 条码')
->assertSee('list="finance-supplier-options"', false)
->assertSeeText('对账人')
->assertSeeText('下载对账单模板');
}
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')
->assertSee('sr-comparison-scroll', false)
->assertSeeText('每页 15 条')
->assertSeeText('ZSJ-3')
->assertSeeText('ZSJ-2')
->assertDontSeeText('各厂家上传情况');
}
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_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('导出当前筛选结果');
$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(15, 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_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_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
{
// 真实场景:我方货号不带厂家后缀,厂家条码带「厂家编号 + 版本」(CJ019312 对 CJ019312N1097V01)
$mine = [
['purchaseNo' => 'PO-CJ-1', 'date' => '2026-09-30', 'skuCode' => 'CJ019312', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
['purchaseNo' => 'PO-DNZ-1', 'date' => '2026-09-30', 'skuCode' => 'DNZ037602', '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_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_can_fallback_only_when_missing_purchase_number_matches_one_row(): 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->assertStringContainsString('按日期+条码唯一匹配', $result['rows'][0]['note']);
}
public function test_matcher_does_not_guess_when_missing_purchase_number_is_ambiguous(): 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->assertStringContainsString('无法唯一匹配', $result['rows'][0]['note']);
}
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 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);
}
}