CaiWuXiTong/tests/Unit/EcommerceFinance/NonJingxiRefundMirrorRepositoryTest.php

397 lines
22 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class NonJingxiRefundMirrorRepositoryTest extends TestCase
{
private NonJingxiRefundMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => $this->definitions(),
]);
DB::purge('business_mirror');
$this->createTables();
$this->markReady();
$generic = new GenericBusinessMirrorRepository(app('db'));
$this->repository = new NonJingxiRefundMirrorRepository(
new BusinessMirrorDatasetRegistry(),
$generic,
);
}
public function test_ready_mirror_supports_second_page_filters_and_current_page_enrichment(): void
{
$db = DB::connection('business_mirror');
$db->table('ops_jst_return_refund_info')->insert([
$this->refundInfo(1, 101, '2026-08-19 10:00:00', '天猫', '一号店'),
$this->refundInfo(2, 102, '2026-08-20 10:00:00', '抖音', '二号店'),
$this->refundInfo(3, 103, '2026-08-21 10:00:00', 'jingxi', '京喜店'),
]);
$db->table('ops_jst_return_refund_item')->insert([
$this->refundItem(11, 101, 'SKU-1', 'SUB-1'),
$this->refundItem(12, 102, 'SKU-2', 'SUB-2'),
$this->refundItem(13, 103, 'SKU-JX', 'SUB-JX'),
]);
$db->table('ops_jst_orders')->insert([
['Id' => 1, 'o_id' => 8901, 'receiver_zip' => 'ZIP-1'],
['Id' => 2, 'o_id' => 8902, 'receiver_zip' => 'ZIP-2'],
]);
$db->table('ops_jst_order_item')->insert([
['Id' => 1, 'o_id' => 8901, 'outer_oi_id' => 'SUB-1', 'shop_i_id' => 'ITEM-1', 'shop_sku_id' => 'SHOP-SKU-1', 'i_id' => 'STYLE-1', 'sku_id' => 'SKU-1', 'buyer_paid_amount' => 88],
['Id' => 2, 'o_id' => 8902, 'outer_oi_id' => 'SUB-2', 'shop_i_id' => 'ITEM-2', 'shop_sku_id' => 'SHOP-SKU-2', 'i_id' => 'STYLE-2', 'sku_id' => 'SKU-2', 'buyer_paid_amount' => 99],
]);
$db->table('ops_jst_sku')->insert([
['autoid' => 1, 'sku_id' => 'SKU-1', 'sku_type' => 'normal', 'cost_price' => 20],
['autoid' => 2, 'sku_id' => 'SKU-2', 'sku_type' => 'normal', 'cost_price' => 30],
]);
$db->table('ops_fys_shop_info')->insert([
['Id' => 1, 'erpShopName' => '一号店', 'responsibleDepartment' => '部门甲'],
['Id' => 2, 'erpShopName' => '二号店', 'responsibleDepartment' => '部门乙'],
]);
$filters = ['from' => '2026-08-18', 'to' => '2026-08-21', 'order_no' => '', 'shop_name' => '', 'sku_id' => ''];
$page2 = $this->repository->paginate($filters, 2, 1);
$this->assertSame(2, $page2['total']);
$this->assertSame(11, $page2['data'][0]->Id);
$this->assertSame('ZIP-1', $page2['data'][0]->originPostcode);
$this->assertSame('ITEM-1', $page2['data'][0]->shop_i_id);
$this->assertSame(88, $page2['data'][0]->buyer_paid_amount);
$this->assertSame(20.0, $page2['data'][0]->unitCostGoods);
$this->assertSame(40.0, $page2['data'][0]->refundCostGoods);
$this->assertSame('部门甲', $page2['data'][0]->responsibleDepartment);
$filters['shop_name'] = '二号';
$this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']);
$filters['shop_name'] = '';
$filters['shop_name'] = '%';
$this->assertSame(0, $this->repository->paginate($filters, 1, 25)['total']);
$filters['shop_name'] = '';
$filters['sku_id'] = 'SKU-1';
$this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']);
$filters['sku_id'] = '';
$filters['order_no'] = '8902';
$this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']);
$filters['order_no'] = '';
$exportRows = iterator_to_array($this->repository->exportRows($filters, 1));
$this->assertCount(2, $exportRows);
$this->assertSame(12, $exportRows[0]->Id);
$this->assertSame(11, $exportRows[1]->Id);
}
public function test_sync_status_distinguishes_rotation_from_initial_sync(): void
{
$states = $this->repository->states();
foreach ($states as &$state) {
$state['status'] = 'running';
$state['last_succeeded_at'] = '2026-08-21 12:00:00';
}
unset($state);
$this->assertSame('syncing', $this->repository->syncStatus(
$states,
Carbon::parse('2026-08-21 12:05:00'),
)['code']);
$states[NonJingxiRefundMirrorRepository::INFO_DATASET]['initial_completed_at'] = null;
$this->assertSame('initializing', $this->repository->syncStatus($states)['code']);
}
public function test_supplement_and_header_only_refunds_survive_without_invented_details(): void
{
$db = DB::connection('business_mirror');
$db->table('ops_jst_return_refund_info')->insert($this->refundInfo(1, 101, '2026-07-02 10:00:00', '天猫', '测试店'));
$db->table('ops_jst_qmcust_refunds')->insert([
$this->supplement(201, '2026-07-02 10:00:00', 'SHOP-1'),
$this->supplement(202, '2026-07-03 10:00:00', 'UNKNOWN'),
$this->supplement(203, '2026-08-01 00:00:00', 'SHOP-1'),
$this->supplement(204, '2026-07-04 10:00:00', 'JINGXI'),
]);
$db->table('ops_jst_qmcust_refund_items')->insert([
'asi_id' => 500, 'as_id' => '201', 'sku_id' => 'Q-SKU', 'qty' => 2, 'r_qty' => 1,
'shop_amount' => '12.34', 'outer_oi_id' => 'Q-ITEM', 'type' => '退货退款',
]);
$db->table('ops_jst_shop_info')->insert([
['Id' => 1, 'shop_id' => 'SHOP-1', 'shop_site' => '天猫'],
['Id' => 2, 'shop_id' => 'SHOP-1', 'shop_site' => '天猫'],
['Id' => 3, 'shop_id' => 'JINGXI', 'shop_site' => '京喜'],
]);
$filters = ['from' => '2026-06-01', 'to' => '2026-07-31', 'order_no' => '', 'shop_name' => '', 'sku_id' => ''];
$result = $this->repository->paginate($filters, 1, 25);
$this->assertSame(3, $result['total']);
$this->assertSame(['202', '201', '101'], array_map(fn ($r) => (string) $r->as_id, $result['data']));
$unknown = $result['data'][0];
$this->assertSame('待确认', $unknown->platform);
$this->assertSame('暂无商品明细', $unknown->detailStatus);
$this->assertNull($unknown->sku_id);
$this->assertNull($unknown->detailRefundMoney);
$this->assertNull($unknown->RefundMoneyIncome);
$this->assertSame('退款清单', $result['data'][1]->dataSource);
$this->assertSame('Q-SKU', $result['data'][1]->sku_id);
$this->assertSame('12.34', $result['data'][1]->detailRefundMoney);
$this->assertSame('已关联', $result['data'][1]->detailStatus);
$this->assertSame('暂无商品明细', $result['data'][2]->detailStatus);
$this->assertNull($result['data'][2]->refundCostGoods);
// Cursor export must include both sources at the same timestamp exactly once.
$export = iterator_to_array($this->repository->exportRows($filters, 1));
$this->assertSame(['202', '201', '101'], array_map(fn ($r) => (string) $r->as_id, $export));
$this->assertSame('201', (string) $this->repository->paginate($filters, 2, 1)['data'][0]->as_id);
$filters['order_no'] = 'SO-Q-201';
$this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']);
$filters['order_no'] = '';
$filters['sku_id'] = 'Q-SKU';
$this->assertSame(1, $this->repository->paginate($filters, 1, 25)['total']);
}
public function test_sources_deduplicate_by_after_sales_id_or_shop_and_external_id(): void
{
$db = DB::connection('business_mirror');
$db->table('ops_jst_return_refund_info')->insert($this->refundInfo(1, 101, '2026-07-02 10:00:00', '天猫', '测试店') + ['shop_id' => 'SHOP-1']);
$sameId = $this->supplement(101, '2026-07-02 10:00:00', 'SHOP-1');
$sameExternal = $this->supplement(202, '2026-07-02 10:00:00', 'SHOP-1');
$sameExternal['outer_as_id'] = 'AS-101';
$differentShop = $sameExternal;
$differentShop['as_id'] = '203';
$differentShop['shop_id'] = 'SHOP-2';
$db->table('ops_jst_qmcust_refunds')->insert([$sameId, $sameExternal, $differentShop]);
$filters = ['from' => '2026-07-01', 'to' => '2026-07-31', 'order_no' => '', 'shop_name' => '', 'sku_id' => ''];
$result = $this->repository->paginate($filters, 1, 25);
$this->assertSame(2, $result['total']);
$this->assertEqualsCanonicalizing(['101', '203'], array_map(fn ($r) => (string) $r->as_id, $result['data']));
}
public function test_export_cursor_crosses_multi_item_headers_and_sources_without_skips(): void
{
$db = DB::connection('business_mirror');
$date = '2026-07-02 10:00:00';
$db->table('ops_jst_return_refund_info')->insert([
$this->refundInfo(1, 101, $date, '天猫', '测试店'),
$this->refundInfo(2, 102, $date, '天猫', '测试店'),
]);
$db->table('ops_jst_return_refund_item')->insert([
$this->refundItem(11, 101, 'MATCH', 'I-11'),
$this->refundItem(12, 102, 'MATCH', 'I-12'),
$this->refundItem(13, 102, 'OTHER', 'I-13'),
$this->refundItem(14, 102, 'MATCH', 'I-14'),
]);
$db->table('ops_jst_qmcust_refunds')->insert([
$this->supplement(201, $date, 'UNKNOWN'), $this->supplement(202, $date, 'UNKNOWN'),
]);
$db->table('ops_jst_qmcust_refund_items')->insert([
['asi_id' => 501, 'as_id' => '202', 'sku_id' => 'MATCH'],
['asi_id' => 502, 'as_id' => '202', 'sku_id' => 'MATCH'],
]);
$filters = ['from' => '2026-07-01', 'to' => '2026-07-31', 'order_no' => '', 'shop_name' => '', 'sku_id' => ''];
$expected = ['202:502', '202:501', '201:201', '102:14', '102:13', '102:12', '101:11'];
$identity = fn ($r) => $r->as_id . ':' . $r->Id;
$this->assertSame($expected, array_map($identity, iterator_to_array($this->repository->exportRows($filters, 1))));
$paged = [];
for ($page = 1; $page <= 4; $page++) {
$paged = array_merge($paged, $this->repository->paginate($filters, $page, 2)['data']);
}
$this->assertSame($expected, array_map($identity, $paged));
$filters['sku_id'] = 'MATCH';
$this->assertSame(['202:502', '202:501', '102:14', '102:12', '101:11'],
array_map($identity, iterator_to_array($this->repository->exportRows($filters, 1))));
}
public function test_supplement_initial_sync_is_required_before_claiming_complete_data(): void
{
DB::connection('business_mirror')->table('business_mirror_table_states')
->where('dataset_key', NonJingxiRefundMirrorRepository::SUPPLEMENT_ITEM_DATASET)
->update(['initial_completed_at' => null, 'status' => 'running']);
$this->assertFalse($this->repository->ready());
$this->assertSame('initializing', $this->repository->syncStatus()['code']);
}
public function test_default_export_crosses_a_thousand_row_batch_without_loss_or_duplicates(): void
{
$headers = [];
for ($id = 1; $id <= 1001; $id++) {
$headers[] = $this->refundInfo($id, 10000 + $id, '2026-07-01 00:00:00', '天猫', '测试店');
}
foreach (array_chunk($headers, 100) as $chunk) {
DB::connection('business_mirror')->table('ops_jst_return_refund_info')->insert($chunk);
}
$filters = ['from' => '2026-07-01', 'to' => '2026-07-01', 'order_no' => '', 'shop_name' => '', 'sku_id' => ''];
$rows = iterator_to_array($this->repository->exportRows($filters));
$this->assertSame(range(11001, 10001), array_map(static fn ($row): int => (int) $row->as_id, $rows));
}
public function test_query_is_blocked_when_a_required_lookup_index_is_missing(): void
{
Schema::connection('business_mirror')->table(
'ops_jst_return_refund_info',
static fn (Blueprint $table) => $table->dropIndex(['as_date', 'Id']),
);
$this->assertFalse($this->repository->queryReady());
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('business_mirror_dataset_not_ready');
$this->repository->paginate(
['from' => '2026-08-18', 'to' => '2026-08-21', 'order_no' => '', 'shop_name' => '', 'sku_id' => ''],
1,
25,
);
}
/** @return array<string,array<string,mixed>> */
private function definitions(): array
{
return [
NonJingxiRefundMirrorRepository::INFO_DATASET => $this->definition('jst_returnAndRefundInfo', 'ops_jst_return_refund_info'),
NonJingxiRefundMirrorRepository::ITEM_DATASET => $this->definition('jst_returnAndRefundItem', 'ops_jst_return_refund_item'),
NonJingxiRefundMirrorRepository::SUPPLEMENT_DATASET => $this->definition('jst_qmcust_refundlist', 'ops_jst_qmcust_refunds'),
NonJingxiRefundMirrorRepository::SUPPLEMENT_ITEM_DATASET => $this->definition('jst_qmcust_refundlist_item', 'ops_jst_qmcust_refund_items'),
NonJingxiRefundMirrorRepository::SHOP_DATASET => $this->definition('jst_shopInfo', 'ops_jst_shop_info'),
'operations_replica.rpt.jst_Orders' => $this->definition('jst_Orders', 'ops_jst_orders'),
'operations_replica.rpt.jst_OrderItem' => $this->definition('jst_OrderItem', 'ops_jst_order_item'),
'operations_replica.rpt.jst_sku' => $this->definition('jst_sku', 'ops_jst_sku'),
'operations_replica.rpt.fys_shopInfo' => $this->definition('fys_shopInfo', 'ops_fys_shop_info'),
];
}
/** @return array<string,mixed> */
private function definition(string $object, string $target): array
{
return ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => $object, 'target' => $target, 'keys' => ['Id']];
}
private function createTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('status')->default('succeeded');
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
});
$schema->create('ops_jst_return_refund_info', function (Blueprint $table): void {
$table->integer('Id'); $table->integer('as_id'); $table->dateTime('as_date'); $table->string('outer_as_id')->nullable();
$table->integer('o_id'); $table->string('so_id')->nullable(); $table->string('type')->nullable(); $table->string('node')->nullable();
$table->string('shop_type')->nullable(); $table->string('shop_name')->nullable(); $table->string('shop_site')->nullable();
$table->decimal('refund', 18, 2)->nullable(); $table->decimal('receive_refund', 18, 2)->nullable();
$table->decimal('buyer_apply_refund', 18, 2)->nullable(); $table->dateTime('createTime')->nullable();
$table->string('status')->nullable(); $table->string('shop_status')->nullable(); $table->dateTime('created')->nullable();
$table->dateTime('confirm_date')->nullable();
$table->string('shop_id')->nullable();
$table->index(['Id']);
$table->index(['as_date', 'Id']);
$table->index(['so_id']); $table->index(['outer_as_id']);
$table->index(['as_date', 'Id', 'as_id', 'shop_site']);
$table->index(['o_id', 'Id']);
$table->index(['as_id', 'Id']);
$table->index(['shop_id', 'outer_as_id']);
});
$schema->create('ops_jst_return_refund_item', function (Blueprint $table): void {
$table->integer('Id'); $table->integer('as_id'); $table->string('outer_oi_id')->nullable();
$table->string('shop_sku_id')->nullable(); $table->string('i_id')->nullable(); $table->string('sku_id')->nullable();
$table->integer('qty')->nullable(); $table->decimal('shop_amount', 18, 2)->nullable();
$table->string('type')->nullable(); $table->integer('r_qty')->nullable();
$table->index(['as_id', 'Id']);
});
$schema->create('ops_jst_orders', function (Blueprint $table): void {
$table->integer('Id'); $table->integer('o_id'); $table->string('receiver_zip')->nullable();
$table->index(['o_id', 'Id']);
});
$schema->create('ops_jst_order_item', function (Blueprint $table): void {
$table->integer('Id'); $table->integer('o_id'); $table->string('outer_oi_id');
$table->string('shop_i_id')->nullable(); $table->string('shop_sku_id')->nullable(); $table->string('i_id')->nullable();
$table->string('sku_id')->nullable(); $table->decimal('buyer_paid_amount', 18, 2)->nullable();
$table->index(['o_id', 'Id']);
});
$schema->create('ops_jst_sku', function (Blueprint $table): void {
$table->integer('autoid'); $table->string('sku_id'); $table->string('sku_type'); $table->decimal('cost_price', 18, 2)->nullable();
});
$schema->create('ops_fys_shop_info', function (Blueprint $table): void {
$table->integer('Id'); $table->string('erpShopName'); $table->string('responsibleDepartment')->nullable();
});
$schema->create('ops_jst_shop_info', function (Blueprint $table): void {
$table->integer('Id'); $table->string('shop_id'); $table->string('shop_site')->nullable();
$table->index(['shop_id', 'Id']);
});
$schema->create('ops_jst_qmcust_refunds', function (Blueprint $table): void {
$table->string('as_id'); $table->dateTime('as_date'); $table->string('outer_as_id')->nullable();
$table->integer('o_id')->nullable(); $table->string('so_id')->nullable(); $table->string('type')->nullable();
$table->string('node')->nullable(); $table->string('shop_type')->nullable(); $table->string('shop_name')->nullable();
$table->string('shop_id')->nullable(); $table->string('refund')->nullable(); $table->string('buyer_apply_refund')->nullable();
$table->string('status')->nullable(); $table->string('shop_status')->nullable(); $table->dateTime('created')->nullable();
$table->dateTime('confirm_date')->nullable(); $table->dateTime('local_created')->nullable();
$table->index(['as_id']); $table->index(['as_date', 'as_id']);
$table->index(['so_id']); $table->index(['outer_as_id']);
});
$schema->create('ops_jst_qmcust_refund_items', function (Blueprint $table): void {
$table->integer('asi_id'); $table->string('as_id'); $table->string('outer_oi_id')->nullable();
$table->string('shop_sku_id')->nullable(); $table->string('i_id')->nullable(); $table->string('sku_id')->nullable();
$table->integer('qty')->nullable(); $table->string('shop_amount')->nullable(); $table->string('type')->nullable();
$table->integer('r_qty')->nullable(); $table->index(['as_id', 'asi_id']);
});
}
private function supplement(int $id, string $date, string $shop): array
{
return ['as_id' => (string) $id, 'as_date' => $date, 'outer_as_id' => 'AS-Q-' . $id,
'o_id' => 9000 + $id, 'so_id' => 'SO-Q-' . $id, 'type' => '仅退款',
'shop_id' => $shop, 'shop_name' => '测试店', 'refund' => '12.34', 'status' => 'Confirmed'];
}
private function markReady(): void
{
foreach (array_keys($this->definitions()) as $key) {
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => $key,
'status' => 'succeeded',
'initial_completed_at' => '2026-08-15 00:00:00',
'last_succeeded_at' => '2026-08-21 12:00:00',
]);
}
}
/** @return array<string,mixed> */
private function refundInfo(int $id, int $asId, string $date, string $platform, string $shopName): array
{
return [
'Id' => $id, 'as_id' => $asId, 'as_date' => $date, 'outer_as_id' => 'AS-' . $asId,
'o_id' => 8900 + $id, 'so_id' => 'SO-' . $id, 'type' => '退款', 'node' => '完成',
'shop_type' => '自营', 'shop_name' => $shopName, 'shop_site' => $platform,
'refund' => 100, 'receive_refund' => 80, 'buyer_apply_refund' => 100,
'createTime' => $date, 'status' => '完成', 'shop_status' => '完成',
'created' => $date, 'confirm_date' => $date,
];
}
/** @return array<string,mixed> */
private function refundItem(int $id, int $asId, string $skuId, string $outerOrderItemId): array
{
return [
'Id' => $id, 'as_id' => $asId, 'outer_oi_id' => $outerOrderItemId,
'shop_sku_id' => 'SHOP-' . $skuId, 'i_id' => 'STYLE-' . $id, 'sku_id' => $skuId,
'qty' => 2, 'shop_amount' => 80, 'type' => '退货退款', 'r_qty' => 2,
];
}
}