diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index 09e57edc..304cd253 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -63,6 +63,9 @@ class ShippingReplicaSynchronizer /** @var array */ private array $seenSourceIds = []; + /** @var array 发货明细已占用的窗口内订单号 */ + private array $expressOrderIds = []; + /** @var list */ private array $dwsFailedDays = []; @@ -106,6 +109,7 @@ class ShippingReplicaSynchronizer $this->packagesRetainedForUnmatchedAmount = 0; $this->staleMirrorPackages = 0; $this->seenSourceIds = []; + $this->expressOrderIds = []; $this->dwsFailedDays = []; $now = now(); $fromS = $windowed ? $from->format('Y-m-d H:i:s') : null; @@ -187,6 +191,7 @@ class ShippingReplicaSynchronizer $this->replaceWindow = false; $this->dwsFailedDays = []; $this->seenSourceIds = []; + $this->expressOrderIds = []; gc_collect_cycles(); } } @@ -276,11 +281,16 @@ class ShippingReplicaSynchronizer if ($windowed) { $kept = []; foreach ($rows as $r) { + $jd = $r['jo_send'] ?? null; + $joTs = ($jd !== null && $jd !== '') ? strtotime((string) $jd) : null; + $oid = (string) ($r['inner_order_id'] ?? ''); + if ($oid !== '' && $joTs !== null && $joTs !== false && $joTs >= $fromTs && $joTs < $toTs) { + $this->expressOrderIds[$oid] = true; + } $st = $r['send_time'] ?? null; $ts = ($st !== null && $st !== '') ? strtotime((string) $st) : null; if ($ts === null) { - $jd = $r['jo_send'] ?? null; - $ts = ($jd !== null && $jd !== '') ? strtotime((string) $jd) : null; + $ts = $joTs; } if ($ts !== null && $ts >= $fromTs && $ts < $toTs) { $kept[] = $r; @@ -382,8 +392,7 @@ class ShippingReplicaSynchronizer $lastRid = null; for ($b = 0; $b <= $maxBatches; $b++) { - $where = "o.status = 'Sent' AND o.send_date >= ? AND o.send_date < ? " - . 'AND NOT EXISTS (SELECT 1 FROM rpt.jst_order_express_details e WHERE e.inner_order_id = o.o_id)'; + $where = "o.status = 'Sent' AND o.send_date >= ? AND o.send_date < ?"; $bind = [$fromS, $toS]; if ($lastRid !== null) { $where .= ' AND o.Id < ?'; @@ -398,6 +407,17 @@ class ShippingReplicaSynchronizer throw new RuntimeException('代发仓扫描达到批次上限,仍有未读取的上游数据。'); } + $cnt = count($rows); + $eligible = []; + foreach ($rows as $r) { + $lastRid = $r['rid']; + $oid = (string) ($r['o_id'] ?? ''); + if ($oid !== '' && ! isset($this->expressOrderIds[$oid])) { + $eligible[] = $r; + } + } + $rows = $eligible; + $oids = []; $soIds = []; $logs = []; @@ -414,7 +434,6 @@ class ShippingReplicaSynchronizer if ($lg !== '') { $logs[$lg] = true; } - $lastRid = $r['rid']; } [$byOid, $bySo] = $this->loadAllItems(array_keys($oids), array_keys($soIds), false); $dws = []; @@ -435,7 +454,6 @@ class ShippingReplicaSynchronizer $skus = $this->loadSkus($this->collectSkuIds($byOid, $bySo, $dws, $viewItems)); $freight = $this->skipFreight ? [] : $this->loadFreight(array_keys($soIds)); - $cnt = count($rows); foreach ($rows as $r) { $oid = (string) ($r['o_id'] ?? ''); if ($oid === '') { diff --git a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php index b9b2407c..059cbcbe 100644 --- a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -234,6 +234,86 @@ class ShippingReplicaSynchronizerTest extends TestCase ->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 LEFT JOIN')) { + 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 LEFT JOIN')) { + 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');