364 lines
16 KiB
PHP
364 lines
16 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('对账人')
|
||
->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);
|
||
}
|
||
}
|