两个页签的「订单号」「商品编码」改成多行输入框,可以直接粘一整列: 每行一个,或用逗号、空格、分号、顿号分隔,一次最多 50 个值,多值取并集。 - 订单号批量:先去两套来源解析候选 o_id(单表、等值 IN,走各自前缀索引), 再按订单过滤;单个订单号仍保留「从头匹配」的模糊语义。 线上实测 20 个真实单号解析 0.01 秒。 - 商品编码批量:每个编码在 商品/款式/店铺编码(推广页为 item_id)里匹配一组, 多组 OR;仍受统计日期/订单日期范围约束。 - 非淘系旧查询路径同步支持批量,保持两套口径一致。
439 lines
20 KiB
PHP
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()
|
|
);
|
|
}
|
|
}
|