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_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('每页 15 行') ->assertSeeText('第 1 / 2 页') ->assertSeeText('RT-01') ->assertDontSeeText('RT-20'); $this->loginAs()->get($pageUrl(2)) ->assertOk() ->assertSeeText('第 2 / 2 页') ->assertSeeText('RT-20') ->assertDontSeeText('RT-01'); } 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> $rows */ private function seedStatement(string $supplier, array $rows): void { app(SupplierStatementStore::class)->save($supplier, $rows, 'statement.csv', (int) $this->company->id, null); } }