新增供应商对账目录:财务端实时上传情况与行级差异(条码+单价匹配,四类口径),厂家端严格按模板上传(表头不一致/必填缺失/非数字/串厂一律失败);复用终版存储与流式读取。
199 lines
8.1 KiB
PHP
199 lines
8.1 KiB
PHP
<?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('下载对账单模板');
|
|
}
|
|
|
|
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']);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|