CaiWuXiTong/tests/Feature/Business/OrderDataPagesUpstreamTest.php
renshen e5922dd78e 订单售后费用明细 / 商品推广费用明细:订单号和商品编码支持批量查询
两个页签的「订单号」「商品编码」改成多行输入框,可以直接粘一整列:
每行一个,或用逗号、空格、分号、顿号分隔,一次最多 50 个值,多值取并集。

- 订单号批量:先去两套来源解析候选 o_id(单表、等值 IN,走各自前缀索引),
  再按订单过滤;单个订单号仍保留「从头匹配」的模糊语义。
  线上实测 20 个真实单号解析 0.01 秒。
- 商品编码批量:每个编码在 商品/款式/店铺编码(推广页为 item_id)里匹配一组,
  多组 OR;仍受统计日期/订单日期范围约束。
- 非淘系旧查询路径同步支持批量,保持两套口径一致。
2026-10-07 13:55:02 +08:00

439 lines
20 KiB
PHP

<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Support\Facades\Cache;
use Mockery;
use Modules\EcommerceFinance\Repositories\OrderDataMirrorRepository;
use Modules\EcommerceFinance\Repositories\WorkOrderMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCache;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Tests\Feature\FeatureTestCase;
class OrderDataPagesUpstreamTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$mirror = Mockery::mock(OrderDataMirrorRepository::class);
$mirror->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('<textarea', false)
->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('<textarea', false)
->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()
);
}
}