[ '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> */ 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 */ 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 */ 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 */ 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, ]; } }