create([ 'company_id' => 1, 'warehouse_provider' => '测试映射仓储方', 'warehouse' => '测试映射仓库', 'warehouse_short_name' => '测试映射简称', ]); $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); $repository->shouldReceive('paginateMonthlyInventoryReport')->once()->with( [ 'month' => '2026-08', 'product_code' => '121630-49', 'warehouse_provider' => '', 'category' => '', ], 1, Mockery::type('int'), )->andReturn([ 'data' => [(object) [ 'product_code' => '121630-49', 'warehouse_provider' => '测试仓储方', 'warehouse' => '测试主仓', ]], 'total' => 1, 'state' => ['status' => 'succeeded'], 'sync' => ['code' => 'healthy'], ]); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.pages.show', [ 'section' => 'product-inventory', 'page' => 'inventory-report', 'month' => '2026-08', 'product_code' => '121630-49', ])); $response->assertOk() ->assertSeeText('导出') ->assertSee('data-inventory-export-scope', false) ->assertSee('data-inventory-export-fields', false) ->assertSee('data-inventory-export', false) ->assertSee('进销存总表') ->assertSee('仓库名称映射表') ->assertSee('测试仓储方') ->assertSee('测试主仓') ->assertSee('data-inventory-report-panel="mapping"', false) ->assertSee('id="inventory-report-total-table"', false) ->assertSee('id="inventory-warehouse-mapping-table"', false) ->assertSee('data-directory-source-url=', false) ->assertSee('mapping=1&page=1', false) ->assertSee('data-directory-table-key="warehouse-mapping"', false) ->assertSee('data-directory-field-picker', false) ->assertSee('data-directory-export-all="visible"', false) ->assertSee('data-directory-export-all="all-fields"', false) ->assertSee('data-inventory-mapping-template', false) ->assertSee('data-directory-fixed="start"', false) ->assertSee('data-directory-fixed="end"', false); $html = $response->getContent(); $totalPanelStart = strpos($html, 'id="inventory-report-total-panel"'); $mappingPanelStart = strpos($html, 'id="inventory-report-mapping-panel"'); $noticePosition = strpos($html, '已从共享业务镜像读取'); $filterPosition = strpos($html, 'aria-label="进销存报表筛选查询栏"'); $this->assertIsInt($totalPanelStart); $this->assertIsInt($mappingPanelStart); $this->assertIsInt($noticePosition); $this->assertIsInt($filterPosition); $this->assertGreaterThan($totalPanelStart, $noticePosition); $this->assertLessThan($mappingPanelStart, $noticePosition); $this->assertGreaterThan($totalPanelStart, $filterPosition); $this->assertLessThan($mappingPanelStart, $filterPosition); } public function testInventoryReportShowsWarehouseShortNameOnlyForExactProviderAndWarehouse(): void { InventoryWarehouseNameMapping::query()->create([ 'company_id' => 1, 'warehouse_provider' => '测试仓储方', 'warehouse' => '测试主仓', 'warehouse_short_name' => '崔桥主仓', ]); $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); $repository->shouldReceive('paginateMonthlyInventoryReport')->once()->andReturn([ 'data' => [ (object) ['warehouse_provider' => '测试仓储方', 'warehouse' => '测试主仓', 'product_code' => 'MATCHED'], (object) ['warehouse_provider' => '其他仓储方', 'warehouse' => '测试主仓', 'product_code' => 'UNMATCHED'], ], 'total' => 2, 'state' => ['status' => 'succeeded'], 'sync' => ['code' => 'healthy'], ]); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.pages.show', [ 'section' => 'product-inventory', 'page' => 'inventory-report', 'month' => '2026-08', ])); $response->assertOk() ->assertSee('data-column-key="warehouse_short_name"', false) ->assertSee('data-inventory-short-name-sort', false) ->assertSee('value="warehouse_short_name"', false) ->assertSee('崔桥主仓'); $html = $response->getContent(); $this->assertLessThan( strpos($html, 'data-column-key="warehouse_short_name"'), strpos($html, 'data-column-key="warehouse"'), ); $this->assertMatchesRegularExpression('/data-column-key="warehouse_short_name"[^>]*>\s*崔桥主仓\s*<\/td>/', $html); $this->assertMatchesRegularExpression('/data-column-key="warehouse_short_name"[^>]*>\s*<\/td>/', $html); } public function testInventoryReportKeepsJulyMonthFilter(): void { $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); $repository->shouldReceive('paginateMonthlyInventoryReport')->once()->with( [ 'month' => '2026-07', 'product_code' => '', 'warehouse_provider' => '', 'category' => '', ], 1, Mockery::type('int'), )->andReturn([ 'data' => [], 'total' => 0, 'state' => ['status' => 'succeeded'], 'sync' => ['code' => 'healthy'], ]); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'product-inventory', 'page' => 'inventory-report', 'month' => '2026-07', ]))->assertOk() ->assertSee('min="2026-07"', false) ->assertSee('value="2026-07"', false) ->assertSee('data-inventory-month-picker', false) ->assertSee('data-inventory-month-grid', false) ->assertSee('data-inventory-month-current', false); } public function testInventoryReportExportUsesCurrentFiltersAndPageColumnOrder(): void { $filters = [ 'month' => '2026-08', 'product_code' => '121630-49', 'warehouse_provider' => '测试仓', 'category' => '家具', ]; $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); $repository->shouldReceive('paginateMonthlyInventoryReport')->once()->with( $filters, 1, Mockery::type('int'), false, )->andReturn([ 'data' => [(object) [ 'owner' => '常州简尚家居有限公司', 'month' => '2026-08-01', 'warehouse_provider' => '测试仓', 'warehouse' => '测试仓', 'product_code' => '121630-49', 'category' => '家具', 'opening_quantity' => 10, 'closing_quantity' => 12, ]], ]); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $response = $this->loginAs()->get(route( 'business.product-inventory.inventory-report.export', $filters + ['scope' => 'page'], )); $response->assertOk()->assertDownload()->assertHeader('X-Accel-Buffering', 'no'); $content = $response->streamedContent(); $this->assertSame((string) strlen($content), $response->headers->get('Content-Length')); $this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content); $this->assertStringContainsString('货主,月份,仓储方,仓库,仓库简称,商品编码,分类,期初数量,期末数量', $content); $this->assertStringContainsString("'121630-49", $content); } public function testInventoryReportExportUsesSelectedVisibleColumnsInOrder(): void { $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); $repository->shouldReceive('paginateMonthlyInventoryReport')->once()->with( [ 'month' => '2026-08', 'product_code' => '121630-49', 'warehouse_provider' => '', 'category' => '', ], 1, Mockery::type('int'), false, )->andReturn([ 'data' => [(object) ['product_code' => '121630-49', 'warehouse' => '主仓', 'opening_quantity' => 10]], ]); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.product-inventory.inventory-report.export', [ 'month' => '2026-08', 'product_code' => '121630-49', 'columns' => 'product_code,warehouse,opening_quantity', 'scope' => 'page', ])); $response->assertOk()->assertDownload(); $this->assertSame("\xEF\xBB\xBF商品编码,仓库,期初数量\n'121630-49,主仓,10\n", $response->streamedContent()); } public function testInventoryReportExportIncludesMappedShortNameAndLeavesUnmatchedBlank(): void { InventoryWarehouseNameMapping::query()->create([ 'company_id' => 1, 'warehouse_provider' => '测试仓储方', 'warehouse' => '主仓', 'warehouse_short_name' => '测试简称', ]); $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); $repository->shouldReceive('paginateMonthlyInventoryReport')->once()->andReturn([ 'data' => [ (object) ['warehouse_provider' => '测试仓储方', 'warehouse' => '主仓'], (object) ['warehouse_provider' => '其他仓储方', 'warehouse' => '主仓'], ], ]); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.product-inventory.inventory-report.export', [ 'month' => '2026-08', 'scope' => 'page', 'columns' => 'warehouse,warehouse_short_name,warehouse_provider', ])); $response->assertOk()->assertDownload(); $this->assertSame( "\xEF\xBB\xBF仓库,仓库简称,仓储方\n主仓,测试简称,测试仓储方\n主仓,,其他仓储方\n", $response->streamedContent(), ); } public function testInventoryReportCurrentPageExportDoesNotReadAllFilteredRows(): void { $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); $repository->shouldNotReceive('exportMonthlyInventoryReportRows'); $repository->shouldReceive('paginateMonthlyInventoryReport')->once()->with( ['month' => '2026-08', 'product_code' => '', 'warehouse_provider' => '', 'category' => ''], 2, Mockery::type('int'), false, )->andReturn(['data' => [(object) ['product_code' => 'PAGE-2']], 'total' => 0]); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('business.product-inventory.inventory-report.export', [ 'month' => '2026-08', 'page' => 2, 'scope' => 'page', 'columns' => 'product_code', ])); $response->assertOk()->assertDownload(); $this->assertStringContainsString("商品编码\n'PAGE-2\n", $response->streamedContent()); } public function testInventoryReportFullExportUsesTheQueueWhenEnabled(): void { Queue::fake(); config(['queue.default' => 'database']); $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); $repository->shouldNotReceive('exportMonthlyInventoryReportRows'); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $this->loginAs()->get(route('business.product-inventory.inventory-report.export', [ 'month' => '2026-08', 'scope' => 'all', ]))->assertRedirect(); Queue::assertPushed(InventoryReportExport::class); } public function testInventoryReportFullExportDefersAfterResponseWhenQueueIsDisabled(): void { Queue::fake(); config(['queue.default' => 'sync']); $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); $repository->shouldNotReceive('exportMonthlyInventoryReportRows'); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $this->loginAs()->get(route('business.product-inventory.inventory-report.export', [ 'month' => '2026-08', 'scope' => 'all', ]))->assertRedirect(); Queue::assertPushed(InventoryReportExport::class); } public function testInventoryReportBackgroundExportWritesWarehouseShortName(): void { Queue::fake(); InventoryWarehouseNameMapping::query()->create([ 'company_id' => 1, 'warehouse_provider' => '后台仓储方', 'warehouse' => '后台仓库', 'warehouse_short_name' => '后台简称', ]); $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('exportMonthlyInventoryReportRows')->once()->andReturn( (static function (): \Generator { yield (object) ['warehouse_provider' => '后台仓储方', 'warehouse' => '后台仓库']; })(), ); $fileName = 'inventory-short-name-test-' . uniqid() . '.csv'; $path = storage_path('app/temp/' . $fileName); try { (new InventoryReportExport( $this->user, ['month' => '2026-08'], [['name' => 'warehouse_short_name', 'label' => '仓库简称']], $fileName, 1, ))->handle($repository, app(InventoryWarehouseShortNames::class)); $this->assertSame("\xEF\xBB\xBF仓库简称\n后台简称\n", file_get_contents($path)); } finally { @unlink($path); } } public function testInventoryReportExportRejectsFieldsOutsideThePageSchema(): void { $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnTrue(); $repository->shouldNotReceive('exportMonthlyInventoryReportRows'); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $this->withExceptionHandling(); $this->loginAs()->get(route('business.product-inventory.inventory-report.export', [ 'columns' => 'product_code,password', ]))->assertStatus(422); } public function testInventoryReportDoesNotFallBackToLegacyRowsBeforeAllWarehouseMirrorIsReady(): void { $repository = Mockery::mock(ThirdPartyWarehouseStorageMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('allWarehousesReady')->once()->andReturnFalse(); $repository->shouldNotReceive('paginateMonthlyInventoryReport'); $this->app->instance(ThirdPartyWarehouseStorageMirrorRepository::class, $repository); $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'product-inventory', 'page' => 'inventory-report', ])) ->assertOk() ->assertSeeText('进销存全仓库共享镜像正在执行首次同步') ->assertDontSeeText('已从共享业务镜像读取'); } public function testFreightReconciliationPageRendersSharedMirrorRowsAndSummary(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $repository = Mockery::mock(CourierFreightReconciliationMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('paginate')->once()->with( Mockery::on(static fn (array $filters): bool => $filters['express_no'] === 'FIXTURE'), 1, Mockery::type('int'), )->andReturn([ 'data' => [[ 'detail_id' => 1, 'waybill' => 'FIXTURE-WAYBILL', 'product_id' => 'ITEM-1', 'send_time' => '2026-08-01 09:00:00', 'transaction_order_no' => 'SO-1', 'province' => '江苏省', 'city' => '常州市', 'district' => '武进区', 'barcode' => 'SKU-1', 'express_name' => '测试快递', 'express_name_2' => '测试快递', 'quantity' => 1, 'product_name' => '测试商品', 'platform' => '淘宝天猫', 'shop_name' => '测试店铺', 'warehouse' => '测试仓库', 'volume' => 1.2, 'supplier' => '测试供应商', 'length' => 1.0, 'width' => 2.0, 'height' => 3.0, 'freight' => 20.0, 'weight' => 1.25, 'reconciliation_weight' => 1.25, 'reconciliation_volume' => 1.2, 'month' => '2026-08', 'status' => '已发货', ]], 'total' => 1, 'summary' => ['count' => 1, 'freight' => 20.0, 'payable_amount' => 20.0, 'actual_payment' => 0.0, 'confirmed_amount' => 0.0, 'reconciliation_difference' => 0.0], 'state' => ['status' => 'succeeded'], 'sync' => ['code' => 'fresh', 'last_succeeded_at' => '2026-08-02 09:00:00', 'lag_minutes' => 1], ]); $this->app->instance(CourierFreightReconciliationMirrorRepository::class, $repository); $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'courier-payables', 'page' => 'freight-reconciliation', 'express_no' => 'FIXTURE', ])) ->assertOk() ->assertSeeText('FIXTURE-WAYBILL') ->assertSeeText('条形码') ->assertSeeText('交易订单号') ->assertSeeText('对账体积(cm3)') ->assertSeeText('SKU-1') ->assertSeeText('测试供应商') ->assertSeeText('运费合计') ->assertSeeText('20.00') ->assertSeeText('数据新鲜') ->assertSeeText('共享业务镜像') ->assertDontSee('class="business-blank-page"', false); } public function testTaobaoAlipayBillsEndpointIsAvailableToWorkspaceUsers() { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $repository = Mockery::mock(StoreReceivingAlipayBillMirrorRepository::class); $repository->shouldReceive('paginate')->once()->andReturn([ 'current_page' => 1, 'last_page' => 1, 'per_page' => 10, 'total' => 1, 'data' => [[ 'record_code' => 'time-format-test', 'occurred_at' => '2026-07-15 23:59:12', ]], 'summary' => ['income' => 1.0, 'expense' => 0.0, 'count' => 1, 'balance' => 1.0], 'sync' => ['status' => 'succeeded', 'last_succeeded_at' => '2026-09-07 09:00:00'], ]); $this->app->instance(StoreReceivingAlipayBillMirrorRepository::class, $repository); $this->loginAs() ->withHeader('X-Requested-With', 'XMLHttpRequest') ->get(route('ecommerce-finance.alipay-bills.index')) ->assertOk() ->assertJsonStructure([ 'current_page', 'data', 'total', 'summary' => ['income', 'expense', 'count', 'balance'], ]) ->assertJsonPath('data.0.occurred_at', '2026-07-15 23:59:12'); } public function testTaobaoAlipayFilterOptionsUseTheSameMirrorScope(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); $repository = Mockery::mock(StoreReceivingAlipayBillMirrorRepository::class); $repository->shouldReceive('filterOptionsForChannel')->once()->with( 'alipayCSV', StoreReceivingAlipayBillMirrorRepository::DATASET, true )->andReturn(['shops' => ['天猫A店'], 'bill_items' => ['交易收款']]); $this->app->instance(StoreReceivingAlipayBillMirrorRepository::class, $repository); $this->loginAs() ->getJson(route('ecommerce-finance.alipay-bills.filter-options')) ->assertOk() ->assertExactJson(['shops' => ['天猫A店'], 'bill_items' => ['交易收款']]); } public function testTaobaoAlipayFilterOptionsReportAnUnreadyMirror(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); $repository = Mockery::mock(StoreReceivingAlipayBillMirrorRepository::class); $repository->shouldReceive('filterOptionsForChannel')->once() ->andThrow(new \RuntimeException('store_receiving_alipay_bill_mirror_not_ready')); $this->app->instance(StoreReceivingAlipayBillMirrorRepository::class, $repository); $this->loginAs() ->getJson(route('ecommerce-finance.alipay-bills.filter-options')) ->assertStatus(503) ->assertJsonPath('shops', []) ->assertJsonPath('bill_items', []); } public function testTaobaoAlipayBillsPageProvidesTheVueMountPoint() { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'billing-transactions', 'page' => 'store-receiving-bills', 'tab' => 'taobao-alipay', ])) ->assertOk() ->assertSee('class="biz-card mt-6 business-blank-page"', false) ->assertSee('public/js/business/pages.min.js', false); } public function testDouyinWorkspaceUsesMatchingRuntimeAssetVersions(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'billing-transactions', 'page' => 'store-receiving-bills', 'tab' => 'douyin', ]))->assertOk() ->assertSee('business-blank-page', false) ->assertSee('public/js/manifest.js?id=' . md5_file(public_path('js/manifest.js')), false) ->assertSee('public/js/vendor.js?id=' . md5_file(public_path('js/vendor.js')), false) ->assertSee('public/js/business/pages.min.js?id=' . md5_file(public_path('js/business/pages.min.js')), false) ->assertDontSee('public/js/vendor.js?v=', false); } public function testDouyinAccountBillsEndpointReadsTheFixedTouTiaoChannel(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class); $repository->shouldReceive('paginate')->once()->with( [], 'occurred_at', 'desc', 1, 10, )->andReturn([ 'current_page' => 1, 'last_page' => 1, 'per_page' => 10, 'total' => 1, 'data' => [[ 'id' => '9001', 'record_code' => '9001', 'store_name' => '放心购旗舰店', 'income' => 88.50, 'expense' => 0.0, 'payload' => [ 'settle_account' => 'TouTiaoFXGZJ', 'move_scene' => '头条放心购', ], ]], 'summary' => ['income' => 88.50, 'expense' => 0.0, 'balance' => 88.50, 'count' => 1], 'sync' => ['status' => 'succeeded', 'last_succeeded_at' => '2026-09-08 09:00:00'], ]); $this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository); $this->loginAs() ->withHeader('X-Requested-With', 'XMLHttpRequest') ->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'douyin'])) ->assertOk() ->assertJsonPath('data.0.payload.move_scene', '头条放心购') ->assertJsonPath('data.0.payload.settle_account', 'TouTiaoFXGZJ') ->assertJsonPath('columns.title', '抖音账户') ->assertJsonPath('summary.count', 1); } public function testDouyinAccountBillsEndpointPassesAllFiltersToTheMirror(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $filters = [ 'store_name' => '放心购', 'bill_item' => '交易佣', 'start' => '2026-08-01', 'end' => '2026-08-01', 'keyword' => '', ]; $repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class); $repository->shouldReceive('paginate')->once()->with( $filters, 'occurred_at', 'desc', 1, 10, )->andReturn([ 'current_page' => 1, 'last_page' => 1, 'per_page' => 10, 'total' => 0, 'data' => [], 'summary' => ['income' => 0.0, 'expense' => 0.0, 'balance' => 0.0, 'count' => 0], ]); $this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository); $this->loginAs() ->withHeader('X-Requested-With', 'XMLHttpRequest') ->get(route('ecommerce-finance.account-bills.index', [ 'bill_type' => 'douyin', ...$filters, ])) ->assertOk() ->assertJsonPath('total', 0); } public function testDouyinAccountBillsFilterOptionsEndpointReturnsUsableChoices(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class); $repository->shouldReceive('filterOptions')->once()->andReturn([ 'shops' => ['放心购旗舰店', '放心购专营店'], 'bill_items' => ['订单收入', '交易佣金'], ]); $this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository); $this->loginAs() ->withHeader('X-Requested-With', 'XMLHttpRequest') ->get(route('ecommerce-finance.account-bills.filter-options', ['bill_type' => 'douyin'])) ->assertOk() ->assertExactJson([ 'shops' => ['放心购旗舰店', '放心购专营店'], 'bill_items' => ['订单收入', '交易佣金'], ]); } public function testDouyinAccountBillsExportUsesDateFiltersAndSelectedColumnOrder(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class); $repository->shouldReceive('exportRows')->once()->with([ 'start' => '2026-09-09', 'end' => '2026-09-09', ], 'occurred_at', 'desc')->andReturn([[ 'record_code' => '9001', 'payload' => [ 'move_scene' => '头条放心购', 'settle_account' => 'TouTiaoFXGZJ', ], ]]); $this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository); $response = $this->loginAs()->get(route('ecommerce-finance.account-bills.export', [ 'bill_type' => 'douyin', 'start' => '2026-09-09', 'end' => '2026-09-09', 'columns' => 'payload.move_scene,record_code,payload.settle_account', ])); $response->assertOk() ->assertHeader('content-type', 'text/csv; charset=UTF-8') ->assertDownload('douyin_account_bills.csv'); $csv = $response->streamedContent(); $this->assertStringContainsString('动账场景,记录编码,结算账户', $csv); $this->assertStringContainsString('头条放心购,9001,TouTiaoFXGZJ', $csv); } public function testDouyinAccountBillsReturnsARetryableResponseWhenMirrorIsNotReady(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $repository = Mockery::mock(StoreReceivingDouyinBillMirrorRepository::class); $repository->shouldReceive('paginate')->once()->andThrow( new \RuntimeException('store_receiving_alipay_bill_mirror_not_ready') ); $this->app->instance(StoreReceivingDouyinBillMirrorRepository::class, $repository); $this->loginAs() ->withHeader('X-Requested-With', 'XMLHttpRequest') ->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'douyin'])) ->assertStatus(503) ->assertJsonPath('message', '抖音账户账单共享业务镜像正在首次同步,完成后将自动显示。'); } public function testTaobaoAlipayBillsExportUsesTheMirrorRepository() { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $repository = Mockery::mock(StoreReceivingAlipayBillMirrorRepository::class); $repository->shouldReceive('exportRows')->once()->with(Mockery::on( static fn (array $filters): bool => ($filters['store_name'] ?? '') === '天猫-经营店' && ($filters['start'] ?? '') === '2026-08-01' && ($filters['end'] ?? '') === '2026-08-31' ), 'occurred_at', 'desc')->andReturn([[ 'record_code' => '1', 'store_name' => '天猫-经营店', 'alipay_account' => 'CommonBillTGCPay', 'bill_item' => '交易佣金', 'account_order_no' => 'KEY-1', 'business_serial_no' => '1001', 'merchant_order_no' => 'P-1', 'business_base_order_no' => 'ORDER-1', 'goods_name' => 'SKU-1', 'occurred_at' => '2026-08-01 09:00:00', 'income' => 100.25, 'expense' => 0, 'balance' => 100.25, 'business_type' => '收入', 'online_order_no' => 'ORDER-1', 'business_description' => 'double', 'business_bill_source' => 'CommonBillTGCPay', 'created_at' => '2026-08-02 09:00:00', 'updated_at' => '2026-08-02 10:00:00', ]]); $this->app->instance(StoreReceivingAlipayBillMirrorRepository::class, $repository); $this->loginAs() ->get(route('ecommerce-finance.alipay-bills.export', [ 'store_name' => '天猫-经营店', 'start' => '2026-08-01', 'end' => '2026-08-31', ])) ->assertOk() ->assertHeader('content-type', 'text/csv; charset=UTF-8') ->assertDownload('taobao_alipay_bills.csv'); } public function testSupplierPayablesPageRendersItsSpecificContent() { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'supplier-payables', 'page' => 'payment-reconciliation', ])) ->assertOk() ->assertSee('id="goods-reconciliation-app"', false) ->assertSee('public/js/ecommerce-finance/goods-reconciliations.min.js', false) ->assertDontSee('class="business-blank-page"', false); } public function testOrderDataTablesRenderAsRawDataPageTabs() { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $this->loginAs() ->get(route('business.pages.show', ['section' => 'order-data'])) ->assertRedirect(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', ])); $this->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', ])) ->assertRedirect(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', ])); $this->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', ])) ->assertOk() ->assertSeeText('订单售后费用明细') ->assertSee('aria-label="订单售后费用明细"', false) ->assertSeeText('内部订单号') ->assertSeeText('800503发空包/赠品费用') ->assertSee('class="business-tab-bar"', false) ->assertSee(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', ]), false) ->assertSee(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'promotion-expenses', ]), false) ->assertDontSee(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'after-sales-expenses', ]), false) ->assertDontSee('class="business-blank-page"', false); $this->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'promotion-expenses', ])) ->assertOk() ->assertSeeText('商品推广费用明细') ->assertSee('aria-label="商品推广费用明细"', false) ->assertSeeText('店铺') ->assertSeeText('8001201直通车/钻展/快车等') ->assertDontSee('class="business-blank-page"', false); } public function testOrderDataIncludesTheConfiguredBillOffsetSubpage(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $filters = [ 'from' => now()->subDays(6)->toDateString(), 'to' => now()->toDateString(), 'order_no' => '', 'shop_name' => '', 'sku_id' => '', ]; $orderData = Mockery::mock(OrderDataPageData::class); $orderData->shouldReceive('afterSalesFilters')->zeroOrMoreTimes()->andReturn($filters); $orderData->shouldReceive('afterSalesRows')->zeroOrMoreTimes()->andReturn([ 'rows' => [], 'has_more' => false, 'status' => ['X-Caiwu-Data-Status' => 'direct'], ]); $this->app->instance(OrderDataPageData::class, $orderData); $this->loginAs(); $this->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'bill-offset', ])) ->assertOk() ->assertSeeInOrder([ route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', ]), route('business.pages.show', [ 'section' => 'order-data', 'page' => 'bill-offset', ]), ], false); $this->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'bill-offset', ])) ->assertOk() ->assertSeeText('账单冲减') ->assertSeeText('暂无数据') ->assertDontSeeText('账单冲减镜像尚未完成首次同步'); $this->withExceptionHandling(); foreach (['national-subsidy', 'hundred-subsidy', 'consumption-coupon'] as $removedSlug) { $this->withHeader('X-Requested-With', 'XMLHttpRequest') ->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => $removedSlug, ])) ->assertNotFound(); } } public function testBillOffsetPageUsesAfterSalesRowsAndTheOriginalAccountField(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $filters = [ 'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '', 'shop_name' => '', 'sku_id' => '', ]; $orderData = Mockery::mock(OrderDataPageData::class); $orderData->shouldReceive('afterSalesFilters')->once()->andReturn($filters); $orderData->shouldReceive('afterSalesRows')->once()->with($filters, 1, 25)->andReturn([ 'rows' => [[ 'internal_order_no' => '9228703', 'online_order_no' => '5127724585360006527', 'shop_name' => '天猫-亿家达旗舰店', 'shop_code' => 'SHOP-1', 'product_code' => 'DNZ005374N79V01', 'style_code' => 'STYLE-1', 'shop_product_code' => 'SP-1', 'shop_style_code' => 'SI-1', 'account_500112' => -20, ]], 'has_more' => false, 'status' => ['X-Caiwu-Data-Status' => 'direct'], ]); $this->app->instance(OrderDataPageData::class, $orderData); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'bill-offset', 'from' => '2026-08-01', 'to' => '2026-08-31', ]))->assertOk() ->assertSeeText('9228703') ->assertSeeText('天猫-亿家达旗舰店') ->assertSeeText('-20.00') ->assertSeeText('本页 500112 冲减收入合计') ->assertSee(e(route('business.bill-offset.export', [ 'from' => '2026-08-01', 'to' => '2026-08-31', ])), false) ->assertSee('id="bill-offset-export-button"', false) ->assertSeeText('数据状态:DIRECT'); } public function testBillOffsetPageKeepsEmptyAccountFieldAsDash(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $filters = [ 'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '', 'shop_name' => '', 'sku_id' => '', ]; $orderData = Mockery::mock(OrderDataPageData::class); $orderData->shouldReceive('afterSalesFilters')->once()->andReturn($filters); $orderData->shouldReceive('afterSalesRows')->once()->with($filters, 1, 25)->andReturn([ 'rows' => [[ 'internal_order_no' => 'ORDER-EMPTY', 'online_order_no' => 'ONLINE-EMPTY', 'shop_name' => '空值店铺', 'account_500112' => null, ]], 'has_more' => false, 'status' => ['X-Caiwu-Data-Status' => 'direct'], ]); $this->app->instance(OrderDataPageData::class, $orderData); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'bill-offset', 'from' => '2026-08-01', 'to' => '2026-08-31', ]))->assertOk() ->assertSeeText('ORDER-EMPTY') ->assertSeeText('空值店铺') ->assertSeeText('本页 500112 冲减收入合计') ->assertSee('data-export-value=""', false) ->assertSeeText('-') ->assertDontSeeText('0.00'); } public function testBillOffsetPageShowsReadFailureSeparatelyFromAnUnreadyMirror(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $filters = [ 'from' => now()->subDays(6)->toDateString(), 'to' => now()->toDateString(), 'order_no' => '', 'shop_name' => '', 'sku_id' => '', ]; $orderData = Mockery::mock(OrderDataPageData::class); $orderData->shouldReceive('afterSalesFilters')->once()->andReturn($filters); $orderData->shouldReceive('afterSalesRows')->once()->andThrow(new \RuntimeException('database unavailable')); $this->app->instance(OrderDataPageData::class, $orderData); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'bill-offset', ]))->assertOk() ->assertSeeText('订单售后费用数据暂时无法读取,请稍后重试。'); } public function testBillOffsetExportKeepsUnknownAccountAmountsEmpty(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); $filters = [ 'from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => 'ORDER-EMPTY', 'shop_name' => '', 'sku_id' => '', ]; $orderData = Mockery::mock(OrderDataPageData::class); $orderData->shouldReceive('afterSalesFilters')->once()->with($filters)->andReturn($filters); $orderData->shouldReceive('afterSalesRows')->once()->with($filters, 1, 100)->andReturn([ 'rows' => [[ 'internal_order_no' => 'ORDER-EMPTY', 'account_500112' => null, 'reversal_order_amount' => 987.65, ]], 'has_more' => false, 'status' => ['X-Caiwu-Data-Status' => 'direct'], ]); $this->app->instance(OrderDataPageData::class, $orderData); $csv = $this->loginAs()->get(route('business.bill-offset.export', $filters))->streamedContent(); $this->assertStringNotContainsString('987.65', $csv); $lines = preg_split('/\r?\n/', trim($csv)); $this->assertSame('', str_getcsv($lines[1])[8]); } public function testBillOffsetShowsTheOriginalDataErrorAndBlocksAnEmptyExport(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); $filters = ['from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => '', 'shop_name' => '', 'sku_id' => '']; $orderData = Mockery::mock(OrderDataPageData::class); $orderData->shouldReceive('afterSalesFilters')->twice()->andReturn($filters); $orderData->shouldReceive('afterSalesRows')->twice()->andThrow(new \RuntimeException('database unavailable')); $this->app->instance(OrderDataPageData::class, $orderData); $this->loginAs()->get(route('business.pages.show', ['section' => 'order-data', 'page' => 'bill-offset'])) ->assertOk()->assertSeeText('订单售后费用数据暂时无法读取,请稍后重试。'); $this->withExceptionHandling()->get(route('business.bill-offset.export'))->assertStatus(503); } public function testBillOffsetExportIncludesAllPagesFromOriginalDataWithTheSameFilters(): void { $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); $filters = ['from' => '2026-08-01', 'to' => '2026-08-31', 'order_no' => 'ORDER', 'shop_name' => '', 'sku_id' => '']; $orderData = Mockery::mock(OrderDataPageData::class); $orderData->shouldReceive('afterSalesFilters')->once()->with($filters)->andReturn($filters); $orderData->shouldReceive('afterSalesRows')->once()->with($filters, 1, 100)->andReturn([ 'rows' => [[ 'internal_order_no' => 'ORDER-1', 'online_order_no' => '001234567890123456789', 'shop_name' => '测试店铺', 'account_500112' => -20, ]], 'has_more' => true, 'status' => ['X-Caiwu-Data-Status' => 'direct'], ]); $orderData->shouldReceive('afterSalesRows')->once()->with($filters, 2, 100)->andReturn([ 'rows' => [[ 'internal_order_no' => 'ORDER-101', 'account_500112' => null, 'reversal_order_amount' => 987.65, ]], 'has_more' => false, 'status' => ['X-Caiwu-Data-Status' => 'direct'], ]); $this->app->instance(OrderDataPageData::class, $orderData); $response = $this->loginAs()->get(route('business.bill-offset.export', $filters)); $response->assertOk()->assertHeader('Content-Type', 'text/csv; charset=UTF-8'); $csv = $response->streamedContent(); $this->assertStringContainsString('500112冲减收入', $csv); $this->assertStringContainsString('测试店铺', $csv); $this->assertStringContainsString('-20.00', $csv); $this->assertStringContainsString("'001234567890123456789", $csv); $this->assertStringContainsString('ORDER-1', $csv); $this->assertStringContainsString('ORDER-101', $csv); $this->assertStringNotContainsString('987.65', $csv); $lines = preg_split('/\r?\n/', trim($csv)); $this->assertCount(3, $lines); $this->assertSame('', str_getcsv($lines[2])[8]); } public function testFinancialAfterSalesPageRendersMirroredJushuitanRefunds(): void { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $refundStates = [ NonJingxiRefundMirrorRepository::INFO_DATASET => [ 'status' => 'succeeded', 'initial_completed_at' => '2026-08-24 12:00:00', ], NonJingxiRefundMirrorRepository::ITEM_DATASET => [ 'status' => 'succeeded', 'initial_completed_at' => '2026-08-24 12:00:00', ], ]; $refunds = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $refunds->shouldReceive('available')->once()->andReturnTrue(); $refunds->shouldReceive('states')->once()->andReturn($refundStates); $refunds->shouldReceive('syncStatus')->once()->with($refundStates)->andReturn([ 'code' => 'healthy', 'last_succeeded_at' => '2026-08-24 12:00:00', ]); $refunds->shouldReceive('ready')->once()->andReturnTrue(); $refunds->shouldReceive('queryReady')->once()->andReturnTrue(); $refunds->shouldReceive('paginate')->once()->withArgs( static fn (array $filters, int $page, int $limit): bool => $filters === [ 'from' => '2026-08-24', 'to' => '2026-08-24', 'order_no' => '', 'shop_name' => '天猫旗舰店', 'sku_id' => '', ] && $page === 1 && $limit === 10 )->andReturn([ 'data' => [(object) [ 'refundDate' => '2026-08-24 10:00:00', 'outer_as_id' => 'AS-FIN-001', 'o_id' => 'ORDER-FIN-001', 'so_id' => 'SUB-FIN-001', 'type' => '退款', 'shop_name' => '天猫旗舰店', 'platform' => '天猫', 'i_id' => 'ITEM-FIN-001', 'sku_id' => 'SKU-FIN-001', 'qty' => 1, 'detailRefundMoney' => 99.5, 'refundType' => '退货退款', 'RefundMoneyIncome' => 99.5, 'AfSalesMoney' => 99.5, 'status' => '完成', ]], 'total' => 1, ]); $this->app->instance(NonJingxiRefundMirrorRepository::class, $refunds); $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'financial-operations', 'page' => 'after-sales-expenses', 'from' => '2026-08-24', 'to' => '2026-08-24', 'shop_name' => '天猫旗舰店', ])) ->assertOk() ->assertDontSeeText('聚水潭售后工单') ->assertDontSeeText('WO-FIN-005') ->assertSeeText('售后(退货退款)明细') ->assertSeeText('AS-FIN-001') ->assertSeeText('ORDER-FIN-001') ->assertSeeText('SKU-FIN-001') ->assertSeeText('99.50') ->assertSeeText('退款回款金额'); } public function testRefundPagesUseTheGenericBusinessPageRoute() { $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); $repository = \Mockery::mock(NonJingxiRefundMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnFalse(); $repository->shouldNotReceive('states'); $repository->shouldNotReceive('syncStatus'); $repository->shouldNotReceive('ready'); $repository->shouldNotReceive('queryReady'); $repository->shouldNotReceive('paginate'); $this->app->instance(NonJingxiRefundMirrorRepository::class, $repository); $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'refunds', ])) ->assertRedirect(route('business.pages.show', [ 'section' => 'refunds', 'page' => 'jingxi-refund-data', ])); $this->get(route('business.pages.show', [ 'section' => 'refunds', 'page' => 'jingxi-refund-data', ])) ->assertOk() ->assertSeeText('京喜退款数据') ->assertSee('aria-label="京喜退款数据"', false) ->assertSee('data-header-row="labels"', false) ->assertSeeInOrder([ '店铺', '账单项目', '线上单号', '单据类型', '商品编码', '商品名称', '费用计算时间', '费用发生时间', '费用项', '收入金额', '支出金额', '币种', '钱包余额备注', '备注', ], false) ->assertSeeText('当前暂无京喜退款数据。') ->assertDontSee('class="biz-empty"', false); $response = $this->get(route('business.pages.show', [ 'section' => 'refunds', 'page' => 'non-jingxi-refund-data', ])); $response ->assertOk() ->assertSeeText('售后(退货退款)') ->assertSee('aria-label="售后(退货退款)"', false) ->assertSee('aria-label="售后(退货退款)分页"', false) ->assertDontSeeText('非京喜退款数据') ->assertSeeText('共享业务镜像尚未建立') ->assertSeeText('数据暂时无法读取') ->assertSee('class="upstream-data-table refund-data-table refund-data-table--wide"', false) ->assertSee('data-header-row="labels"', false) ->assertSee('data-header-row="fields"', false) ->assertDontSee('class="biz-empty"', false); $html = $response->getContent(); $headerRows = []; preg_match_all( '/