CaiWuXiTong/tests/Feature/Business/SupplierReconciliationTest.php
renshen 2848c81a5e 供应商对账:退货明细改为「退货扣减确认」面板 (#369)
退货扣减确认:顶部汇总、逐行确认/异议、全部确认(不覆盖异议)、金额空按预估金额扣减,展示金额最高 15 行。
2026-10-17 09:40:54 +08:00

364 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
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,
);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '晨丰',
]))
->assertOk()
->assertSeeText('厂家对账单 · 上传与实时数据')
->assertSeeText('上传记录')
->assertSeeText('退货扣减确认')
->assertSeeText('合计扣减')
->assertSeeText('全部确认无异议')
->assertSeeText('待确认')
->assertSeeText('ZSJ-1');
}
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('合计扣减 ¥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 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);
}
}