createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql, array $bindings) use (&$detailQueries, &$orderMetadataQueries, &$detailCalls): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { $detailQueries[] = $sql; return $detailCalls++ === 0 ? [['id' => 5000, 'inner_order_id' => 'order-1', 'send_time' => null, 'logistic_id' => '']] : []; } if (str_contains($sql, 'FROM rpt.jst_Orders om WHERE om.o_id IN')) { $orderMetadataQueries[] = [$sql, $bindings]; return [['o_id' => 'order-1', 'l_id' => 'order-tracking', 'send_date' => '2026-09-02 12:00:00']]; } if (str_contains($sql, 'FROM rpt.jst_Orders o WHERE o.status =')) { return []; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true, ); $this->assertNotEmpty($detailQueries); $this->assertStringNotContainsString('LEFT JOIN rpt.jst_Orders', $detailQueries[0]); $this->assertCount(1, $orderMetadataQueries); $this->assertSame(['order-1'], $orderMetadataQueries[0][1]); $this->assertSame(1, $result['processed']); $row = DB::connection('business_mirror')->table('jst_oms_shipping')->where('source_id', 5000)->sole(); $this->assertSame('order-tracking', $row->express_no); $this->assertSame('2026-09-02 12:00:00', $row->ship_time); } public function test_windowed_outbound_lookup_preloads_date_range_and_reuses_cache_without_in_queries(): void { $from = '2026-09-22 00:00:00'; $to = '2026-09-25 00:00:00'; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->expects($this->once()) ->method('select') ->with( $this->callback(fn (string $sql): bool => str_contains($sql, 'WHERE io_date >= ? AND io_date < ?') && ! str_contains($sql, 'l_id IN') && ! str_contains($sql, 'o_id IN')), [$from, $to], ) ->willReturn([ ['l_id' => 'logistic-1', 'o_id' => 'order-1', 'io_id' => 'outbound-1'], ['l_id' => 'logistic-2', 'o_id' => 'order-2', 'io_id' => 'outbound-2'], ]); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $synchronizer = new ShippingReplicaSynchronizer($factory); $preload = new \ReflectionMethod($synchronizer, 'preloadOutboundMap'); $preload->setAccessible(true); $preload->invoke($synchronizer, $from, $to); $lookup = new \ReflectionMethod($synchronizer, 'ioMapFor'); $lookup->setAccessible(true); $map = $lookup->invoke($synchronizer, true, ['logistic-1'], ['order-2']); $this->assertSame('outbound-1', $map['byL']['logistic-1']); $this->assertSame('outbound-2', $map['byO']['order-2']); } public function test_windowed_item_lookup_skips_unindexed_raw_so_id_fallback(): void { $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->expects($this->once()) ->method('select') ->with( $this->stringContains('WHERE o_id IN'), ['order-1'], ) ->willReturn([ [ 'o_id' => 'order-1', 'raw_so_id' => 'trade-1', 'sku_id' => 'sku-1', 'i_id' => 'item-1', 'shop_i_id' => 'shop-item-1', 'name' => '商品', 'qty' => 1, 'amount' => 10, 'send_warehouse' => '仓库', 'Id' => 1, ], ]); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $synchronizer = new ShippingReplicaSynchronizer($factory); $method = new \ReflectionMethod($synchronizer, 'loadAllItems'); $method->setAccessible(true); [$byOrder, $byTrade] = $method->invoke($synchronizer, ['order-1'], ['trade-1'], false); $this->assertArrayHasKey('order-1', $byOrder); $this->assertSame([], $byTrade); } public function test_windowed_scan_continues_past_an_old_page_to_find_later_shipments(): void { $pages = [ array_map(fn (int $id): array => ['id' => $id, 'send_time' => $id === 4500 ? '2026-09-02 12:00:00' : null], range(4500, 3001)), array_map(fn (int $id): array => ['id' => $id, 'send_time' => '2026-08-31 12:00:00'], range(3000, 1501)), [['id' => 1500, 'send_time' => '2026-09-03 12:00:00']], ]; $scanCalls = 0; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls, $pages): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return $pages[$scanCalls++] ?? []; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), ); $this->assertSame(3, $scanCalls); $this->assertSame(2, $result['processed']); $this->assertSame(2, $result['unique_packages']); $this->assertSame([1500, 4500], DB::connection('business_mirror')->table('jst_oms_shipping') ->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all()); } public function test_express_page_reconnects_once_after_transient_dblib_cursor_failure(): void { $firstPage = array_map(fn (int $id): array => [ 'id' => $id, 'send_time' => $id === 3000 ? '2026-09-02 12:00:00' : null, ], range(3000, 1501)); $lastPage = [['id' => 1500, 'send_time' => '2026-09-03 12:00:00']]; $scanCalls = 0; $bindings = []; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql, array $bind) use (&$scanCalls, &$bindings, $firstPage, $lastPage): array { if (! str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return []; } $scanCalls++; $bindings[] = $bind; if ($scanCalls === 2) { throw new PDOException('SQLSTATE[HY000]: General error: PDO_DBLIB: dbnextrow() returned FAIL'); } return $scanCalls === 1 ? $firstPage : $lastPage; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true, ); $this->assertSame(3, $scanCalls); $this->assertSame([[], [1501], [1501]], $bindings); $this->assertSame(2, $result['processed']); $this->assertSame(2, $result['unique_packages']); $this->assertSame([1500, 3000], DB::connection('business_mirror')->table('jst_oms_shipping') ->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all()); } public function test_non_transient_upstream_failure_is_not_retried(): void { $calls = 0; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql) use (&$calls): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { $calls++; throw new PDOException('SQLSTATE[HY000]: unrelated upstream error'); } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); try { (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true, ); $this->fail('Expected the upstream error to be propagated.'); } catch (PDOException $e) { $this->assertStringContainsString('unrelated upstream error', $e->getMessage()); } $this->assertSame(1, $calls); $this->assertSame('failed', DB::connection('business_mirror')->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade')->value('status')); } public function test_adaptive_server_timeout_reconnects_and_replays_the_same_page(): void { $page = array_map(fn (int $id): array => [ 'id' => $id, 'send_time' => $id === 1500 ? '2026-09-02 12:00:00' : null, ], range(1500, 1)); $scanCalls = 0; $bindings = []; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql, array $bind) use (&$scanCalls, &$bindings, $page): array { if (! str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return []; } $scanCalls++; $bindings[] = $bind; if ($scanCalls <= 2) { throw new PDOException('SQLSTATE[HY000]: General error: 20003 Adaptive Server connection timed out [20003] (severity 6)'); } return $scanCalls === 3 ? $page : []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true, ); $this->assertSame(4, $scanCalls); $this->assertSame([[], [], [], [1]], $bindings); $this->assertSame(1, $result['processed']); $this->assertSame([1500], DB::connection('business_mirror')->table('jst_oms_shipping') ->pluck('source_id')->map(fn ($id) => (int) $id)->all()); } public function test_window_result_distinguishes_duplicate_package_rows_from_unique_source_ids(): void { $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { $row = ['id' => 5000, 'send_time' => '2026-09-02 12:00:00']; return [$row, $row]; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true, ); $this->assertSame(2, $result['packages']); $this->assertSame(1, $result['unique_packages']); $this->assertSame(1, DB::connection('business_mirror')->table('jst_oms_shipping') ->distinct()->count('source_id')); } public function test_express_scan_fails_when_more_rows_remain_after_batch_limit(): void { DB::connection('business_mirror')->table('jst_oms_shipping')->insert([ 'source_id' => 3000, 'sku_source_id' => 3000, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'old-tracking', 'freight' => 18.75, ]); $pages = [ array_map(fn (int $id): array => ['id' => $id, 'send_time' => $id === 3000 ? '2026-09-02 12:00:00' : null], range(3000, 1501)), [['id' => 1500]], ]; $scanCalls = 0; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls, $pages): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return $pages[$scanCalls++] ?? []; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $synchronizer = new ShippingReplicaSynchronizer($factory); $this->setScanBatchLimit($synchronizer, 'maxExpressBatches', 1); try { $synchronizer->sync(Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true); $this->fail('Expected an incomplete express scan to fail.'); } catch (RuntimeException $e) { $this->assertStringContainsString('发货扫描达到批次上限', $e->getMessage()); } $this->assertSame(2, $scanCalls); $this->assertSame('failed', DB::connection('business_mirror')->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade')->value('status')); $this->assertSame('pending', DB::connection('business_mirror')->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade.freight.20260901000000-20260905000000')->value('status')); $original = DB::connection('business_mirror')->table('jst_oms_shipping')->where('source_id', 3000)->first(); $this->assertSame('old-tracking', $original->express_no); $this->assertEquals(18.75, $original->freight); } public function test_full_last_batch_succeeds_when_no_more_express_rows_exist(): void { $scanCalls = 0; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return $scanCalls++ === 0 ? array_map(fn (int $id): array => ['id' => $id], range(1500, 1)) : []; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $synchronizer = new ShippingReplicaSynchronizer($factory); $this->setScanBatchLimit($synchronizer, 'maxExpressBatches', 1); $result = $synchronizer->sync(Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05')); $this->assertSame(2, $scanCalls); $this->assertSame(0, $result['processed']); $this->assertSame('succeeded', DB::connection('business_mirror')->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade')->value('status')); } public function test_auto_ship_scan_fails_when_more_rows_remain_after_batch_limit(): void { $pages = [ array_map(fn (int $id): array => ['rid' => $id, 'o_id' => ''], range(1000, 601)), [['rid' => 600, 'o_id' => '']], ]; $scanCalls = 0; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls, $pages): array { if (str_contains($sql, 'FROM rpt.jst_Orders o WHERE')) { return $pages[$scanCalls++] ?? []; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $synchronizer = new ShippingReplicaSynchronizer($factory); $this->setScanBatchLimit($synchronizer, 'maxAutoShipBatches', 1); try { $synchronizer->sync(Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05')); $this->fail('Expected an incomplete auto-ship scan to fail.'); } catch (RuntimeException $e) { $this->assertStringContainsString('代发仓扫描达到批次上限', $e->getMessage()); } $this->assertSame(2, $scanCalls); $this->assertSame('failed', DB::connection('business_mirror')->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade')->value('status')); } public function test_auto_ship_excludes_every_order_with_express_detail_even_when_detail_is_outside_window_or_label_excluded(): void { $orderQueries = []; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql, array $bindings) use (&$orderQueries): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return [ ['id' => 1001, 'inner_order_id' => '123', 'jo_send' => '2026-09-02 13:00:00', 'send_time' => '2026-08-31 12:00:00'], ['id' => 1002, 'inner_order_id' => '456', 'jo_send' => '2026-09-02 13:00:00', 'send_time' => '2026-09-02 12:00:00', 'order_labels' => '线上发货'], ['id' => 1003, 'inner_order_id' => '789', 'jo_send' => '2026-09-02 13:00:00', 'send_time' => '2026-09-02 12:00:00'], ]; } if (str_contains($sql, 'FROM rpt.jst_Orders o WHERE')) { $orderQueries[] = [$sql, $bindings]; return array_map(fn (int $oid): array => [ 'rid' => $oid, 'o_id' => (string) $oid, 'send_date' => '2026-09-02 13:00:00', ], [789, 456, 321, 123]); } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true, ); $this->assertCount(1, $orderQueries); $this->assertStringNotContainsString('NOT EXISTS', $orderQueries[0][0]); $this->assertSame(['2026-09-01 00:00:00', '2026-09-05 00:00:00'], $orderQueries[0][1]); $this->assertSame(1, $result['processed']); $this->assertSame(1, $result['autoship']); $this->assertSame([1003, 1000000321], DB::connection('business_mirror')->table('jst_oms_shipping') ->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all()); } public function test_auto_ship_advances_raw_page_cursor_when_all_400_orders_have_express_details(): void { $orderBindings = []; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql, array $bindings) use (&$orderBindings): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return array_map(fn (int $oid): array => [ 'id' => $oid, 'inner_order_id' => (string) $oid, 'jo_send' => '2026-09-02 13:00:00', 'send_time' => '2026-08-31 12:00:00', ], range(1000, 601)); } if (str_contains($sql, 'FROM rpt.jst_Orders o WHERE')) { $orderBindings[] = $bindings; if (count($orderBindings) === 1) { return array_map(fn (int $oid): array => [ 'rid' => $oid, 'o_id' => (string) $oid, 'send_date' => '2026-09-02 13:00:00', ], range(1000, 601)); } return [['rid' => 600, 'o_id' => '500', 'send_date' => '2026-09-02 13:00:00']]; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true, ); $this->assertSame([ ['2026-09-01 00:00:00', '2026-09-05 00:00:00'], ['2026-09-01 00:00:00', '2026-09-05 00:00:00', 601], ], $orderBindings); $this->assertSame(0, $result['processed']); $this->assertSame(1, $result['autoship']); $this->assertSame([1000000500], DB::connection('business_mirror')->table('jst_oms_shipping') ->pluck('source_id')->map(fn ($id) => (int) $id)->all()); } public function test_skip_freight_preserves_existing_amounts_and_marks_new_amounts_incomplete(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ ['source_id' => 5000, 'sku_source_id' => 5000, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'existing-tracking', 'express_name2' => 'existing-express', 'freight' => 12.34, 'settle_freight' => 13.45, 'weight_kg' => 1.2, 'settle_weight_kg' => 1.3], ['source_id' => 6000, 'sku_source_id' => 6000, 'ship_time' => '2026-09-03 12:00:00', 'express_no' => 'weight-only-tracking', 'express_name2' => null, 'freight' => null, 'settle_freight' => null, 'weight_kg' => null, 'settle_weight_kg' => 4.5], ]); $scanCalls = 0; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls): array { if (str_contains($sql, 'fys_jstFreightCostDetail')) { $this->fail('Skip-freight mode must not query the freight view.'); } if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return $scanCalls++ === 0 ? [ ['id' => 6000, 'send_time' => '2026-09-03 12:00:00', 'weight' => 2, 'logistic_id' => 'new-weight-tracking'], ['id' => 5000, 'send_time' => '2026-09-02 12:00:00', 'weight' => 2, 'logistic_id' => 'new-tracking'], ['id' => 4000, 'send_time' => '2026-09-03 12:00:00', 'weight' => 2], ] : []; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true, ); $this->assertSame(3, $result['processed']); $this->assertSame(3, $result['packages']); $this->assertSame(3, $result['packages_without_items']); $this->assertTrue($result['freight_incomplete']); $this->assertSame('succeeded', $db->table('jst_sync_state')->where('dataset_key', 'jst.sctrade')->value('status')); $this->assertSame('pending', $db->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade.freight.20260901000000-20260905000000')->value('status')); $this->assertSame('partial', (new ShippingOrderRepository())->syncStatus()['code']); $existing = $db->table('jst_oms_shipping')->where('source_id', 5000)->first(); $this->assertSame('existing-tracking', $existing->express_no); $this->assertSame('existing-express', $existing->express_name2); $this->assertEquals(12.34, $existing->freight); $this->assertEquals(13.45, $existing->settle_freight); $this->assertEquals(1.2, $existing->weight_kg); $this->assertEquals(1.3, $existing->settle_weight_kg); $weightOnly = $db->table('jst_oms_shipping')->where('source_id', 6000)->first(); $this->assertSame('weight-only-tracking', $weightOnly->express_no); $this->assertNull($weightOnly->freight); $this->assertEquals(4.5, $weightOnly->settle_weight_kg); $new = $db->table('jst_oms_shipping')->where('source_id', 4000)->first(); $this->assertNull($new->freight); $this->assertNull($new->settle_freight); $this->assertNull($new->weight_kg); $this->assertNull($new->settle_weight_kg); $this->assertEquals(2, $new->actual_weight); (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-04'), Carbon::parse('2026-09-05'), ); $this->assertSame('partial', (new ShippingOrderRepository())->syncStatus()['code']); (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), ); $this->assertSame('pending', $db->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade.freight.20260901000000-20260905000000')->value('status')); $this->assertSame('partial', (new ShippingOrderRepository())->syncStatus()['code']); } public function test_normal_window_preserves_old_charges_when_freight_is_missing_but_uses_new_cost(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ ['source_id' => 5000, 'sku_source_id' => 5000, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'old-5000', 'freight' => 12.34, 'settle_freight' => 13.45, 'weight_kg' => 1.2, 'settle_weight_kg' => 1.3], ['source_id' => 6000, 'sku_source_id' => 6000, 'ship_time' => '2026-09-03 12:00:00', 'express_no' => 'old-6000', 'freight' => 15.00, 'settle_freight' => 15.00, 'weight_kg' => 1.5, 'settle_weight_kg' => 1.5], ]); $db->table('jst_sync_state')->insert([ 'dataset_key' => 'jst.sctrade.freight.20260901000000-20260905000000', 'status' => 'pending', ]); $scanCalls = 0; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return $scanCalls++ === 0 ? [ ['id' => 6000, 'so_id' => 'so-6000', 'logistic_id' => 'new-6000', 'send_time' => '2026-09-03 12:00:00', 'weight' => 2], ['id' => 5000, 'so_id' => 'so-5000', 'logistic_id' => 'new-5000', 'send_time' => '2026-09-02 12:00:00', 'weight' => 2], ] : []; } if (str_contains($sql, 'fys_jstFreightCostDetail')) { return [['so_id' => 'so-6000', 'expressNo' => 'new-6000', 'cost' => 7.50, 'w' => 2.5, 'name' => '快递公司']]; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), ); $this->assertSame(0, $result['stale_mirror_packages']); $missing = $db->table('jst_oms_shipping')->where('source_id', 5000)->first(); $this->assertSame('new-5000', $missing->express_no); $this->assertEquals(12.34, $missing->freight); $this->assertEquals(13.45, $missing->settle_freight); $this->assertEquals(1.2, $missing->weight_kg); $this->assertEquals(1.3, $missing->settle_weight_kg); $updated = $db->table('jst_oms_shipping')->where('source_id', 6000)->first(); $this->assertEquals(7.50, $updated->freight); $this->assertEquals(7.50, $updated->settle_freight); $this->assertEquals(2.5, $updated->settle_weight_kg); $this->assertSame('succeeded', $db->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade.freight.20260901000000-20260905000000')->value('status')); } public function test_window_replacement_preserves_unreconstructed_package_and_matching_item_fields(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ 'source_id' => 5000, 'sku_source_id' => 101, 'ship_time' => '2026-09-02 12:00:00', 'package_no' => 'old-package', 'download_time' => '2026-09-02 13:00:00', 'freight_rate' => 1.25, 'freight_formula' => 'legacy-formula', 'settle_volume_cm3' => 100, 'recon_weight_kg' => 1.5, 'recon_volume_cm3' => 105, 'finished_barcode' => 'finished-101', 'finished_name' => '成品甲', 'finished_supplier' => '供应商甲', ]); $db->table('jst_oms_shipping')->insert([ 'source_id' => 5000, 'sku_source_id' => 102, 'ship_time' => '2026-09-02 12:00:00', 'buyer_nick' => 'old-buyer', 'seller_memo' => 'old-seller-memo', 'front_no' => 'old-front-no', 'receiver_name' => 'old-recipient', 'receiver_mobile' => 'old-mobile', 'receiver_phone' => 'old-phone', 'finished_barcode' => 'finished-102', 'finished_name' => '成品乙', 'finished_supplier' => '供应商乙', ]); $scanCalls = 0; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return $scanCalls++ === 0 ? [[ 'id' => 5000, 'inner_order_id' => 'order-1', 'send_time' => '2026-09-02 12:00:00', ]] : []; } if (str_contains($sql, 'FROM rpt.jst_OrderItem WHERE o_id IN')) { return [ ['o_id' => 'order-1', 'Id' => 101, 'name' => '新商品甲', 'qty' => 1], ['o_id' => 'order-1', 'Id' => 103, 'name' => '新商品丙', 'qty' => 1], ]; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), ); $this->assertSame([101, 103], $db->table('jst_oms_shipping')->where('source_id', 5000) ->orderBy('sku_source_id')->pluck('sku_source_id')->map(fn ($id) => (int) $id)->all()); $first = $db->table('jst_oms_shipping')->where('source_id', 5000)->where('sku_source_id', 101)->first(); $this->assertSame('old-buyer', $first->buyer_nick); $this->assertSame('old-seller-memo', $first->seller_memo); $this->assertSame('old-recipient', $first->receiver_name); $this->assertSame('old-mobile', $first->receiver_mobile); $this->assertSame('old-phone', $first->receiver_phone); $this->assertSame('old-package', $first->package_no); $this->assertSame('2026-09-02 13:00:00', $first->download_time); $this->assertEquals(1.25, $first->freight_rate); $this->assertSame('legacy-formula', $first->freight_formula); $this->assertEquals(100, $first->settle_volume_cm3); $this->assertEquals(1.5, $first->recon_weight_kg); $this->assertEquals(105, $first->recon_volume_cm3); $this->assertSame('old-front-no', $first->front_no); $this->assertSame('finished-101', $first->finished_barcode); $this->assertSame('成品甲', $first->finished_name); $this->assertSame('供应商甲', $first->finished_supplier); $second = $db->table('jst_oms_shipping')->where('source_id', 5000)->where('sku_source_id', 103)->first(); $this->assertNull($second->seller_memo); $this->assertNull($second->settle_volume_cm3); $this->assertNull($second->finished_barcode); $this->assertNull($second->finished_name); $this->assertNull($second->finished_supplier); } public function test_missing_new_items_preserves_old_product_rows_and_marks_items_pending(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ ['source_id' => 5000, 'sku_source_id' => 101, 'ship_time' => '2026-09-02 12:00:00', 'barcode' => 'sku-101', 'product_name' => '旧商品甲', 'qty' => 1, 'amount' => 10.50, 'freight' => 5.25], ['source_id' => 5000, 'sku_source_id' => 102, 'ship_time' => '2026-09-02 12:00:00', 'barcode' => 'sku-102', 'product_name' => '旧商品乙', 'qty' => 2, 'amount' => 20.75, 'freight' => null], ]); $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql, array $bindings): array { if (str_contains($sql, 'rpt.dws_sn_sales_full_info_di') && $bindings === ['2026-09-02']) { throw new RuntimeException('DWS read timed out'); } if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return [['id' => 5000, 'inner_order_id' => 'order-1', 'send_time' => '2026-09-02 12:00:00']]; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true, ); $this->assertSame(1, $result['packages_without_items']); $this->assertSame(1, $result['packages_retained_for_missing_items']); $this->assertContains('2026-09-02', $result['dws_failed_days']); $this->assertSame('pending', $db->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade.items.20260901000000-20260905000000')->value('status')); $this->assertSame('partial', (new ShippingOrderRepository())->syncStatus()['code']); $rows = $db->table('jst_oms_shipping')->where('source_id', 5000)->orderBy('sku_source_id')->get(); $this->assertCount(2, $rows); $this->assertSame(['sku-101', 'sku-102'], $rows->pluck('barcode')->all()); $this->assertSame(['旧商品甲', '旧商品乙'], $rows->pluck('product_name')->all()); $this->assertEquals(10.50, $rows[0]->amount); $this->assertEquals(20.75, $rows[1]->amount); $this->assertEquals(5.25, $rows[0]->freight); } public function test_view_item_with_null_amount_preserves_matching_synthetic_sku_amount(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ 'source_id' => 5000, 'sku_source_id' => 500000, 'ship_time' => '2026-09-02 12:00:00', 'barcode' => 'sku-101', 'product_name' => '旧商品', 'amount' => 10.50, ]); $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return [['id' => 5000, 'inner_order_id' => 'order-1', 'send_time' => '2026-09-02 12:00:00']]; } if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo WHERE io_date')) { return [['o_id' => 'order-1', 'io_id' => 'outbound-1']]; } if (str_contains($sql, 'FROM rpt.Jst_SendData_View_extension')) { return [['io_id' => 'outbound-1', 'sku_ids' => 'sku-101', 'qty' => 1, 'type' => 0, 'sku_name' => '实发商品', 'volume' => null]]; } if (str_contains($sql, 'FROM rpt.jst_OrderItem WHERE o_id IN')) { return [['o_id' => 'order-1', 'Id' => 101, 'sku_id' => 'other-sku', 'name' => '订单商品', 'qty' => 1, 'amount' => 999]]; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), ); $this->assertSame(0, $result['packages_retained_for_unmatched_amount']); $row = $db->table('jst_oms_shipping')->where('source_id', 5000)->sole(); $this->assertSame(500000, (int) $row->sku_source_id); $this->assertSame('实发商品', $row->product_name); $this->assertEquals(10.50, $row->amount); } public function test_view_item_with_unmatched_synthetic_sku_retains_old_package_and_marks_items_pending(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ ['source_id' => 5000, 'sku_source_id' => 500000, 'ship_time' => '2026-09-02 12:00:00', 'barcode' => null, 'product_name' => '旧商品甲', 'amount' => 10.50], ['source_id' => 6000, 'sku_source_id' => 600000, 'ship_time' => '2026-09-02 13:00:00', 'barcode' => 'old-sku', 'product_name' => '旧商品乙', 'amount' => 20.75], ]); $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return [ ['id' => 5000, 'inner_order_id' => 'order-1', 'send_time' => '2026-09-02 12:00:00'], ['id' => 6000, 'inner_order_id' => 'order-2', 'send_time' => '2026-09-02 13:00:00'], ]; } if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo WHERE io_date')) { return [ ['o_id' => 'order-1', 'io_id' => 'outbound-1'], ['o_id' => 'order-2', 'io_id' => 'outbound-2'], ]; } if (str_contains($sql, 'FROM rpt.Jst_SendData_View_extension')) { return [ ['io_id' => 'outbound-1', 'sku_ids' => 'new-sku', 'qty' => 1, 'type' => 0, 'sku_name' => '新商品甲', 'volume' => null], ['io_id' => 'outbound-2', 'sku_ids' => 'new-sku', 'qty' => 1, 'type' => 0, 'sku_name' => '新商品乙', 'volume' => null], ]; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), ); $this->assertSame(2, $result['packages_retained_for_unmatched_amount']); $this->assertSame('pending', $db->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade.items.20260901000000-20260905000000')->value('status')); $this->assertSame('partial', (new ShippingOrderRepository())->syncStatus()['code']); $rows = $db->table('jst_oms_shipping')->orderBy('source_id')->get(); $this->assertSame(['旧商品甲', '旧商品乙'], $rows->pluck('product_name')->all()); $this->assertEquals(10.50, $rows[0]->amount); $this->assertEquals(20.75, $rows[1]->amount); } public function test_explicit_zero_item_amount_replaces_old_amount(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ 'source_id' => 5000, 'sku_source_id' => 101, 'ship_time' => '2026-09-02 12:00:00', 'barcode' => 'sku-101', 'amount' => 10.50, ]); $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return [['id' => 5000, 'inner_order_id' => 'order-1', 'send_time' => '2026-09-02 12:00:00']]; } if (str_contains($sql, 'FROM rpt.jst_OrderItem WHERE o_id IN')) { return [['o_id' => 'order-1', 'Id' => 101, 'sku_id' => 'sku-101', 'name' => '新商品', 'qty' => 1, 'amount' => 0]]; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), ); $this->assertSame(0, $result['packages_retained_for_unmatched_amount']); $row = $db->table('jst_oms_shipping')->where('source_id', 5000)->sole(); $this->assertSame('新商品', $row->product_name); $this->assertEquals(0, $row->amount); } public function test_shipping_command_reports_express_and_autoship_counts_separately(): void { $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return [['id' => 5000, 'send_time' => '2026-09-02 12:00:00']]; } if (str_contains($sql, 'FROM rpt.jst_Orders o WHERE')) { return [['rid' => 100, 'o_id' => '123', 'send_date' => '2026-09-02 13:00:00']]; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $this->app->instance(UpstreamReadOnlyQueryFactory::class, $factory); $this->artisan('jushuitan:sync-shipping', [ '--from' => '2026-09-01', '--to' => '2026-09-05', '--skip-freight' => true, ])->expectsOutputToContain('唯一包裹源键 2 个,包裹记录处理 2 次;发货明细 1 条,代发仓 1 条,合计处理 2 条明细') ->assertExitCode(0); } public function test_normal_window_failure_preserves_unprocessed_old_packages(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ ['source_id' => 3000, 'sku_source_id' => 3000, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'processed-old', 'freight' => 11.11], ['source_id' => 1000, 'sku_source_id' => 1000, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'unprocessed-old', 'freight' => 22.22], ]); $scanCalls = 0; $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql) use (&$scanCalls): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { if ($scanCalls++ > 0) { throw new RuntimeException('upstream page failed'); } return array_map(fn (int $id): array => [ 'id' => $id, 'send_time' => $id >= 2701 ? '2026-09-02 12:00:00' : '2026-08-31 12:00:00', ], range(3000, 1501)); } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); try { (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), ); $this->fail('Expected the second upstream page to fail.'); } catch (RuntimeException $e) { $this->assertSame('upstream page failed', $e->getMessage()); } $this->assertSame(2, $scanCalls); $this->assertSame('failed', $db->table('jst_sync_state')->where('dataset_key', 'jst.sctrade')->value('status')); $processed = $db->table('jst_oms_shipping')->where('source_id', 3000)->first(); $this->assertEquals(11.11, $processed->freight); $unprocessed = $db->table('jst_oms_shipping')->where('source_id', 1000)->first(); $this->assertSame('unprocessed-old', $unprocessed->express_no); $this->assertEquals(22.22, $unprocessed->freight); $this->assertSame(301, $db->table('jst_oms_shipping')->count()); } public function test_at_prefix_dedup_requires_same_trade_and_same_window(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ ['source_id' => 1, 'sku_source_id' => 1, 'ship_time' => '2026-09-02 12:00:00', 'trade_no' => 'same-trade', 'express_no' => 'clean-1'], ['source_id' => 2, 'sku_source_id' => 2, 'ship_time' => '2026-09-02 12:00:00', 'trade_no' => 'same-trade', 'express_no' => '@clean-1'], ['source_id' => 3, 'sku_source_id' => 3, 'ship_time' => '2026-09-02 12:00:00', 'trade_no' => 'other-trade', 'express_no' => '@clean-1'], ['source_id' => 4, 'sku_source_id' => 4, 'ship_time' => '2026-09-08 12:00:00', 'trade_no' => 'outside-trade', 'express_no' => 'clean-2'], ['source_id' => 5, 'sku_source_id' => 5, 'ship_time' => '2026-09-02 12:00:00', 'trade_no' => 'outside-trade', 'express_no' => '@clean-2'], ['source_id' => 6, 'sku_source_id' => 6, 'ship_time' => '2026-09-03 12:00:00', 'trade_no' => 'same-trade', 'express_no' => '@clean-1'], ]); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->willReturn($this->createMock(UpstreamReadOnlyQuery::class)); $removed = (new ShippingReplicaSynchronizer($factory))->dedupAtPrefix( '2026-09-01 00:00:00', '2026-09-05 00:00:00', ); $this->assertSame(1, $removed); $this->assertSame([1, 3, 4, 5, 6], $db->table('jst_oms_shipping') ->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all()); $this->assertSame(0, (new ShippingReplicaSynchronizer($factory))->dedupAtPrefix()); } public function test_non_window_fast_sync_still_ignores_existing_package(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ 'source_id' => 5000, 'sku_source_id' => 5000, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'existing-tracking', 'freight' => 12.34, ]); $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql): array { if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return [[ 'id' => 5000, 'send_time' => '2026-09-02 12:00:00', 'logistic_id' => 'new-tracking', ]]; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync(); $this->assertSame(1, $result['processed']); $this->assertSame(1, $db->table('jst_oms_shipping')->count()); $existing = $db->table('jst_oms_shipping')->where('source_id', 5000)->first(); $this->assertSame('existing-tracking', $existing->express_no); $this->assertEquals(12.34, $existing->freight); } public function test_failed_dws_preload_is_reported_separately_from_package_count(): void { $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturnCallback(function (string $sql, array $bindings): array { if (str_contains($sql, 'rpt.dws_sn_sales_full_info_di') && $bindings === ['2026-08-31']) { throw new RuntimeException('read timed out'); } if (str_contains($sql, 'FROM rpt.jst_order_express_details e')) { return [['id' => 1234, 'send_time' => '2026-09-02 12:00:00']]; } return []; }); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), ); $this->assertSame(1, $result['packages']); $this->assertSame(1, $result['packages_without_items']); $this->assertSame(['2026-08-31'], $result['dws_failed_days']); $this->assertSame('pending', DB::connection('business_mirror')->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade.items.20260901000000-20260905000000')->value('status')); } public function test_skip_freight_rolls_back_old_package_when_replacement_insert_conflicts(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ 'source_id' => 5000, 'sku_source_id' => 5000, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'old-tracking', 'freight' => 12.34, ]); $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->willReturn($upstream); $synchronizer = new ShippingReplicaSynchronizer($factory); $skipFreight = new \ReflectionProperty($synchronizer, 'skipFreight'); $skipFreight->setAccessible(true); $skipFreight->setValue($synchronizer, true); $replacements = new \ReflectionProperty($synchronizer, 'replacementPackages'); $replacements->setAccessible(true); $newRow = [ 'source_id' => 5000, 'sku_source_id' => 123, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'new-tracking', 'express_name2' => null, 'freight' => null, 'settle_freight' => null, 'weight_kg' => null, 'settle_weight_kg' => null, 'amount' => null, 'receiver_name' => null, 'receiver_mobile' => null, 'receiver_phone' => null, ]; $replacements->setValue($synchronizer, [5000 => [$newRow, $newRow]]); $flush = new \ReflectionMethod($synchronizer, 'flushReplacements'); $flush->setAccessible(true); try { $flush->invoke($synchronizer); $this->fail('Expected a duplicate replacement row to roll back the package transaction.'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('UNIQUE constraint failed', $e->getMessage()); } $rows = $db->table('jst_oms_shipping')->where('source_id', 5000)->get(); $this->assertCount(1, $rows); $this->assertSame(5000, (int) $rows[0]->sku_source_id); $this->assertSame('old-tracking', $rows[0]->express_no); $this->assertEquals(12.34, $rows[0]->freight); } public function test_skip_freight_keeps_unseen_mirror_package_and_marks_window_partial(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ 'source_id' => 7000, 'sku_source_id' => 7000, 'ship_time' => '2026-09-02 12:00:00', 'express_no' => 'unseen-tracking', 'freight' => 22.50, ]); $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->method('select')->willReturn([]); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->method('forSource')->with('operations_replica')->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync( Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true, ); $this->assertSame(1, $result['stale_mirror_packages']); $this->assertSame('partial', $db->table('jst_sync_state')->where('dataset_key', 'jst.sctrade')->value('status')); $this->assertStringContainsString('源键核对', $db->table('jst_sync_state') ->where('dataset_key', 'jst.sctrade')->value('last_error')); $old = $db->table('jst_oms_shipping')->where('source_id', 7000)->first(); $this->assertSame('unseen-tracking', $old->express_no); $this->assertEquals(22.50, $old->freight); } public function test_partial_sync_status_uses_business_warning_instead_of_raw_error(): void { DB::connection('business_mirror')->table('jst_sync_state')->insert([ 'dataset_key' => 'jst.sctrade', 'status' => 'partial', 'last_error' => 'SQLSTATE[42S02]: SECRET_MIRROR_TABLE does not exist', ]); $status = (new ShippingOrderRepository())->syncStatus(); $this->assertSame('partial', $status['code']); $this->assertStringContainsString('源键核对', $status['warning']); $this->assertStringNotContainsString('SQLSTATE', $status['warning']); $this->assertStringNotContainsString('SECRET_MIRROR_TABLE', $status['warning']); } public function test_sync_removes_only_the_known_demo_source_ids(): void { $db = DB::connection('business_mirror'); $db->table('jst_oms_shipping')->insert([ ['source_id' => 9000001, 'sku_source_id' => 9000001], ['source_id' => 9000006, 'sku_source_id' => 9000006], ['source_id' => 9000007, 'sku_source_id' => 9000007], ['source_id' => 999999999, 'sku_source_id' => 999999999], ]); $upstream = $this->createMock(UpstreamReadOnlyQuery::class); $upstream->expects($this->once())->method('select')->willReturn([]); $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); $factory->expects($this->once()) ->method('forSource') ->with('operations_replica') ->willReturn($upstream); $result = (new ShippingReplicaSynchronizer($factory))->sync(); $this->assertSame(['processed' => 0, 'autoship' => 0], $result); $this->assertSame( [9000007, 999999999], $db->table('jst_oms_shipping')->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all(), ); } private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void { $reflection = new \ReflectionProperty($synchronizer, $property); $reflection->setAccessible(true); $reflection->setValue($synchronizer, $limit); } }