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('采购单号 + 日期 + 商品编码') ->assertSee('list="finance-supplier-options"', false) ->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', 'purchaseNo' => '编号-ZSJ-1', 'date' => '2026-09-30', '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', 'purchaseNo' => '编号-SJ-1', 'date' => '2026-09-30', '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', 'purchaseNo' => '编号-ZSJ-1N1097V01', 'date' => '2026-09-30', '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', 'purchaseNo' => '编号-ZSJ-2N1097V01', 'date' => '2026-09-30', 'supplier' => '晨丰', 'skuCode' => 'ZSJ-2', 'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三', ], $companyId, null); $this->seedStatement('晨丰', [ $this->statementRow('ZSJ-1N1097V01', '128.00', '3', '384.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('采购单号') ->assertSeeText('日期') ->assertSeeText('数量差异(厂家-我方)') ->assertSeeText('厂家多 1.00') ->assertSeeText('厂家多 ¥128.00') ->assertSeeText('编号-ZSJ-1N1097V01') ->assertSeeText('导出比对汇总') ->assertSee('sr-comparison-scroll', false) ->assertSeeText('每页 15 条') ->assertSeeText('ZSJ-3') ->assertSeeText('ZSJ-2') ->assertDontSeeText('各厂家上传情况'); } public function test_finance_supplier_filter_requires_an_exact_searchable_name(): void { $store = app(SupplierPayableOverrideStore::class); $companyId = (int) $this->company->id; foreach (['晨丰', '鸣飞家具'] as $supplier) { $store->save('final:supplier:payment-reconciliation', 'supplier:' . $supplier, [ 'summary_month' => '2026-09', 'purchaseNo' => 'PO-' . $supplier, 'date' => '2026-09-30', 'supplier' => $supplier, 'skuCode' => 'A-1', 'qty' => '1', 'unitPrice' => '10', 'amount' => '10', 'reconciler' => '张三', ], $companyId, null); } $pageUrl = route('business.pages.show', [ 'company_id' => $companyId, 'section' => 'supplier-reconciliation', 'page' => 'finance-data', ]); $this->loginAs()->get($pageUrl . '?supplier=' . urlencode('晨丰')) ->assertOk() ->assertSeeText('晨丰 · 比对明细') ->assertSee('value="晨丰"', false); $this->loginAs()->get($pageUrl . '?supplier=' . urlencode('不存在的厂家')) ->assertOk() ->assertSeeText('未找到名为「不存在的厂家」的供应商') ->assertDontSeeText('晨丰 · 比对明细') ->assertDontSeeText('鸣飞家具 · 比对明细'); } public function test_finance_comparison_rows_are_paginated_fifteen_per_page(): void { $companyId = (int) $this->company->id; $store = app(SupplierPayableOverrideStore::class); $statementRows = []; for ($i = 1; $i <= 20; $i++) { $purchaseNo = sprintf('PO-%02d', $i); $barcode = sprintf('A-%02d', $i); $store->save('final:supplier:payment-reconciliation', 'compare:' . $i, [ 'summary_month' => '2026-09', 'purchaseNo' => $purchaseNo, 'date' => '2026-09-30', 'supplier' => '晨丰', 'skuCode' => $barcode, 'qty' => '1', 'unitPrice' => '10', 'amount' => '10', 'reconciler' => '张三', ], $companyId, null); $statementRows[] = array_merge( $this->statementRow($barcode . 'N1097V01', '10.00', '1', '10.00'), ['purchaseNo' => $purchaseNo], ); } $this->seedStatement('晨丰', $statementRows); $pageUrl = fn (int $page): string => route('business.pages.show', [ 'company_id' => $companyId, 'section' => 'supplier-reconciliation', 'page' => 'finance-data', 'supplier' => '晨丰', 'compare_page' => $page, ]); $this->loginAs()->get($pageUrl(1)) ->assertOk() ->assertSeeText('第 1 / 2 页') ->assertSeeText('每页 15 条') ->assertSeeText('A-01') ->assertSeeText('A-15') ->assertDontSeeText('A-16') ->assertDontSeeText('A-20'); $this->loginAs()->get($pageUrl(2)) ->assertOk() ->assertSeeText('第 2 / 2 页') ->assertSeeText('A-16') ->assertSeeText('A-20') ->assertDontSeeText('A-15') ->assertDontSeeText('A-01'); } public function test_finance_comparison_export_includes_summary_and_all_filtered_rows(): void { $companyId = (int) $this->company->id; $store = app(SupplierPayableOverrideStore::class); foreach ([ 'mine:1' => ['purchaseNo' => 'PO-1', 'skuCode' => 'A-1', 'qty' => '2', 'unitPrice' => '100', 'amount' => '200', 'reconciler' => '张三'], 'mine:2' => ['purchaseNo' => 'PO-2', 'skuCode' => 'B-1', 'qty' => '1', 'unitPrice' => '50', 'amount' => '50', 'reconciler' => '张三'], 'mine:other' => ['purchaseNo' => 'PO-OTHER', 'skuCode' => 'C-1', 'qty' => '1', 'unitPrice' => '30', 'amount' => '30', 'reconciler' => '李四'], ] as $key => $row) { $store->save('final:supplier:payment-reconciliation', $key, $row + [ 'summary_month' => $key === 'mine:other' ? '2026-10' : '2026-09', 'date' => '2026-09-30', 'supplier' => '晨丰', ], $companyId, null); } $this->seedStatement('晨丰', [ array_merge($this->statementRow('A-1N1097V01', '100.00', '2', '200.00'), ['purchaseNo' => 'PO-1']), array_merge($this->statementRow('B-1N1097V01', '50.00', '2', '100.00'), ['purchaseNo' => 'PO-2']), array_merge($this->statementRow('D-1N1097V01', '80.00', '1', '80.00'), ['purchaseNo' => 'PO-D']), ]); $response = $this->loginAs()->get(route('business.supplier-reconciliation.comparison.export', [ 'supplier' => '晨丰', 'month' => '2026-09', 'reconciler' => '张三', ])); $content = $response->streamedContent(); $response->assertOk() ->assertDownload() ->assertHeader('content-type', 'text/csv; charset=UTF-8'); foreach ([ '供应商,晨丰', '月份,2026-09', '对账人,张三', '双方一致,1', '厂家多,1', '数量/金额不一致,1', '厂家少,0', '采购单号,日期,我方商品编码,厂家条码,比对结果', 'PO-1', 'PO-2', 'PO-D', '厂家多 1', '厂家多 ¥50.00', ] as $expected) { $this->assertStringContainsString($expected, $content); } $this->assertStringNotContainsString('PO-OTHER', $content); } 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, ); $response = $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'supplier-reconciliation', 'page' => 'supplier-data', 'supplier' => '晨丰', ])) ->assertOk() ->assertSeeText('厂家对账单 · 上传与实时数据') ->assertSeeText('上传记录') ->assertSeeText('退货扣减确认') ->assertSeeText('退货金额') ->assertSeeText('全部确认无异议') ->assertSeeText('待确认') ->assertSeeText('条形码') ->assertSeeText('商品名称') ->assertSeeText('发货时间') ->assertSeeText('ZSJ-1') ->assertSeeText('查询') ->assertSeeText('导出当前筛选结果'); $response->assertSee('sr-return-scroll', false); $html = (string) $response->getContent(); $uploadHistoryPosition = strpos($html, ' · 上传记录'); $returnPanelPosition = strpos($html, '退货扣减确认'); $this->assertNotFalse($uploadHistoryPosition, '内部页面应保留上传记录。'); $this->assertNotFalse($returnPanelPosition, '内部页面应正常渲染退货面板。'); $this->assertGreaterThan($returnPanelPosition, $uploadHistoryPosition, '上传记录应移动到页面最下面。'); $tableStart = strpos($html, ''); $tableEnd = $tableStart === false ? false : strpos($html, '
', $tableStart); $this->assertNotFalse($tableStart, '厂家退货表应正常渲染。'); $this->assertNotFalse($tableEnd, '厂家退货表应正常结束。'); $returnTable = substr($html, $tableStart, $tableEnd - $tableStart); $this->assertSame(15, substr_count($returnTable, '')); foreach (['月份', '预估单价', '预估金额', '标记多标签', '创建人', '对账人'] as $hiddenColumn) { $this->assertStringNotContainsString('' . $hiddenColumn . '', $returnTable); } } public function test_supplier_page_uses_a_searchable_exact_supplier_picker(): void { $this->seedStatement('晨丰', [ $this->statementRow('ZSJ-1', '128.00', '2', '256.00'), ]); $this->seedStatement('鸣飞家具', [ array_merge($this->statementRow('SJ-1', '96.00', '1', '96.00'), ['supplier' => '鸣飞家具']), ]); $pageUrl = route('business.pages.show', [ 'company_id' => $this->company->id, 'section' => 'supplier-reconciliation', 'page' => 'supplier-data', ]); $this->loginAs()->get($pageUrl) ->assertOk() ->assertSee('name="supplier"', false) ->assertSee('list="supplier-name-options"', false) ->assertSeeText('查询厂家') ->assertSee('value="晨丰"', false) ->assertSee('value="鸣飞家具"', false); $this->loginAs()->get($pageUrl . '?supplier=' . urlencode('鸣飞家具')) ->assertOk() ->assertSeeText('鸣飞家具 · 上传记录') ->assertDontSeeText('晨丰 · 上传记录'); $this->loginAs()->get($pageUrl . '?supplier=' . urlencode('不存在的厂家')) ->assertOk() ->assertSeeText('未找到名为「不存在的厂家」的厂家') ->assertDontSeeText('晨丰 · 上传记录') ->assertDontSeeText('鸣飞家具 · 上传记录'); } public function test_supplier_portal_is_locked_to_its_own_page_and_blocks_core_routes(): void { $this->seedStatement('晨丰', [ $this->statementRow('ZSJ-1', '128.00', '2', '256.00'), ]); $supplierUser = $this->createSupplierPortalUser('晨丰'); $pageUrl = route('business.pages.show', [ 'company_id' => $this->company->id, 'section' => 'supplier-reconciliation', 'page' => 'supplier-data', 'supplier' => '鸣飞家具', ]); $this->actingAsSupplierPortal($supplierUser)->get($pageUrl) ->assertOk() ->assertSeeText('当前厂家(由钉钉组织自动确定)') ->assertSeeText('晨丰') ->assertDontSee('list="supplier-name-options"', false) ->assertDontSeeText('权限管理') ->assertDontSeeText('数据库管理') ->assertDontSeeText('鸣飞家具 · 上传记录'); $this->withExceptionHandling(); $this->actingAsSupplierPortal($supplierUser) ->get(route('business.pages.show', [ 'company_id' => $this->company->id, 'section' => 'supplier-reconciliation', 'page' => 'finance-data', ])) ->assertForbidden(); $this->actingAsSupplierPortal($supplierUser) ->get(route('business.supplier-reconciliation.comparison.export', ['company_id' => $this->company->id])) ->assertForbidden(); $this->actingAsSupplierPortal($supplierUser) ->get(route('dashboard', ['company_id' => $this->company->id])) ->assertRedirect(route('business.supplier-portal.home', [ 'company_id' => $this->company->id, ])); $this->actingAsSupplierPortal($supplierUser) ->get(route('business.supplier-portal.home', ['company_id' => $this->company->id])) ->assertRedirect(route('business.pages.show', [ 'company_id' => $this->company->id, 'section' => 'supplier-reconciliation', 'page' => 'supplier-data', ])); } public function test_supplier_portal_upload_and_return_actions_ignore_spoofed_supplier(): void { $supplierUser = $this->createSupplierPortalUser('晨丰'); $file = UploadedFile::fake()->createWithContent( 'statement.csv', "\xEF\xBB\xBF" . self::HEADER . "\n" . ",,PORTAL-UPLOAD,2026-09-30,晨丰,ZSJ-1,规格甲,2,128.00,256.00\n", ); $this->actingAsSupplierPortal($supplierUser) ->post(route('business.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [ 'supplier' => '鸣飞家具', 'import' => $file, ]) ->assertStatus(302) ->assertSessionHas('success', '对账单已保存:晨丰 共 1 行。'); $store = app(SupplierPayableOverrideStore::class); $companyId = (int) $this->company->id; $store->save('final:supplier:return-reconciliation', 'portal:own-confirm', [ 'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰', 'barcode' => 'OWN-CONFIRM', 'qty' => '1', 'unit_price' => '100', 'amount' => '100', ], $companyId, null); $store->save('final:supplier:return-reconciliation', 'portal:own-dispute', [ 'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰', 'barcode' => 'OWN-DISPUTE', 'qty' => '1', 'unit_price' => '100', 'amount' => '100', ], $companyId, null); $store->save('final:supplier:return-reconciliation', 'portal:foreign', [ 'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '鸣飞家具', 'barcode' => 'FOREIGN-RETURN', 'qty' => '1', 'unit_price' => '100', 'amount' => '100', ], $companyId, null); $this->actingAsSupplierPortal($supplierUser) ->post(route('business.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [ 'supplier' => '鸣飞家具', 'key' => 'portal:own-confirm', ]) ->assertStatus(302) ->assertSessionHas('success', '已确认该行退货扣减。'); $this->actingAsSupplierPortal($supplierUser) ->post(route('business.supplier-reconciliation.return.dispute', ['company_id' => $companyId]), [ 'supplier' => '鸣飞家具', 'key' => 'portal:own-dispute', ]) ->assertStatus(302) ->assertSessionHas('success', '已提交异议,复核通过前该行暂不扣减。'); $this->withExceptionHandling(); $this->actingAsSupplierPortal($supplierUser) ->post(route('business.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [ 'key' => 'portal:foreign', ]) ->assertForbidden(); $this->assertCount(1, app(SupplierStatementStore::class)->rows('晨丰', $companyId)); $this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', $companyId)); } public function test_vendor_portal_shows_only_all_returns_for_the_logged_in_supplier(): void { $store = app(SupplierPayableOverrideStore::class); $companyId = (int) $this->company->id; $this->seedStatement('鸣飞家具', [ array_merge($this->statementRow('OTHER-SUPPLIER', '100.00', '1', '100.00'), ['supplier' => '鸣飞家具']), ]); for ($i = 1; $i <= 20; $i++) { $store->save('final:supplier:return-reconciliation', sprintf('portal:%02d', $i), [ 'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰', 'barcode' => sprintf('PORTAL-%02d', $i), 'sku_name' => '门户退货商品', 'qty' => '1', 'unit_price' => '100', 'amount' => '100', ], $companyId, null); } $store->save('final:supplier:return-reconciliation', 'portal:other', [ 'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '鸣飞家具', 'barcode' => 'OTHER-SUPPLIER-RETURN', 'qty' => '1', 'unit_price' => '100', 'amount' => '100', ], $companyId, null); $vendor = $this->createVendorPortalUser('晨丰'); $portalUrl = route('portal.supplier-reconciliation.index', ['company_id' => $companyId]); $this->post(route('login'), [ 'email' => $vendor->email, 'password' => 'portal-password', ]) ->assertRedirect($portalUrl); $this->loginAs($vendor)->get($portalUrl) ->assertOk() ->assertSeeText('供应商对账') ->assertSeeText('晨丰 · 厂家对账') ->assertSeeText('退货明细(全部退货数据)') ->assertSeeText('PORTAL-01') ->assertDontSeeText('PORTAL-20') ->assertDontSeeText('OTHER-SUPPLIER-RETURN') ->assertDontSeeText('鸣飞家具') ->assertDontSeeText('最近上传') ->assertDontSee('supplier-name-options', false) ->assertSee('sp-return-scroll', false) ->assertSee('id="portal-return-date-from"', false) ->assertSee('data-return-date-range="last-month"', false) ->assertDontSeeText('预估单价'); $this->loginAs($vendor)->get($portalUrl . '?return_page=2') ->assertOk() ->assertSeeText('PORTAL-20') ->assertDontSeeText('PORTAL-01') ->assertDontSeeText('OTHER-SUPPLIER-RETURN'); } public function test_non_vendor_portal_account_cannot_open_supplier_return_page(): void { $this->withExceptionHandling(); $customer = Contact::customer()->whereNotNull('user_id')->firstOrFail(); $this->loginAs($customer->user) ->get(route('portal.supplier-reconciliation.index', ['company_id' => $this->company->id])) ->assertForbidden(); } public function test_vendor_portal_export_and_confirmation_are_scoped_to_the_bound_supplier(): void { $this->withExceptionHandling(); $store = app(SupplierPayableOverrideStore::class); $companyId = (int) $this->company->id; $store->save('final:supplier:return-reconciliation', 'portal:own', [ 'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰', 'barcode' => 'OWN-RETURN', 'qty' => '1', 'unit_price' => '100', 'amount' => '100', ], $companyId, null); $store->save('final:supplier:return-reconciliation', 'portal:foreign', [ 'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '鸣飞家具', 'barcode' => 'FOREIGN-RETURN', 'qty' => '1', 'unit_price' => '100', 'amount' => '100', ], $companyId, null); $vendor = $this->createVendorPortalUser('晨丰'); $response = $this->loginAs($vendor)->get(route('portal.supplier-reconciliation.returns.export', [ 'company_id' => $companyId, 'supplier' => '鸣飞家具', ])); $content = $response->streamedContent(); $response->assertOk()->assertDownload(); $this->assertStringContainsString('OWN-RETURN', $content); $this->assertStringNotContainsString('FOREIGN-RETURN', $content); $this->assertStringNotContainsString('预估单价', $content); $this->loginAs($vendor) ->post(route('portal.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [ 'key' => 'portal:foreign', ]) ->assertForbidden(); $this->loginAs($vendor) ->post(route('portal.supplier-reconciliation.return.confirm', ['company_id' => $companyId]), [ 'key' => 'portal:own', ]) ->assertStatus(302) ->assertSessionHas('success', '已确认该行退货扣减。'); $statuses = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class) ->statuses('晨丰', $companyId); $this->assertSame('confirmed', $statuses['portal:own']); $this->assertArrayNotHasKey('portal:foreign', $statuses); } public function test_vendor_portal_upload_always_uses_the_bound_supplier(): void { $vendor = $this->createVendorPortalUser('晨丰'); $file = UploadedFile::fake()->createWithContent( 'statement.csv', "\xEF\xBB\xBF" . self::HEADER . "\n" . ",,PORTAL-UPLOAD,2026-09-30,晨丰,ZSJ-1,规格甲,2,128.00,256.00\n", ); $this->loginAs($vendor) ->post(route('portal.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [ 'supplier' => '鸣飞家具', 'import' => $file, ]) ->assertStatus(302) ->assertSessionHas('success', '对账单已保存:晨丰 共 1 行。'); $this->assertCount(1, app(SupplierStatementStore::class)->rows('晨丰', (int) $this->company->id)); $this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id)); } public function test_vendor_portal_rejects_a_statement_for_another_supplier(): void { $this->withExceptionHandling(); $vendor = $this->createVendorPortalUser('晨丰'); $file = UploadedFile::fake()->createWithContent( 'statement.csv', "\xEF\xBB\xBF" . self::HEADER . "\n" . ",,PORTAL-WRONG-SUPPLIER,2026-09-30,鸣飞家具,ZSJ-1,规格甲,2,128.00,256.00\n", ); $this->loginAs($vendor) ->post(route('portal.supplier-reconciliation.statement.upload', ['company_id' => $this->company->id]), [ 'import' => $file, ]) ->assertSessionHasErrors([ 'import' => '第 2 行:供应商必须填写「晨丰」,不能提交其他厂家的数据。', ]); $this->assertCount(0, app(SupplierStatementStore::class)->rows('晨丰', (int) $this->company->id)); $this->assertCount(0, app(SupplierStatementStore::class)->rows('鸣飞家具', (int) $this->company->id)); } public function test_return_bookkeeping_note_is_identical_on_finance_and_supplier_views(): void { $companyId = (int) $this->company->id; app(SupplierPayableOverrideStore::class)->save( 'final:supplier:payment-reconciliation', 'mine:logistics:1', [ 'summary_month' => '2026-09', 'purchaseNo' => 'PO-LOGISTICS-1', 'date' => '2026-09-27', 'supplier' => '曹县宇诺', 'reconciler' => '陈亲园', 'skuCode' => 'CZ008407', 'qty' => '1', 'unitPrice' => '259', 'amount' => '259', ], $companyId, null, ); app(SupplierPayableOverrideStore::class)->save( 'final:supplier:return-reconciliation', 'return:logistics:1', [ 'summary_month' => '2026-09', 'send_time' => '2026-09-27', 'supplier' => '曹县宇诺', 'barcode' => 'CZ008407N1178V01', 'sku_name' => '款式二 160/80/75 黑胡桃色', 'qty' => '1', 'unit_price' => '259', 'amount' => '259', 'remark' => '原数量2件,CZ008407N1178V01*1记账物流', 'title' => '常州松宇货运', 'express_no' => '202609270079', 'reconciler' => '陈亲园', ], $companyId, null, ); $finance = $this->loginAs()->get(route('business.pages.show', [ 'section' => 'supplier-reconciliation', 'page' => 'finance-data', 'supplier' => '曹县宇诺', 'month' => '2026-09', 'reconciler' => '陈亲园', ])); $finance->assertOk() ->assertSeeText('退货扣减 · 财务与厂家同步') ->assertSeeText('转物流记账') ->assertSeeText('常州松宇货运') ->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流') ->assertSeeText('259.00'); $supplier = $this->loginAs()->get(route('business.pages.show', [ 'section' => 'supplier-reconciliation', 'page' => 'supplier-data', 'supplier' => '曹县宇诺', ])); $supplier->assertOk() ->assertSeeText('转物流记账') ->assertSeeText('不扣本厂家,转常州松宇货运记账') ->assertSeeText('原数量2件,CZ008407N1178V01*1记账物流'); $vendor = $this->createVendorPortalUser('曹县宇诺'); $this->loginAs($vendor)->get(route('portal.supplier-reconciliation.index', [ 'company_id' => $companyId, ])) ->assertOk() ->assertSeeText('转物流记账') ->assertSeeText('不扣本厂家,转常州松宇货运记账') ->assertSeeText('原数量2件,CZ008407N1178V01*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('每页 10 行') ->assertSeeText('第 1 / 2 页') ->assertSeeText('RT-01') ->assertSeeText('RT-10') ->assertDontSeeText('RT-11') ->assertDontSeeText('RT-20'); $this->loginAs()->get($pageUrl(2)) ->assertOk() ->assertSeeText('第 2 / 2 页') ->assertSeeText('RT-11') ->assertSeeText('RT-20') ->assertDontSeeText('RT-10') ->assertDontSeeText('RT-01'); } public function test_supplier_return_rows_can_be_queried_and_pagination_keeps_filters(): void { $store = app(SupplierPayableOverrideStore::class); $companyId = (int) $this->company->id; for ($i = 1; $i <= 20; $i++) { $store->save('final:supplier:return-reconciliation', sprintf('match:%02d', $i), [ 'summary_month' => '2026-09', 'send_time' => $i <= 15 ? '2026-09-20 10:00:00' : '2026-09-21 10:00:00', 'supplier' => '晨丰', 'barcode' => sprintf('MATCH-%02d', $i), 'sku_name' => '查询商品', 'qty' => '1', 'unit_price' => '100', 'amount' => '100', 'warehouse' => '客退临时上架仓', 'express_no' => sprintf('SF%018d', $i), ], $companyId, null); } $store->save('final:supplier:return-reconciliation', 'other:01', [ 'summary_month' => '2026-09', 'send_time' => '2026-09-20 09:00:00', 'supplier' => '晨丰', 'barcode' => 'OTHER-01', 'sku_name' => '其他商品', 'qty' => '1', 'unit_price' => '100', 'amount' => '100', 'warehouse' => '其他仓', ], $companyId, null); app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class) ->mark('晨丰', 'match:20', 'confirmed', $companyId, null); $this->seedStatement('晨丰', [$this->statementRow('ZSJ-1', '128.00', '1', '128.00')]); $filters = [ 'section' => 'supplier-reconciliation', 'page' => 'supplier-data', 'supplier' => '晨丰', 'return_keyword' => 'MATCH', 'return_date_from' => '2026-09-20', 'return_date_to' => '2026-09-21', 'return_warehouse' => '客退临时上架仓', 'return_status' => 'pending', ]; $this->loginAs()->get(route('business.pages.show', $filters)) ->assertOk() ->assertSeeText('当前筛选 19 件,退货金额 ¥1,900.00') ->assertSee('value="2026-09-20"', false) ->assertSee('value="2026-09-21"', false) ->assertSee('data-return-date-range="last-month"', false) ->assertSeeText('MATCH-01') ->assertDontSeeText('MATCH-20') ->assertDontSeeText('OTHER-01') ->assertSeeText('导出当前筛选结果'); $this->loginAs()->get(route('business.pages.show', $filters + ['return_page' => 2])) ->assertOk() ->assertSeeText('MATCH-19') ->assertDontSeeText('MATCH-01') ->assertSee('return_keyword=MATCH', false) ->assertSee('return_status=pending', false); } public function test_return_summary_uses_quantity_and_amount_without_row_counts(): void { $store = app(SupplierPayableOverrideStore::class); $companyId = (int) $this->company->id; $rows = [ 'summary:1' => ['qty' => '2', 'amount' => '200'], 'summary:2' => ['qty' => '3', 'amount' => '300'], 'summary:3' => ['qty' => '1', 'amount' => '50'], 'summary:4' => ['qty' => '4', 'amount' => '400'], ]; foreach ($rows as $key => $values) { $store->save('final:supplier:return-reconciliation', $key, $values + [ 'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰', 'barcode' => strtoupper($key), 'unit_price' => '100', ], $companyId, null); } $confirmations = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class); $confirmations->mark('晨丰', 'summary:1', 'confirmed', $companyId, null); $confirmations->mark('晨丰', 'summary:3', 'disputed', $companyId, null); $vendor = $this->createVendorPortalUser('晨丰'); $portal = $this->loginAs($vendor)->get(route('portal.supplier-reconciliation.index', [ 'company_id' => $companyId, ])); $portal->assertOk() ->assertSeeText('退货件数') ->assertSeeText('退货金额') ->assertSee('10件', false) ->assertSeeText('¥950.00') ->assertSee('2件', false) ->assertSeeText('¥200.00') ->assertSee('1件', false) ->assertSeeText('¥50.00') ->assertSee('7件', false) ->assertSeeText('¥700.00') ->assertDontSeeText('退货合计') ->assertDontSeeText('扣减合计') ->assertDontSeeText('已确认 2 行') ->assertDontSeeText('异议 1 行') ->assertDontSeeText('待确认 3 行'); $this->loginAs()->get(route('business.pages.show', [ 'company_id' => $companyId, 'section' => 'supplier-reconciliation', 'page' => 'supplier-data', 'supplier' => '晨丰', ])) ->assertOk() ->assertSeeText('退货件数') ->assertSeeText('退货金额') ->assertSee('10件', false) ->assertSeeText('¥950.00') ->assertDontSeeText('退货合计') ->assertDontSeeText('扣减合计'); } public function test_confirm_all_only_applies_to_the_current_return_filter(): void { $store = app(SupplierPayableOverrideStore::class); $companyId = (int) $this->company->id; foreach ([ 'return:filtered' => 'FILTER-ONE', 'return:other' => 'FILTER-TWO', ] as $key => $barcode) { $store->save('final:supplier:return-reconciliation', $key, [ 'summary_month' => '2026-09', 'send_time' => '2026-09-20', 'supplier' => '晨丰', 'barcode' => $barcode, 'qty' => '1', 'unit_price' => '100', 'amount' => '100', ], $companyId, null); } $this->seedStatement('晨丰', [$this->statementRow('ZSJ-1', '128.00', '1', '128.00')]); $this->loginAs() ->post(route('business.supplier-reconciliation.return.confirm-all'), [ 'supplier' => '晨丰', 'return_keyword' => 'FILTER-ONE', ]) ->assertStatus(302) ->assertSessionHas('success', '已全部确认:1 行退货扣减生效(异议行保持暂不扣减)。'); $statuses = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class) ->statuses('晨丰', $companyId); $this->assertSame('confirmed', $statuses['return:filtered']); $this->assertArrayNotHasKey('return:other', $statuses); } public function test_supplier_return_export_includes_all_filtered_rows_without_finance_columns(): void { $store = app(SupplierPayableOverrideStore::class); $companyId = (int) $this->company->id; for ($i = 1; $i <= 20; $i++) { $store->save('final:supplier:return-reconciliation', sprintf('export:%02d', $i), [ 'summary_month' => '2026-09', 'send_time' => '2026-09-20 10:00:00', 'supplier' => '晨丰', 'barcode' => sprintf('EXPORT-%02d', $i), 'sku_name' => '导出商品', 'qty' => '2', 'unit_price' => '50', 'amount' => '100', 'supplier_barcode' => sprintf('SUPPLIER-%02d', $i), 'warehouse' => '客退临时上架仓', 'express_no' => sprintf('SF%018d', $i), 'creator' => '财务甲', 'reconciler' => '财务乙', 'labels' => '财务标记', ], $companyId, null); } $store->save('final:supplier:return-reconciliation', 'export:other', [ 'summary_month' => '2026-10', 'send_time' => '2026-10-01', 'supplier' => '晨丰', 'barcode' => 'EXPORT-OTHER', 'qty' => '1', 'unit_price' => '50', 'amount' => '50', 'warehouse' => '其他仓', ], $companyId, null); $response = $this->loginAs()->get(route('business.supplier-reconciliation.returns.export', [ 'supplier' => '晨丰', 'return_keyword' => 'EXPORT-', 'return_date_from' => '2026-09-01', 'return_date_to' => '2026-09-30', 'return_warehouse' => '客退临时上架仓', ])); $content = $response->streamedContent(); $response->assertOk() ->assertDownload() ->assertHeader('content-type', 'text/csv; charset=UTF-8'); $lines = preg_split('/\r\n|\r|\n/', trim(substr($content, 3))); $this->assertCount(21, $lines, '导出应包含全部 20 条筛选结果及 1 行表头。'); $this->assertSame([ '发货时间', '明细供应商', '条形码', '商品名称', '发货量', '单价', '金额', '供应商条码', '备注', '标题', 'wms配货单号', '仓库名称', '快递单号', '类型', '确认状态', ], str_getcsv($lines[0])); $this->assertStringContainsString('EXPORT-01', $content); $this->assertStringContainsString('EXPORT-20', $content); $this->assertStringNotContainsString('EXPORT-OTHER', $content); foreach (['月份', '预估单价', '预估金额', '标记多标签', '创建人', '对账人', '财务甲', '财务乙', '财务标记'] as $financeValue) { $this->assertStringNotContainsString($financeValue, $content); } } 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('退货金额')->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 = [ ['purchaseNo' => 'PO-A-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], ['purchaseNo' => 'PO-B-1', 'date' => '2026-09-30', 'skuCode' => 'B-1', 'unitPrice' => '50', 'qty' => '2', 'amount' => '100'], ['purchaseNo' => 'PO-C-1', 'date' => '2026-09-30', 'skuCode' => 'C-1', 'unitPrice' => '30', 'qty' => '1', 'amount' => '30'], ]; $theirs = [ ['purchaseNo' => 'PO-A-1', 'createdAt' => '2026-09-30', 'barcode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], ['purchaseNo' => 'PO-B-1', 'createdAt' => '2026-09-30', 'barcode' => 'B-1', 'unitPrice' => '50', 'qty' => '1', 'amount' => '50'], ['purchaseNo' => 'PO-D-1', 'createdAt' => '2026-09-30', '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(330.0, $result['summary']['theirsAmount']); $this->assertSame(80.0, $result['summary']['supplierOverAmount']); $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(-1.0, $byBarcode['B-1']['qtyDifference']); $this->assertSame(-50.0, $byBarcode['B-1']['amountDifference']); $this->assertSame('PO-B-1', $byBarcode['B-1']['purchaseNo']); $this->assertSame('2026-09-30', $byBarcode['B-1']['date']); $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 { // 真实场景:我方商品编码与厂家条码的厂家编号/版本后缀不同,去掉后缀后应一致。 $mine = [ ['purchaseNo' => 'PO-CJ-1', 'date' => '2026-09-30', 'skuCode' => 'CJ019312', 'productCode' => 'CJ019312N1178V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'], ['purchaseNo' => 'PO-DNZ-1', 'date' => '2026-09-30', 'skuCode' => 'DNZ037602', 'productCode' => 'DNZ037602N1178V01', 'unitPrice' => '27', 'qty' => '38', 'amount' => '1026'], ]; $theirs = [ ['purchaseNo' => 'PO-CJ-1', 'createdAt' => '2026-09-30', 'barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '130', 'amount' => '23725'], ['purchaseNo' => 'PO-DNZ-1', 'createdAt' => '2026-09-30', '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_matcher_uses_product_code_instead_of_sku_code(): void { $mine = [[ 'purchaseNo' => 'PO-CODE-1', 'date' => '2026-09-30', 'skuCode' => 'SKU-ONLY', 'productCode' => 'CJ019312N1178V01', 'unitPrice' => '182.5', 'qty' => '2', 'amount' => '365', ]]; $theirs = [[ 'purchaseNo' => 'PO-CODE-1', 'createdAt' => '2026-09-30', 'barcode' => 'CJ019312N1097V01', 'unitPrice' => '182.5', 'qty' => '2', 'amount' => '365', ]]; $result = app(SupplierStatementMatcher::class)->compare($mine, $theirs); $this->assertSame(1, $result['summary']['match']); $this->assertSame('CJ019312N1178V01', $result['rows'][0]['barcode']); $this->assertSame('CJ019312N1097V01', $result['rows'][0]['supplierBarcode']); } public function test_matcher_uses_purchase_number_date_and_barcode_as_the_unique_key(): void { $mine = [ ['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], ['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'], ]; $theirs = [ ['purchaseNo' => 'PO-1', 'createdAt' => '2026/9/30 09:30:00', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'], ['purchaseNo' => 'PO-3', 'createdAt' => '2026-10-01', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], ]; $result = app(SupplierStatementMatcher::class)->compare($mine, $theirs); $rows = collect($result['rows'])->keyBy('purchaseNo'); $this->assertSame(SupplierStatementMatcher::TYPE_MISMATCH, $rows['PO-1']['type']); $this->assertSame('2026-09-30', $rows['PO-1']['date']); $this->assertSame(1.0, $rows['PO-1']['qtyDifference']); $this->assertSame(100.0, $rows['PO-1']['amountDifference']); $this->assertStringContainsString('厂家数量多 1', $rows['PO-1']['note']); $this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_MISSING, $rows['PO-2']['type']); $this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_EXTRA, $rows['PO-3']['type']); $this->assertSame(3.0, $result['summary']['supplierOverQty']); $this->assertSame(300.0, $result['summary']['supplierOverAmount']); } public function test_matcher_can_fallback_only_when_missing_purchase_number_matches_one_row(): void { $mine = [ ['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], ]; $theirs = [ ['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], ]; $result = app(SupplierStatementMatcher::class)->compare($mine, $theirs); $this->assertSame(1, $result['summary']['match']); $this->assertSame('PO-1', $result['rows'][0]['purchaseNo']); $this->assertStringContainsString('按日期+商品编码唯一匹配', $result['rows'][0]['note']); } public function test_matcher_does_not_guess_when_missing_purchase_number_is_ambiguous(): void { $mine = [ ['purchaseNo' => 'PO-1', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '1', 'amount' => '100'], ['purchaseNo' => 'PO-2', 'date' => '2026-09-30', 'skuCode' => 'A-1', 'unitPrice' => '100', 'qty' => '2', 'amount' => '200'], ]; $theirs = [ ['purchaseNo' => '', 'createdAt' => '2026-09-30', 'barcode' => 'A-1N1097V01', 'unitPrice' => '100', 'qty' => '3', 'amount' => '300'], ]; $result = app(SupplierStatementMatcher::class)->compare($mine, $theirs); $this->assertSame(0, $result['summary']['match']); $this->assertSame(1, $result['summary']['supplierExtra']); $this->assertSame(2, $result['summary']['supplierMissing']); $this->assertStringContainsString('无法唯一匹配', $result['rows'][0]['note']); } 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 createVendorPortalUser(string $supplier): User { $email = Str::lower(Str::random(12)) . '@vendor.test'; $contact = $this->dispatch(new CreateContact([ 'type' => Contact::VENDOR_TYPE, 'name' => $supplier, 'email' => $email, 'currency_code' => default_currency(), 'password' => 'portal-password', 'password_confirmation' => 'portal-password', 'company_id' => (int) $this->company->id, 'enabled' => '1', 'create_user' => 'true', 'created_from' => 'core::test', ])); $this->assertInstanceOf(User::class, $contact->user); return $contact->user; } private function createSupplierPortalUser(string $supplier): User { $supplierUser = User::create([ 'name' => '供应商测试人员', 'email' => Str::lower(Str::random(12)) . '@supplier.test', 'password' => Str::random(32), 'locale' => 'zh-CN', 'enabled' => true, 'created_from' => SupplierPortalAccess::CREATED_FROM, 'landing_page' => 'business.supplier-portal.home', 'created_by' => 0, ]); $supplierUser->roles()->attach(Role::where('name', 'user')->firstOrFail()->id); $supplierUser->permissions()->attach( Permission::where('name', 'read-admin-panel')->firstOrFail()->id ); $supplierUser->companies()->attach($this->company->id); DingTalkIdentity::create([ 'user_id' => $supplierUser->id, 'union_id' => 'union-supplier-fixture', 'dingtalk_user_id' => 'ding-supplier-fixture', ]); SupplierPortalIdentity::create([ 'company_id' => $this->company->id, 'user_id' => $supplierUser->id, 'supplier_code' => 'SUPPLIER-TEST', 'supplier_name' => $supplier, 'dingtalk_group_id' => 1, 'dingtalk_group_name' => $supplier, 'last_verified_at' => now(), ]); return $supplierUser; } private function actingAsSupplierPortal(User $user): self { $identity = DingTalkIdentity::query()->where('user_id', $user->id)->firstOrFail(); return $this->loginAs($user)->withSession([ 'dingtalk_identity_id' => $identity->id, 'dingtalk_roster_checked_at' => now()->timestamp, ]); } 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); } }