CaiWuXiTong/tests/Feature/Business/SupplierReconciliationTest.php
苏木 368c6c5a6c 供应商对账:单价/金额留空自动补齐,未定价行厂家也能直接上传
实测雪翔家居 9 月对账单:表头已改对(10 列与模板一致),但第 11 行是未定价行(数量 52、单价为空、金额 0),按「单价必填」会被拒绝。现在单价留空按「金额÷数量」补齐、金额留空按「数量×单价」补齐、两者都空按 0(数量仍必填),厂家直接导出即可上传。
2026-10-17 09:16:29 +08:00

283 lines
12 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_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('款式十一');
}
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);
}
}