CaiWuXiTong/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php

1184 lines
60 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\Jushuitan\ShippingReplicaSynchronizer;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
use PDOException;
use RuntimeException;
use Tests\TestCase;
class ShippingReplicaSynchronizerTest extends TestCase
{
public function test_windowed_express_scan_maps_order_metadata_without_joining_orders_per_page(): void
{
$detailQueries = [];
$orderMetadataQueries = [];
$detailCalls = 0;
$upstream = $this->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(),
);
}
public function test_outbound_fallback_flushes_replacements_and_preserves_freight_when_skipped(): void
{
$db = DB::connection('business_mirror');
$db->table('jst_oms_shipping')->insert([
'source_id' => 2000000420, 'sku_source_id' => 2000000420,
'express_no' => 'outbound-420', 'trade_no' => 'trade-420',
'ship_time' => '2026-09-02 12:00:00', 'freight' => 18.50, 'settle_weight_kg' => 7,
]);
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
$upstream->method('select')->willReturnCallback(function (string $sql): array {
if (str_contains($sql, 'MIN(Id) AS lo')) {
return [['lo' => 1, 'hi' => 1]];
}
if (str_contains($sql, 'WHERE Id >= ?')) {
return [['io_id' => '420', 'so_id' => 'trade-420', 'l_id' => 'outbound-420', 'io_date' => '2026-09-02 12:00:00']];
}
if (str_contains($sql, 'SELECT io_id, sku_ids')) {
return [['io_id' => '420', 'sku_ids' => 'sku-420', 'qty' => 2, 'type' => '0', 'volume' => 100, 'l' => 10, 'w' => 5, 'h' => 2]];
}
$this->assertStringNotContainsString('fys_jstFreightCost', $sql);
return [];
});
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->willReturn($upstream);
$result = (new ShippingReplicaSynchronizer($factory))->sync(
Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true,
);
$this->assertSame(1, $result['outbound']);
$row = $db->table('jst_oms_shipping')->where('source_id', 2000000420)->sole();
$this->assertSame('sku-420', $row->barcode);
$this->assertEquals(2, $row->qty);
$this->assertEquals(18.50, $row->freight);
$this->assertEquals(7, $row->settle_weight_kg);
}
public function test_outbound_mapping_keeps_shared_tracking_orders_distinct_and_ignores_cancelled(): void
{
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->willReturn($upstream);
$sync = new ShippingReplicaSynchronizer($factory);
$map = (new \ReflectionMethod($sync, 'mapOutboundRows'))->invoke($sync, [
['io_id' => '10', 'o_id' => 'A', 'l_id' => '@shared', 'io_date' => '2026-09-02'],
['io_id' => '20', 'o_id' => 'B', 'l_id' => 'shared', 'io_date' => '2026-09-03'],
['io_id' => '30', 'o_id' => 'A', 'l_id' => 'shared', 'io_date' => '2026-09-04', 'status' => 'Cancelled'],
]);
$method = new \ReflectionMethod($sync, 'ioFor');
$this->assertSame('10', $method->invoke($sync, $map, 'shared', 'A'));
$this->assertSame('20', $method->invoke($sync, $map, '@shared', 'B'));
$this->assertArrayNotHasKey(30, $map['ioL']);
}
public function test_tracking_replacement_requires_one_unique_pair(): void
{
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
$upstream->method('select')->willReturn([]);
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->willReturn($upstream);
$sync = new ShippingReplicaSynchronizer($factory);
$method = new \ReflectionMethod($sync, 'pairChangedTracking');
$rows = [['id' => 1, 'inner_order_id' => 'A', 'logistic_id' => 'old']];
$this->assertSame([], $method->invoke($sync, $rows, ['byL' => [], 'byOAll' => ['A' => ['10' => 'new-a', '20' => 'new-b']]]));
$this->assertSame(['1' => ['io' => '10', 'l_id' => 'new-a', 'old' => 'old']],
$method->invoke($sync, $rows, ['byL' => [], 'byOAll' => ['A' => ['10' => 'new-a']]]));
}
public function test_cloud_order_label_is_included_and_volume_rules_are_bounded(): void
{
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->willReturn($this->createMock(UpstreamReadOnlyQuery::class));
$sync = new ShippingReplicaSynchronizer($factory);
$label = new \ReflectionMethod($sync, 'excludedByLabel');
$this->assertTrue($label->invoke($sync, '线上发货'));
$this->assertFalse($label->invoke($sync, '线上发货,云仓订单'));
$volume = new \ReflectionMethod($sync, 'detailVolume');
$this->assertSame(120.0, $volume->invoke($sync, 10, 3, 2, 2.0, '普通仓', '中通速递'));
$this->assertSame(60.0, $volume->invoke($sync, 10, 3, 2, 2.0, '常州简尚家居有限公司', '自送'));
$this->assertNull($volume->invoke($sync, 0, 3, 2, 2.0, '普通仓', '中通速递'));
$this->assertSame(0.0, $volume->invoke($sync, 10, 3, 2, 2.0, '普通仓', '中通礼品单'));
}
private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void
{
$reflection = new \ReflectionProperty($synchronizer, $property);
$reflection->setAccessible(true);
$reflection->setValue($synchronizer, $limit);
}
}