CaiWuXiTong/tests/Unit/EcommerceFinance/OrderDataMirrorRepositoryTest.php
renshen af83d75a00 订单售后费用明细:订单号查询改为先解析候选订单再过滤
订单号原来在「订单 ⋈ 明细」关联后做三列 %x% 匹配,没有日期条件时优化器会放弃
索引,淘系那套 520 万订单 × 680 万明细必须全表扫描,线上实测单条跑到 19 分钟
还没结束(还占满 Apache 工作进程)。

改成两段式:先用带前缀索引的单表查询把订单号解析成两套来源各自的候选 o_id
(线上订单号 / 子订单号从头匹配、内部订单号按数值等值),再让分支按 o_id 过滤。
两套来源分开解析,避免两侧 o_id 撞号。候选超过 500 条或订单号不足 4 个字符时
退回原来的包含匹配,保持既有行为。

顺带给 jst_OrderItem 补 outer_oi_id 前缀索引(非淘系子订单号原来要扫 70 秒),
已在生产建好索引。
2026-10-07 11:31:56 +08:00

354 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Mockery;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\OrderDataMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class OrderDataMirrorRepositoryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.connections.business_mirror' => [
'driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '',
]]);
DB::purge('business_mirror');
$this->createTables();
}
public function test_after_sales_reads_complete_rows_from_shared_mirror(): void
{
$db = DB::connection('business_mirror');
$db->table('ops_jst_orders')->insert([
'o_id' => 1, 'so_id' => 'SO-1', 'pay_date' => '2026-08-01 08:00:00',
'end_time' => '2026-08-02 12:00:00', 'send_date' => '2026-08-02 10:00:00',
'order_date' => '2026-08-01 09:00:00', 'shop_name' => '京东店铺',
'shop_id' => 'SHOP-1', 'shop_site' => 'jd', 'type' => '普通订单',
'labels' => '正常', 'wms_co_id' => 'WH-1', 'l_id' => 'EXP-1',
'logistics_company' => '物流', 'receiver_state' => '江苏', 'receiver_city' => '常州',
'creator_name' => '销售员', 'free_amount' => 2, 'freight' => 5,
]);
$db->table('ops_jst_order_item')->insert([
'oi_id' => 11, 'o_id' => 1, 'outer_oi_id' => 'SUB-1', 'sku_id' => 'SKU-1',
'i_id' => 'STYLE-1', 'shop_sku_id' => 'SHOP-SKU-1', 'shop_i_id' => '1001',
'properties_value' => '黑色', 'name' => '线上商品', 'qty' => 2,
'amount' => 100, 'buyer_paid_amount' => 95, 'seller_income_amount' => 98,
'is_gift' => 0, 'src_combine_sku_id' => null, 'send_warehouse' => 'WH-1',
]);
$db->table('ops_jst_sku')->insert([
'autoid' => 1, 'sku_type' => 'normal', 'sku_id' => 'SKU-1', 'brand' => '品牌A', 'category' => '家具', 'vc_name' => '桌子',
'cost_price' => 20, 'name' => '镜像商品', 'supplier_id' => 'SUP-1', 'supplier_name' => '供应商A',
]);
$db->table('ops_jst_warehouse_info')->insert([
'wms_co_id' => 'WH-1', 'name' => '主仓', 'remark1' => '运营云仓',
]);
$db->table('ops_jst_return_refund_info')->insert([
'as_id' => 21, 'outer_as_id' => 'AF-1', 'o_id' => 1,
'as_date' => '2026-08-02 12:00:00', 'type' => '退货退款',
'question_type' => '质量问题', 'labels' => '售后', 'refund' => 50, 'free_amount' => 3,
]);
$db->table('ops_jst_return_refund_item')->insert([
'as_id' => 21, 'outer_oi_id' => 'SUB-1', 'sku_id' => 'SKU-1', 'type' => '退货',
'qty' => 1, 'amount' => 50, 'r_qty' => 1, 'shop_amount' => 50, 'defective_qty' => 1,
]);
$mirror = Mockery::mock(GenericBusinessMirrorRepository::class);
$mirror->shouldReceive('connection')->andReturn($db);
$repository = new OrderDataMirrorRepository(
Mockery::mock(BusinessMirrorDatasetRegistry::class), $mirror,
);
$result = $repository->afterSales([
'from' => '2026-08-01', 'to' => '2026-08-02',
'order_no' => '', 'shop_name' => '', 'sku_id' => '',
], 1, 25);
$this->assertCount(1, $result['rows']);
$row = $result['rows'][0];
$this->assertSame('SO-1', $row['online_order_no']);
$this->assertSame('AF-1', $row['after_sales_no']);
$this->assertSame(40.0, $row['product_cost']);
$this->assertSame(1.0, $row['post_return_quantity']);
$this->assertSame('https://item.jd.com/1001.html', $row['product_link']);
$this->assertNull($row['account_500112']);
$this->assertNull($row['account_8001201']);
}
public function test_promotion_expenses_use_the_bill_item_without_scanning_the_large_sku_map(): void
{
$db = DB::connection('business_mirror');
$db->table('ops_item_sales_cost_ratio')->insert([
'stat_date' => '2026-08-09 00:00:00', 'shop_name' => '京东店铺',
'item_id' => 'ITEM-1', 'push_fee' => 9.05,
]);
$mirror = Mockery::mock(GenericBusinessMirrorRepository::class);
$mirror->shouldReceive('connection')->andReturn($db);
$repository = new OrderDataMirrorRepository(
Mockery::mock(BusinessMirrorDatasetRegistry::class), $mirror,
);
$result = $repository->promotionExpenses([
'date' => '2026-08-09', 'shop_name' => '', 'sku_id' => '',
], 1, 25);
$this->assertSame('2026-08-09', $result['date']);
$this->assertNull($result['rows'][0]['product_code']);
$this->assertSame('ITEM-1', $result['rows'][0]['shop_product_code']);
$this->assertSame(9.05, $result['rows'][0]['promotion_fee']);
}
public function test_promotion_readiness_does_not_wait_for_the_unused_sku_map(): void
{
$datasets = app(BusinessMirrorDatasetRegistry::class);
$mirror = Mockery::mock(GenericBusinessMirrorRepository::class);
$mirror->shouldReceive('connection')->andReturn(DB::connection('business_mirror'));
$mirror->shouldReceive('ready')->once()->andReturnTrue();
$this->assertTrue((new OrderDataMirrorRepository($datasets, $mirror))->promotionReady());
}
public function test_after_sales_filters_by_multiple_shop_names(): void
{
$db = DB::connection('business_mirror');
$this->insertOrder($db, 1, 'SO-A', '拼多多-甲店');
$this->insertOrder($db, 2, 'SO-B', '京东-乙店');
$this->insertOrder($db, 3, 'SO-C', '抖音-丙店');
$repository = $this->repository($db);
$base = ['from' => '2026-08-01', 'to' => '2026-08-02', 'order_no' => '', 'shop_name' => '', 'sku_id' => ''];
$both = $repository->afterSales(array_replace($base, ['shop_names' => ['甲店', '乙店']]), 1, 25);
$this->assertSame(['京东-乙店', '拼多多-甲店'], $this->sortedShops($both['rows']));
$single = $repository->afterSales(array_replace($base, ['shop_names' => ['甲店']]), 1, 25);
$this->assertSame(['拼多多-甲店'], $this->sortedShops($single['rows']));
// 兼容旧的单值 shop_name 过滤。
$legacy = $repository->afterSales(array_replace($base, ['shop_name' => '丙店']), 1, 25);
$this->assertSame(['抖音-丙店'], $this->sortedShops($legacy['rows']));
$none = $repository->afterSales(array_replace($base, ['shop_names' => ['没有这个店铺']]), 1, 25);
$this->assertSame([], $none['rows']);
}
public function test_shop_names_lists_distinct_shops_from_the_order_mirror(): void
{
$db = DB::connection('business_mirror');
$this->insertOrder($db, 1, 'SO-A', '拼多多-甲店');
$this->insertOrder($db, 2, 'SO-B', '拼多多-甲店');
$this->insertOrder($db, 3, 'SO-C', '京东-乙店');
$names = $this->repository($db)->shopNames();
sort($names);
$this->assertSame(['京东-乙店', '拼多多-甲店'], $names);
}
public function test_after_sales_includes_taobao_orders_and_their_refunds(): void
{
$db = DB::connection('business_mirror');
$this->insertOrder($db, 1, 'SO-A', '拼多多-甲店');
// 淘系(天猫)订单与售后走另一套表,必须一起出现在报表里。
$db->table('ops_jst_qmcust_orders')->insert([
'Id' => 9001, 'o_id' => 9325277, 'so_id' => '5127742981080004116',
'order_date' => '2026-09-11 13:22:54', 'pay_date' => '2026-09-11 13:23:08',
'send_date' => '2026-09-15 10:00:00',
'shop_name' => '天猫-卓禾家具旗舰店', 'shop_id' => '18525257', 'shop_site' => '淘宝天猫',
'type' => '普通订单', 'wms_co_id' => 'WH-1',
]);
$db->table('ops_jst_qmcust_order_items')->insert([
'Id' => 8001, 'oi_id' => 9380102, 'o_id' => 9325277,
'outer_oi_id' => '5127742981080004116', 'sku_id' => 'SKU-TB-1',
'i_id' => 'STYLE-TB-1', 'name' => '天猫商品', 'qty' => 1, 'amount' => 88.0,
]);
$db->table('ops_jst_qmcust_refunds')->insert([
'as_id' => 7001, 'o_id' => 9325277, 'outer_as_id' => 'AF-TB-1',
'as_date' => '2026-09-20 10:00:00', 'type' => '退货退款',
'question_type' => '质量问题', 'labels' => '售后', 'refund' => 30,
]);
$db->table('ops_jst_qmcust_refund_items')->insert([
'asi_id' => 6001, 'as_id' => 7001, 'outer_oi_id' => '5127742981080004116',
'sku_id' => 'SKU-TB-1', 'type' => '退货', 'qty' => 1, 'amount' => 30, 'r_qty' => 1,
]);
$result = $this->repository($db)->afterSales([
'from' => '2026-09-01', 'to' => '2026-09-30',
'order_no' => '', 'shop_name' => '', 'sku_id' => '',
], 1, 25);
$rows = collect($result['rows'])->keyBy('shop_name');
$this->assertTrue($rows->has('天猫-卓禾家具旗舰店'));
$taobao = $rows->get('天猫-卓禾家具旗舰店');
$this->assertSame('5127742981080004116', $taobao['online_order_no']);
$this->assertSame('AF-TB-1', $taobao['after_sales_no']);
$this->assertSame('质量问题', $taobao['question_type']);
$this->assertSame(1.0, $taobao['post_return_quantity']);
$this->assertArrayNotHasKey('source_branch', $taobao);
// 店铺候选也要包含淘系店铺。
$names = $this->repository($db)->shopNames();
$this->assertContains('天猫-卓禾家具旗舰店', $names);
}
public function test_after_sales_order_number_is_resolved_to_candidates_before_filtering(): void
{
$db = DB::connection('business_mirror');
$this->insertOrder($db, 1, 'SO-2026-0001', '拼多多-甲店');
$this->insertOrder($db, 2, 'SO-2026-0002', '拼多多-乙店');
$repository = $this->repository($db);
$base = ['from' => '', 'to' => '', 'shop_name' => '', 'sku_id' => ''];
// 完整线上订单号:只返回这一单。
$exact = $repository->afterSales(array_replace($base, ['order_no' => 'SO-2026-0001']), 1, 25);
$this->assertSame(['SO-2026-0001'], $this->sortedOnlineOrders($exact['rows']));
// 子订单号(明细表的 outer_oi_id)同样能定位到订单。
$sub = $repository->afterSales(array_replace($base, ['order_no' => 'SUB-2']), 1, 25);
$this->assertSame(['SO-2026-0002'], $this->sortedOnlineOrders($sub['rows']));
// 查不到的单号直接返回空,不再对整张宽表做包含匹配。
$missing = $repository->afterSales(array_replace($base, ['order_no' => 'SO-9999-0000']), 1, 25);
$this->assertSame([], $missing['rows']);
}
public function test_after_sales_short_order_fragment_keeps_contains_match(): void
{
$db = DB::connection('business_mirror');
$this->insertOrder($db, 1, 'SO-2026-0001', '拼多多-甲店');
$this->insertOrder($db, 2, 'SO-2026-0002', '拼多多-乙店');
$result = $this->repository($db)->afterSales([
'from' => '', 'to' => '', 'order_no' => 'SO-', 'shop_name' => '', 'sku_id' => '',
], 1, 25);
$this->assertSame(['SO-2026-0001', 'SO-2026-0002'], $this->sortedOnlineOrders($result['rows']));
}
/** @param array<int,array<string,mixed>> $rows @return list<string> */
private function sortedOnlineOrders(array $rows): array
{
$numbers = array_map(static fn (array $row): string => (string) ($row['online_order_no'] ?? ''), $rows);
sort($numbers);
return array_values($numbers);
}
private function repository(\Illuminate\Database\ConnectionInterface $db): OrderDataMirrorRepository
{
$mirror = Mockery::mock(GenericBusinessMirrorRepository::class);
$mirror->shouldReceive('connection')->andReturn($db);
return new OrderDataMirrorRepository(Mockery::mock(BusinessMirrorDatasetRegistry::class), $mirror);
}
private function insertOrder(\Illuminate\Database\ConnectionInterface $db, int $id, string $soId, string $shopName): void
{
$db->table('ops_jst_orders')->insert([
'o_id' => $id, 'so_id' => $soId, 'order_date' => '2026-08-01 09:00:00',
'shop_name' => $shopName, 'shop_id' => 'SHOP-' . $id,
]);
$db->table('ops_jst_order_item')->insert([
'oi_id' => 100 + $id, 'o_id' => $id, 'outer_oi_id' => 'SUB-' . $id,
'sku_id' => 'SKU-' . $id, 'qty' => 1, 'amount' => 10,
]);
}
/** @param array<int,array<string,mixed>> $rows */
private function sortedShops(array $rows): array
{
$names = array_map(static fn (array $row): string => (string) ($row['shop_name'] ?? ''), $rows);
sort($names);
return $names;
}
private function createTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('ops_jst_orders', function (Blueprint $t): void {
$t->integer('o_id'); $t->string('so_id')->nullable(); $t->dateTime('pay_date')->nullable();
$t->dateTime('end_time')->nullable(); $t->dateTime('send_date')->nullable(); $t->dateTime('order_date');
$t->string('shop_name'); $t->string('shop_id')->nullable(); $t->string('shop_site')->nullable();
$t->string('type')->nullable(); $t->string('labels')->nullable(); $t->string('wms_co_id')->nullable();
$t->string('l_id')->nullable(); $t->string('logistics_company')->nullable();
$t->string('receiver_state')->nullable(); $t->string('receiver_city')->nullable();
$t->string('creator_name')->nullable(); $t->decimal('free_amount')->nullable(); $t->decimal('freight')->nullable();
});
$schema->create('ops_jst_order_item', function (Blueprint $t): void {
$t->integer('oi_id'); $t->integer('o_id'); $t->string('outer_oi_id')->nullable();
$t->string('sku_id'); $t->string('i_id')->nullable(); $t->string('shop_sku_id')->nullable();
$t->string('shop_i_id')->nullable(); $t->string('properties_value')->nullable(); $t->string('name')->nullable();
$t->decimal('qty')->nullable(); $t->decimal('amount')->nullable(); $t->decimal('buyer_paid_amount')->nullable();
$t->decimal('seller_income_amount')->nullable(); $t->integer('is_gift')->nullable();
$t->string('src_combine_sku_id')->nullable(); $t->string('send_warehouse')->nullable();
});
$schema->create('ops_jst_sku', function (Blueprint $t): void {
$t->integer('autoid'); $t->string('sku_type'); $t->string('sku_id'); $t->string('brand')->nullable(); $t->string('category')->nullable();
$t->string('vc_name')->nullable(); $t->decimal('cost_price')->nullable(); $t->string('name')->nullable();
$t->string('supplier_id')->nullable(); $t->string('supplier_name')->nullable();
});
$schema->create('ops_jst_warehouse_info', function (Blueprint $t): void {
$t->string('wms_co_id'); $t->string('name')->nullable(); $t->string('remark1')->nullable();
});
$schema->create('ops_jst_return_refund_info', function (Blueprint $t): void {
$t->integer('as_id'); $t->string('outer_as_id')->nullable(); $t->integer('o_id');
$t->dateTime('as_date'); $t->string('type')->nullable(); $t->string('question_type')->nullable();
$t->string('labels')->nullable(); $t->decimal('refund')->nullable(); $t->decimal('free_amount')->nullable();
});
$schema->create('ops_jst_return_refund_item', function (Blueprint $t): void {
$t->integer('as_id'); $t->string('outer_oi_id')->nullable(); $t->string('sku_id')->nullable();
$t->string('type')->nullable(); $t->decimal('qty')->nullable(); $t->decimal('amount')->nullable();
$t->decimal('r_qty')->nullable(); $t->decimal('shop_amount')->nullable(); $t->decimal('defective_qty')->nullable();
});
$schema->create('ops_item_sales_cost_ratio', function (Blueprint $t): void {
$t->dateTime('stat_date'); $t->string('shop_name'); $t->string('item_id'); $t->decimal('push_fee');
});
$schema->create('ops_jst_sku_map', function (Blueprint $t): void {
$t->string('shop_i_id'); $t->integer('shop_id'); $t->string('sku_id')->nullable();
$t->string('i_id')->nullable(); $t->string('shop_sku_id')->nullable();
$t->string('properties_value')->nullable(); $t->dateTime('modified')->nullable();
});
$schema->create('ops_jst_shop_info', function (Blueprint $t): void {
$t->integer('shop_id'); $t->string('shop_name');
});
// 淘系(天猫/淘宝)订单与售后:与 jst_Orders 系列并列的第二套来源。
$schema->create('ops_jst_qmcust_orders', function (Blueprint $t): void {
$t->integer('Id'); $t->integer('o_id'); $t->string('so_id')->nullable(); $t->dateTime('order_date');
$t->dateTime('pay_date')->nullable(); $t->dateTime('end_time')->nullable(); $t->dateTime('send_date')->nullable();
$t->string('shop_name'); $t->string('shop_id')->nullable(); $t->string('shop_site')->nullable();
$t->string('type')->nullable(); $t->string('labels')->nullable(); $t->string('wms_co_id')->nullable();
$t->string('l_id')->nullable(); $t->string('logistics_company')->nullable();
$t->string('receiver_state')->nullable(); $t->string('receiver_city')->nullable();
$t->string('creator_name')->nullable(); $t->decimal('free_amount')->nullable(); $t->decimal('freight')->nullable();
});
$schema->create('ops_jst_qmcust_order_items', function (Blueprint $t): void {
$t->integer('Id'); $t->integer('oi_id'); $t->integer('o_id'); $t->string('outer_oi_id')->nullable();
$t->string('sku_id'); $t->string('i_id')->nullable(); $t->string('shop_sku_id')->nullable();
$t->string('shop_i_id')->nullable(); $t->string('properties_value')->nullable(); $t->string('name')->nullable();
$t->decimal('qty')->nullable(); $t->decimal('amount')->nullable(); $t->decimal('buyer_paid_amount')->nullable();
$t->decimal('seller_income_amount')->nullable(); $t->integer('is_gift')->nullable();
$t->string('src_combine_sku_id')->nullable();
});
$schema->create('ops_jst_qmcust_refunds', function (Blueprint $t): void {
$t->integer('as_id'); $t->integer('o_id'); $t->string('outer_as_id')->nullable();
$t->dateTime('as_date'); $t->string('type')->nullable(); $t->string('question_type')->nullable();
$t->string('labels')->nullable(); $t->decimal('refund')->nullable(); $t->decimal('free_amount')->nullable();
});
$schema->create('ops_jst_qmcust_refund_items', function (Blueprint $t): void {
$t->integer('asi_id'); $t->integer('as_id'); $t->string('outer_oi_id')->nullable();
$t->string('sku_id')->nullable(); $t->string('type')->nullable(); $t->decimal('qty')->nullable();
$t->decimal('amount')->nullable(); $t->decimal('r_qty')->nullable(); $t->decimal('shop_amount')->nullable();
$t->decimal('defective_qty')->nullable();
});
}
}