964 lines
42 KiB
PHP
964 lines
42 KiB
PHP
<?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('对账人')
|
||
->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', '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', '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', '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', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-2',
|
||
'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三',
|
||
], $companyId, null);
|
||
$this->seedStatement('晨丰', [
|
||
$this->statementRow('ZSJ-1N1097V01', '128.00', '2', '256.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('ZSJ-3')
|
||
->assertSeeText('ZSJ-2')
|
||
->assertDontSeeText('各厂家上传情况');
|
||
}
|
||
|
||
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',
|
||
'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 = [
|
||
['skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], // 一致
|
||
['skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'], // 金额不一致(厂家低)
|
||
['skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'], // 厂家少
|
||
];
|
||
$theirs = [
|
||
['barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'],
|
||
['barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'],
|
||
['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(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(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 = [
|
||
['skuCode' => 'CJ019312', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||
['skuCode' => 'DNZ037602', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'],
|
||
];
|
||
$theirs = [
|
||
['barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'],
|
||
['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_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);
|
||
}
|
||
}
|