diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index edcc74ae..8ae617f3 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -43,6 +43,10 @@ class ShippingReplicaSynchronizer 'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3', 'front_no', ]; private const PRESERVED_CONTACT_FIELDS = ['receiver_name', 'receiver_mobile', 'receiver_phone']; + private const REFRESHED_PACKAGE_FIELDS = [ + 'trade_no', 'express_name1', 'warehouse_name', 'shop_name', 'platform', 'pay_time', + 'receiver_state', 'receiver_city', 'receiver_district', + ]; private const PRESERVED_ITEM_FIELDS = ['finished_barcode', 'finished_name', 'finished_supplier']; private const VIEW = 'rpt.Jst_SendData_View_extension'; @@ -68,6 +72,7 @@ class ShippingReplicaSynchronizer private int $packagesRetainedForMissingItems = 0; private int $packagesRetainedForUnmatchedAmount = 0; private int $staleMirrorPackages = 0; + private int $supersededRemoved = 0; /** @var array */ private array $seenSourceIds = []; @@ -174,6 +179,7 @@ class ShippingReplicaSynchronizer $this->packagesRetainedForMissingItems = 0; $this->packagesRetainedForUnmatchedAmount = 0; $this->staleMirrorPackages = 0; + $this->supersededRemoved = 0; $this->seenSourceIds = []; $this->expressOrderIds = []; $this->dwsFailedDays = []; @@ -261,6 +267,7 @@ class ShippingReplicaSynchronizer $result['packages_retained_for_unmatched_amount'] = $this->packagesRetainedForUnmatchedAmount; $result['dws_failed_days'] = $this->dwsFailedDays; $result['stale_mirror_packages'] = $this->staleMirrorPackages; + $result['superseded_packages_removed'] = $this->supersededRemoved; $result['tracking_pairing_skipped_pages'] = $this->pairingSkipped; } if ($skipFreight) { @@ -2177,9 +2184,11 @@ class ShippingReplicaSynchronizer $rows = []; $replaceIds = []; + $refresh = []; foreach ($packages as $id => $packageRows) { if (! ($hasItems[$id] ?? false) && ($oldHasItems[$id] ?? false)) { $this->packagesRetainedForMissingItems++; + $refresh[$id] = $packageRows[0] ?? []; continue; } $unmatchedOldAmounts = $oldAmountSkuIds[$id] ?? []; @@ -2216,6 +2225,7 @@ class ShippingReplicaSynchronizer } if ($unsafeAmount) { $this->packagesRetainedForUnmatchedAmount++; + $refresh[$id] = $packageRows[0] ?? []; continue; } $replaceIds[] = $id; @@ -2245,6 +2255,7 @@ class ShippingReplicaSynchronizer } array_push($rows, ...$packageRows); } + $this->refreshRetainedPackages($refresh); if ($replaceIds === []) { return; } @@ -2347,21 +2358,55 @@ class ShippingReplicaSynchronizer return $out; } + /** + * 窗口内本次未扫到的旧包裹:若其快递单号已由本次其它包裹(新来源键)吐出,说明是被取代的旧记录 + * (如凌晨同步时发货明细未到、先按自动发货仓/出库单兜底入库,之后发货明细到齐换成发货明细来源),删除以免重复和残留空白; + * 其余仍保留并计数,需按源键核对。 + */ private function countUnseenMirrorPackages(string $fromS, string $toS): int { $unseen = 0; - $ids = $this->db->table('jst_oms_shipping') + $superseded = []; + $rows = $this->db->table('jst_oms_shipping') ->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS) - ->distinct()->pluck('source_id'); - foreach ($ids as $id) { - if (! isset($this->seenSourceIds[(int) $id])) { - $unseen++; + ->selectRaw('source_id, MAX(express_no) AS express_no')->groupBy('source_id')->get(); + foreach ($rows as $row) { + $id = (int) $row->source_id; + if (isset($this->seenSourceIds[$id])) { + continue; } + $tk = ltrim(trim((string) ($row->express_no ?? '')), '@'); + if ($tk !== '' && isset($this->emittedTrack[$tk])) { + $superseded[] = $id; + continue; + } + $unseen++; } + foreach (array_chunk($superseded, 500) as $chunk) { + $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk)->delete(); + } + $this->supersededRemoved = count($superseded); return $unseen; } + /** 因商品/金额无法安全替换而保留的旧包裹:包裹级字段(省市区、快递、仓库、店铺等)仍按本次上游刷新,商品与金额不动。 */ + private function refreshRetainedPackages(array $refresh): void + { + foreach ($refresh as $id => $new) { + $set = []; + foreach (self::REFRESHED_PACKAGE_FIELDS as $field) { + $v = $new[$field] ?? null; + if ($v !== null && trim((string) $v) !== '') { + $set[$field] = $v; + } + } + if ($set !== []) { + $this->db->table('jst_oms_shipping')->where('source_id', (int) $id)->update($set); + } + } + } + private function withPreservedFreight(array $payload, array $previous): array { $payload = $this->withPreservedCharges($payload, $previous); diff --git a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php index 85da9083..3a3182df 100644 --- a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -1315,6 +1315,65 @@ class ShippingReplicaSynchronizerTest extends TestCase $this->assertSame('15170678', $pending->getValue($sync)[2]); } + public function test_retained_package_refreshes_address_and_superseded_fallback_package_is_removed(): 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', 'express_no' => 'YT5', + 'barcode' => null, 'product_name' => '旧商品甲', 'amount' => 10.50, 'receiver_state' => null], + // 凌晨先按自动发货仓兜底入库的旧包裹,之后发货明细到齐由 6000 吐出同一单号 + ['source_id' => 1000000777, 'sku_source_id' => 1000000777, 'ship_time' => '2026-09-02 13:00:00', 'express_no' => 'YT6', + 'barcode' => null, 'product_name' => null, 'amount' => null, 'receiver_state' => null], + ['source_id' => 1000000888, 'sku_source_id' => 1000000888, 'ship_time' => '2026-09-02 14:00:00', 'express_no' => 'ZZ9', + 'barcode' => null, 'product_name' => null, 'amount' => null, 'receiver_state' => null], + ]); + $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', 'logistic_id' => 'YT5', 'send_time' => '2026-09-02 12:00:00', + 'receiver_address' => '河南省-郑州市-金水区'], + ['id' => 6000, 'inner_order_id' => 'order-2', 'logistic_id' => 'YT6', 'send_time' => '2026-09-02 13:00:00', + 'receiver_address' => '江苏省-常州市-武进区'], + ]; + } + if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo WHERE io_date')) { + return [ + ['o_id' => 'order-1', 'io_id' => 'outbound-1', 'l_id' => 'YT5'], + ['o_id' => 'order-2', 'io_id' => 'outbound-2', 'l_id' => 'YT6'], + ]; + } + 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(1, $result['packages_retained_for_unmatched_amount']); + $this->assertSame(1, $result['superseded_packages_removed']); + $this->assertSame(1, $result['stale_mirror_packages']); + // 保留的旧包裹:商品金额不动,省市区按上游刷新 + $kept = $db->table('jst_oms_shipping')->where('source_id', 5000)->sole(); + $this->assertSame('旧商品甲', $kept->product_name); + $this->assertEquals(10.50, $kept->amount); + $this->assertSame('河南省', $kept->receiver_state); + $this->assertSame('郑州市', $kept->receiver_city); + // 被发货明细取代的兜底包裹删除;无对应的仍保留待核对 + $this->assertSame(0, $db->table('jst_oms_shipping')->where('source_id', 1000000777)->count()); + $this->assertSame(1, $db->table('jst_oms_shipping')->where('source_id', 1000000888)->count()); + $this->assertSame('江苏省', $db->table('jst_oms_shipping')->where('source_id', 6000)->value('receiver_state')); + } + private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void { $reflection = new \ReflectionProperty($synchronizer, $property);