From f6497f5afc0cc979af67f91bfb1471a0a2ef7c88 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Wed, 14 Oct 2026 08:54:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=8F=91=E8=B4=A7=E6=95=B0=E6=8D=AE):=20?= =?UTF-8?q?=E9=87=8D=E8=B7=91=E6=97=B6=E6=B8=85=E9=99=A4=E8=A2=AB=E5=8F=96?= =?UTF-8?q?=E4=BB=A3=E7=9A=84=E5=85=9C=E5=BA=95=E5=8C=85=E8=A3=B9=EF=BC=8C?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=8C=85=E8=A3=B9=E4=B9=9F=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E7=9C=81=E5=B8=82=E5=8C=BA/=E5=BF=AB=E9=80=92/=E4=BB=93?= =?UTF-8?q?=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 凌晨同步时发货明细常未到齐,包裹先按自动发货仓/出库单兜底入库(无省份、无商品);之后发货明细到齐换成发货明细来源键, 旧兜底包裹因"上游未再出现"被保留,造成重复和空白省份(10 月重跑 2,226 个)。现在:旧包裹的快递单号已由本次其它包裹吐出时 视为被取代并删除;无对应的仍保留待核对。 - 商品金额无法安全匹配(或新商品明细缺失)而保留的旧包裹(10 月 28,201 个),此前整包不更新;现在商品与金额不动, 省市区、快递名称、仓库、店铺、平台、交易单号、付款时间按本次上游刷新。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf --- .../Jushuitan/ShippingReplicaSynchronizer.php | 55 +++++++++++++++-- .../ShippingReplicaSynchronizerTest.php | 59 +++++++++++++++++++ 2 files changed, 109 insertions(+), 5 deletions(-) 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);