shouldReceive('ready')->zeroOrMoreTimes()->andReturnFalse(); $mirror->shouldReceive('promotionReady')->zeroOrMoreTimes()->andReturnFalse(); $mirror->shouldReceive('shopNames')->zeroOrMoreTimes()->andReturn([]); $this->app->instance(OrderDataMirrorRepository::class, $mirror); $workOrders = Mockery::mock(WorkOrderMirrorRepository::class); $workOrders->shouldReceive('paginateAfterSales')->zeroOrMoreTimes()->andReturn([ 'data' => [], 'total' => 0, 'page' => 1, 'per_page' => 10, 'last_page' => 1, 'ready' => false, 'sync_status' => ['ready' => false, 'code' => 'initial_sync_pending', 'last_succeeded_at' => null, 'datasets' => []], ]); $this->app->instance(WorkOrderMirrorRepository::class, $workOrders); Cache::forget('order-data.after-sales.shop-options'); } public function test_after_sales_prefers_complete_business_mirror_without_registering_a_snapshot(): void { $this->allowOrderDataPages(); $mirror = Mockery::mock(OrderDataMirrorRepository::class); $mirror->shouldReceive('ready')->once()->andReturnTrue(); $mirror->shouldReceive('shopNames')->zeroOrMoreTimes()->andReturn([]); $mirror->shouldReceive('afterSales')->once()->andReturn(['rows' => [[ 'internal_order_no' => 'MIRROR-ORDER-1', 'shop_name' => '共享镜像店铺', 'product_amount' => 88.5, ]] ]); $this->app->instance(OrderDataMirrorRepository::class, $mirror); $factory = Mockery::mock(UpstreamQueryCacheFactory::class); $factory->shouldNotReceive('forSource'); $this->app->instance(UpstreamQueryCacheFactory::class, $factory); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', ]))->assertOk() ->assertSeeText('MIRROR-ORDER-1') ->assertSeeText('共享镜像店铺') ->assertSeeText('MIRROR'); } public function test_after_sales_page_offers_a_selectable_multi_shop_filter(): void { $this->allowOrderDataPages(); $mirror = Mockery::mock(OrderDataMirrorRepository::class); $mirror->shouldReceive('ready')->zeroOrMoreTimes()->andReturnTrue(); $mirror->shouldReceive('shopNames')->zeroOrMoreTimes()->andReturn(['拼多多-甲店', '京东-乙店']); $mirror->shouldReceive('afterSales')->once() ->with( Mockery::on(static fn (array $filters): bool => ($filters['shop_names'] ?? null) === ['拼多多-甲店', '京东-乙店']), 1, 25, ) ->andReturn(['rows' => [[ 'internal_order_no' => 'ORDER-MULTI-1', 'shop_name' => '拼多多-甲店', 'product_amount' => 12.5, ]]]); $this->app->instance(OrderDataMirrorRepository::class, $mirror); $factory = Mockery::mock(UpstreamQueryCacheFactory::class); $factory->shouldNotReceive('forSource'); $this->app->instance(UpstreamQueryCacheFactory::class, $factory); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', 'shop_name' => ['拼多多-甲店', '京东-乙店'], ])) ->assertOk() ->assertSee('data-shop-picker', false) ->assertSee('data-shop-options', false) ->assertSee('京东-乙店', false) ->assertSeeText('拼多多-甲店') ->assertSeeText('ORDER-MULTI-1'); } public function test_after_sales_filters_accept_batch_order_numbers_and_sku_codes(): void { $this->allowOrderDataPages(); $mirror = Mockery::mock(OrderDataMirrorRepository::class); $mirror->shouldReceive('ready')->zeroOrMoreTimes()->andReturnTrue(); $mirror->shouldReceive('shopNames')->zeroOrMoreTimes()->andReturn([]); $mirror->shouldReceive('afterSales')->once() ->with( Mockery::on(static fn (array $filters): bool => str_contains((string) $filters['order_no'], 'ORDER-1') && str_contains((string) $filters['order_no'], 'ORDER-2') && str_contains((string) $filters['sku_id'], 'SKU-9')), 1, 25, ) ->andReturn(['rows' => [[ 'internal_order_no' => 'ORDER-1', 'shop_name' => '批量测试店铺', 'product_amount' => 1.5, ]]]); $this->app->instance(OrderDataMirrorRepository::class, $mirror); $factory = Mockery::mock(UpstreamQueryCacheFactory::class); $factory->shouldNotReceive('forSource'); $this->app->instance(UpstreamQueryCacheFactory::class, $factory); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', 'from' => '2026-08-01', 'to' => '2026-08-09', 'order_no' => "ORDER-1\nORDER-2", 'sku_id' => "SKU-9\nSKU-8", ])) ->assertOk() ->assertSee('name="order_no"', false) ->assertSee('name="sku_id"', false) ->assertSee('assertSee('可批量', false) ->assertSeeText('批量测试店铺'); } public function test_promotion_page_offers_the_same_selectable_multi_shop_filter(): void { $this->allowOrderDataPages(); $mirror = Mockery::mock(OrderDataMirrorRepository::class); $mirror->shouldReceive('ready')->zeroOrMoreTimes()->andReturnTrue(); $mirror->shouldReceive('promotionReady')->zeroOrMoreTimes()->andReturnTrue(); $mirror->shouldReceive('promotionShopNames')->once()->with('2026-08-13') ->andReturn(['天猫_甲店', '京东_乙店']); $mirror->shouldReceive('promotionExpenses')->once() ->with( Mockery::on(static fn (array $filters): bool => ($filters['shop_names'] ?? null) === ['天猫_甲店', '京东_乙店']), 1, 25, ) ->andReturn(['date' => '2026-08-13', 'rows' => [[ 'shop_name' => '天猫_甲店', 'product_code' => null, 'style_code' => null, 'shop_product_code' => 'ITEM-1', 'shop_style_code' => 'ITEM-1', 'online_color_spec' => null, 'promotion_fee' => 12.5, ]]]); $this->app->instance(OrderDataMirrorRepository::class, $mirror); $factory = Mockery::mock(UpstreamQueryCacheFactory::class); $factory->shouldNotReceive('forSource'); $this->app->instance(UpstreamQueryCacheFactory::class, $factory); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'promotion-expenses', 'date' => '2026-08-13', 'shop_name' => ['天猫_甲店', '京东_乙店'], ])) ->assertOk() ->assertSee('data-shop-picker', false) ->assertSee('data-shop-options', false) ->assertSee('name="shop_name[]"', false) ->assertSee('name="sku_id"', false) ->assertSee('assertSee('可批量', false) ->assertSeeText('天猫_甲店') ->assertSeeText('12.5'); } public function test_after_sales_filter_normalization_keeps_defaults_and_clears_on_reset(): void { $data = app(\Modules\EcommerceFinance\View\OrderDataPageData::class); // 首次进入(没有任何参数)仍给近 7 天的默认区间。 $default = $data->afterSalesFilters([]); $this->assertSame(now()->subDays(6)->toDateString(), $default['from']); $this->assertSame(now()->toDateString(), $default['to']); // 点“重置”后不再带日期条件,页面上日期框保持为空。 $cleared = $data->afterSalesFilters(['reset' => 1]); $this->assertSame('', $cleared['from']); $this->assertSame('', $cleared['to']); $blank = $data->afterSalesFilters(['from' => '', 'to' => '']); $this->assertSame('', $blank['from']); $this->assertSame('', $blank['to']); $explicit = $data->afterSalesFilters(['from' => '2026-08-01', 'to' => '2026-08-31']); $this->assertSame('2026-08-01', $explicit['from']); $this->assertSame('2026-08-31', $explicit['to']); } public function test_after_sales_reset_renders_empty_date_inputs(): void { $this->allowOrderDataPages(); $mirror = Mockery::mock(OrderDataMirrorRepository::class); $mirror->shouldReceive('ready')->zeroOrMoreTimes()->andReturnTrue(); $mirror->shouldReceive('shopNames')->zeroOrMoreTimes()->andReturn([]); $mirror->shouldReceive('afterSales')->once() ->with( Mockery::on(static fn (array $filters): bool => $filters['from'] === '' && $filters['to'] === ''), 1, 25, ) ->andReturn(['rows' => []]); $this->app->instance(OrderDataMirrorRepository::class, $mirror); $factory = Mockery::mock(UpstreamQueryCacheFactory::class); $factory->shouldNotReceive('forSource'); $this->app->instance(UpstreamQueryCacheFactory::class, $factory); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', 'reset' => 1, ])) ->assertOk() ->assertSee('name="from" type="date" value=""', false) ->assertSee('name="to" type="date" value=""', false); } public function test_order_data_pages_render_cached_upstream_rows_and_filters(): void { $this->allowOrderDataPages(); $cache = Mockery::mock(UpstreamQueryCache::class); $cache->shouldReceive('remember')->twice()->andReturnUsing( function (string $scope, array $parameters): array { if ($scope === 'order-data.after-sales.list') { $this->assertSame('ORDER-1', $parameters['filters']['order_no']); return ['rows' => [[ 'internal_order_no' => 'ORDER-1', 'shop_name' => '测试店铺', 'product_amount' => 123.45, ]]]; } $this->assertSame('order-data.promotion.list', $scope); return [ 'date' => '2026-08-09', 'rows' => [[ 'shop_name' => '推广测试店铺', 'product_code' => '=SKU-1', 'promotion_fee' => 9.05, ]], ]; }, ); $cache->shouldReceive('responseHeaders')->twice()->andReturn([ 'X-Caiwu-Data-Status' => 'direct', ]); $factory = Mockery::mock(UpstreamQueryCacheFactory::class); $factory->shouldReceive('forSource')->twice()->with('operations_replica')->andReturn($cache); $this->app->instance(UpstreamQueryCacheFactory::class, $factory); $afterSalesResponse = $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', 'order_no' => 'ORDER-1', 'from' => '2026-08-01', 'to' => '2026-08-09', ])); $afterSalesResponse ->assertOk() ->assertSeeText('ORDER-1') ->assertSeeText('测试店铺') ->assertSeeText('123.45') ->assertSeeText('DIRECT') ->assertSeeText('导出当前页') ->assertSee('id="order-data-from" name="from" type="date" value="2026-08-01"', false) ->assertSee('id="order-data-to" name="to" type="date" value="2026-08-09"', false) ->assertSee('data-date-picker-contract="order-data-after-sales-date-picker"', false) ->assertSee('data-date-picker-input="order-data-from"', false) ->assertSee('aria-label="选择订单开始日期"', false) ->assertSee('data-date-picker-input="order-data-to"', false) ->assertSee('aria-label="选择订单结束日期"', false) ->assertSeeText('calendar_today') ->assertSee("typeof input.showPicker === 'function'", false) ->assertSee('id="order-data-after-sales-export-button"', false) ->assertSee('data-export-contract="order-data.after-sales.current-page-export"', false) ->assertSee('data-export-table="order-data-after-sales-table"', false) ->assertSee('data-export-value="ORDER-1"', false) ->assertSee('data-export-value="123.45"', false) ->assertSee('data-export-number="true"', false) ->assertSee('window.setTimeout(() => {', false) ->assertSee('URL.revokeObjectURL(url);', false) ->assertSee('订单售后费用明细_2026-08-01_2026-08-09_第1页.csv', false); $promotionResponse = $this->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'promotion-expenses', ])); $promotionResponse ->assertOk() ->assertSeeText('推广测试店铺') ->assertSeeText('SKU-1') ->assertSeeText('9.05') ->assertSee('value="2026-08-09"', false) ->assertSee('id="order-data-promotion-export-button"', false) ->assertSee('data-export-contract="order-data.promotion.current-page-export"', false) ->assertSee('data-export-table="order-data-promotion-table"', false) ->assertSee('data-export-value="=SKU-1"', false) ->assertSee('data-export-value="9.05"', false) ->assertSee('data-export-number="true"', false) ->assertSee('protectSpreadsheetFormula', false) ->assertSee('URL.revokeObjectURL(url);', false) ->assertSee('商品推广费用明细_2026-08-09_第1页.csv', false); } public function test_order_data_page_keeps_rendering_when_the_upstream_is_unavailable(): void { $this->allowOrderDataPages(); $cache = Mockery::mock(UpstreamQueryCache::class); $cache->shouldReceive('remember')->once()->andThrow( new ServiceUnavailableHttpException(30, '首份共享快照正在准备。') ); $factory = Mockery::mock(UpstreamQueryCacheFactory::class); $factory->shouldReceive('forSource')->once()->with('operations_replica')->andReturn($cache); $this->app->instance(UpstreamQueryCacheFactory::class, $factory); $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'promotion-expenses', ])) ->assertOk() ->assertSeeText('首份共享快照正在准备。') ->assertSeeText('暂无数据') ->assertSee('id="order-data-promotion-export-button"', false) ->assertSee('disabled="disabled"', false); } public function test_after_sales_export_is_disabled_when_the_current_page_has_no_rows(): void { $this->allowOrderDataPages(); $cache = Mockery::mock(UpstreamQueryCache::class); $cache->shouldReceive('remember')->once()->andReturn(['rows' => []]); $cache->shouldReceive('responseHeaders')->once()->andReturn([ 'X-Caiwu-Data-Status' => 'direct', ]); $factory = Mockery::mock(UpstreamQueryCacheFactory::class); $factory->shouldReceive('forSource')->once()->with('operations_replica')->andReturn($cache); $this->app->instance(UpstreamQueryCacheFactory::class, $factory); $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', ])) ->assertOk() ->assertSeeText('暂无数据') ->assertSeeText('导出当前页') ->assertSee('id="order-data-after-sales-export-button"', false) ->assertSee('disabled="disabled"', false); } public function test_after_sales_page_renders_mirrored_work_orders_as_a_separate_read_only_section(): void { $this->allowOrderDataPages(); $mirror = Mockery::mock(OrderDataMirrorRepository::class); $mirror->shouldReceive('ready')->once()->andReturnTrue(); $mirror->shouldReceive('afterSales')->once()->andReturn(['rows' => []]); $this->app->instance(OrderDataMirrorRepository::class, $mirror); $workOrders = Mockery::mock(WorkOrderMirrorRepository::class); $workOrders->shouldReceive('paginateAfterSales')->once()->withArgs(function (int $page, int $limit, array $filters): bool { return $page === 1 && $limit === 10 && $filters['from'] === '2026-08-24' && $filters['to'] === '2026-08-24' && $filters['sku_id'] === 'SKU-AF'; })->andReturn([ 'data' => [[ 'order_no' => 'WO-005-AF', 'work_type_name' => '天猫_售后超标', 'title' => '售后超标提醒', 'status' => '待处理', 'create_user' => '运营一组', 'create_time' => '2026-08-24 10:00:00', 'form_values' => [['field_name' => '店铺名称', 'field_value' => '天猫旗舰店']], ]], 'total' => 1, 'page' => 1, 'per_page' => 10, 'last_page' => 1, 'ready' => true, 'sync_status' => ['ready' => true, 'code' => 'ready', 'last_succeeded_at' => '2026-08-24 12:00:00', 'datasets' => []], ]); $this->app->instance(WorkOrderMirrorRepository::class, $workOrders); $this->loginAs()->get(route('business.pages.show', [ 'section' => 'order-data', 'page' => 'raw-data', 'tab' => 'after-sales-expenses', 'from' => '2026-08-24', 'to' => '2026-08-24', 'sku_id' => 'SKU-AF', ])) ->assertOk() ->assertSeeText('聚水潭售后工单') ->assertSeeText('WO-005-AF') ->assertSeeText('天猫_售后超标') ->assertSeeText('天猫旗舰店') ->assertSeeText('不计入上方订单退款金额'); } private function allowOrderDataPages(): void { $this->user->update(['locale' => 'zh-CN']); $this->user->attachPermission( Permission::where('name', 'read-admin-panel')->firstOrFail() ); } }