From b5cab149e1d9cc2e4d868ee1b86b09cd65de058d Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Mon, 2 Nov 2026 11:56:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=8F=91=E8=B4=A7=E6=95=B0=E6=8D=AE):=20?= =?UTF-8?q?=E6=B8=85=E7=90=86=E5=8C=85=E8=A3=B9=E9=87=8C=E6=AE=8B=E7=95=99?= =?UTF-8?q?=E7=9A=84=E8=AE=A2=E5=8D=95=E5=8D=A0=E4=BD=8D=E5=95=86=E5=93=81?= =?UTF-8?q?=E8=A1=8C(=E6=95=B0=E9=87=8F0)=EF=BC=8C=E4=BB=B6=E6=95=B0?= =?UTF-8?q?=E9=9A=8F=E4=B9=8B=E6=9B=B4=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 订单里被替换的原商品、组合装原套装等(数量 0、金额 0/空、item_status=Replaced), 聚水潭销售出库单不显示;占位过滤上线前入库的包裹,之后因"金额对不上保留旧包裹"一直保留着这些行, 导致商品编码多出、件数(1/N)偏大,按件数加收的面单费跟着多算。 - 每次窗口同步后,包裹已有实发商品时删除这些占位行,件数改为实发行数; 首行若是占位行,其上的重量/运费/结算等包裹级字段先挪到保留的首行。 - 同步修正单测 test_view_item_with_unmatched_synthetic_sku_retains_old_package_and_marks_items_pending 的预期: main 上已上线"新旧条码完全不同视为商品已更正、覆盖旧记录",原用例仍按旧行为断言 2 个保留包裹,与现行逻辑不符。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf --- .../Jushuitan/ShippingReplicaSynchronizer.php | 65 +++++++++++++++++++ .../ShippingReplicaSynchronizerTest.php | 54 ++++++++++++++- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index d4f4646e..f3fee6c7 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -59,6 +59,7 @@ class ShippingReplicaSynchronizer private bool $replaceWindow = false; private int $shipTimeFixed = 0; private int $weightFixed = 0; + private int $placeholderPurged = 0; /** @var array>> */ private array $replacementPackages = []; @@ -202,6 +203,7 @@ class ShippingReplicaSynchronizer $this->staleMirrorPackages = 0; $this->shipTimeFixed = 0; $this->weightFixed = 0; + $this->placeholderPurged = 0; $this->supersededRemoved = 0; $this->outboundScanned = 0; $this->purchaseScanned = 0; @@ -267,12 +269,16 @@ class ShippingReplicaSynchronizer } } try { + $this->placeholderPurged = $this->purgeZeroQtyPlaceholders($fromS, $toS); $al = $this->alignWithOutbound($fromS, $toS); $this->shipTimeFixed = $al['ship_time']; $this->weightFixed = $al['weight']; if ($progress && ($this->shipTimeFixed > 0 || $this->weightFixed > 0)) { $progress('按销售出库单校正:发货时间 ' . $this->shipTimeFixed . ' 个包裹,线上/实称重量 ' . $this->weightFixed . ' 个包裹'); } + if ($progress && $this->placeholderPurged > 0) { + $progress('清理订单占位商品行(数量0):' . $this->placeholderPurged . ' 个包裹'); + } } catch (Throwable $e) { if ($progress) { $progress('发货时间校正跳过:' . $e->getMessage()); @@ -311,6 +317,7 @@ class ShippingReplicaSynchronizer $result['tracking_pairing_skipped_pages'] = $this->pairingSkipped; $result['ship_time_fixed'] = $this->shipTimeFixed; $result['weight_fixed'] = $this->weightFixed; + $result['placeholder_packages_cleaned'] = $this->placeholderPurged; } if ($skipFreight) { $result['freight_incomplete'] = true; @@ -2909,6 +2916,64 @@ class ShippingReplicaSynchronizer return ['ship_time' => $nTime, 'weight' => $nWeight]; } + /** + * 清理历史残留的订单占位行:包裹已有实发商品(数量>0)时,删除数量 0、金额 0/空 的订单商品行 + * (被替换的原商品、组合装原套装等,聚水潭销售出库单不显示),商品行数(件数 1/N)随之更正。 + * 这类行多半是占位过滤上线前入库、之后因"金额对不上保留旧包裹"一直没被替换下来的。 + * 包裹级字段(重量/运费/结算等)若记在被删的首行上,先挪到保留下来的首行。 + */ + public function purgeZeroQtyPlaceholders(string $fromS, string $toS): int + { + $pkgFields = array_merge( + ['online_weight', 'actual_weight', 'weight_kg', 'settle_weight_kg', 'freight', 'settle_freight'], + self::PRESERVED_PACKAGE_FIELDS, + ); + $sids = $this->db->table('jst_oms_shipping') + ->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS) + ->where('qty', 0) + ->where(fn ($q) => $q->whereNull('amount')->orWhere('amount', 0)) + ->distinct()->pluck('source_id')->map(fn ($v) => (int) $v)->all(); + $packages = 0; + foreach (array_chunk($sids, 500) as $chunk) { + $rows = $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk) + ->orderBy('id')->get(array_merge(['id', 'source_id', 'qty', 'amount'], $pkgFields)); + $by = []; + foreach ($rows as $r) { + $by[(int) $r->source_id][] = (array) $r; + } + foreach ($by as $list) { + $isPh = static fn (array $r): bool => is_numeric($r['qty']) && (float) $r['qty'] == 0.0 + && ($r['amount'] === null || (is_numeric($r['amount']) && (float) $r['amount'] == 0.0)); + $keep = array_values(array_filter($list, fn ($r) => ! $isPh($r))); + $drop = array_values(array_filter($list, $isPh)); + $shipped = array_filter($keep, static fn ($r) => is_numeric($r['qty']) && (float) $r['qty'] > 0); + if ($drop === [] || $shipped === []) { + continue; + } + $this->db->transaction(function () use ($list, $keep, $drop, $isPh, $pkgFields): void { + if ($isPh($list[0])) { + $set = []; + foreach ($pkgFields as $f) { + $old = $list[0][$f] ?? null; + $cur = $keep[0][$f] ?? null; + if (($cur === null || $cur === '') && $old !== null && $old !== '') { + $set[$f] = $old; + } + } + if ($set !== []) { + $this->db->table('jst_oms_shipping')->where('id', $keep[0]['id'])->update($set); + } + } + $this->db->table('jst_oms_shipping')->whereIn('id', array_column($drop, 'id'))->delete(); + $this->db->table('jst_oms_shipping')->whereIn('id', array_column($keep, 'id'))->update(['pkg_count' => '1/' . count($keep)]); + }); + $packages++; + } + } + + return $packages; + } + private function ts(mixed $v): ?string { if ($v === null || $v === '') { diff --git a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php index 9cd3e430..31ae0a56 100644 --- a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -782,14 +782,15 @@ class ShippingReplicaSynchronizerTest extends TestCase Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), ); - $this->assertSame(2, $result['packages_retained_for_unmatched_amount']); + // 旧条码为空的包裹无法判断是否错配:保留旧记录;旧条码 old-sku 与实发 new-sku 完全不同:视为商品已更正,覆盖旧记录 + $this->assertSame(1, $result['packages_retained_for_unmatched_amount']); + $this->assertSame(1, $result['packages_items_corrected']); $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->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 @@ -1539,6 +1540,53 @@ class ShippingReplicaSynchronizerTest extends TestCase $this->assertEquals(3.0, $w(8005, 1)->actual_weight); } + public function test_stale_zero_quantity_order_rows_are_purged_and_package_fields_move_to_first_kept_row(): void + { + $db = DB::connection('business_mirror'); + foreach ([ + // 组合套装原行(数量0、金额0)排在首行,且带着包裹级运费/重量;实发只有挂钩一行 + ['source_id' => 9001, 'sku_source_id' => 1, 'ship_time' => '2026-10-07 09:54:45', 'express_no' => 'JT2213401161843', 'barcode' => '运营组合套装13462', + 'qty' => 0, 'amount' => 0, 'pkg_count' => '1/3', 'freight' => 2.5, 'weight_kg' => 0.01, 'online_weight' => 0.01], + ['source_id' => 9001, 'sku_source_id' => 2, 'ship_time' => '2026-10-07 09:54:45', 'express_no' => 'JT2213401161843', 'barcode' => 'SJ038059N1410V01', + 'qty' => 0, 'amount' => 0, 'pkg_count' => '1/3'], + ['source_id' => 9001, 'sku_source_id' => 3, 'ship_time' => '2026-10-07 09:54:45', 'express_no' => 'JT2213401161843', 'barcode' => 'GG003-SZ', + 'qty' => 1, 'amount' => 568, 'pkg_count' => '1/3'], + // 被替换的原商品(数量0、金额空)在后面 + ['source_id' => 9002, 'sku_source_id' => 1, 'ship_time' => '2026-10-22 11:02:57', 'express_no' => 'JT3181752322961', 'barcode' => 'HCZDYMJ-50-KFSBD-292', + 'qty' => 1, 'amount' => 73, 'pkg_count' => '1/2', 'freight' => 4.1], + ['source_id' => 9002, 'sku_source_id' => 2, 'ship_time' => '2026-10-22 11:02:57', 'express_no' => 'JT3181752322961', 'barcode' => 'HCZDYMJ-50-KFSBD-TJ', + 'qty' => 0, 'amount' => null, 'pkg_count' => '1/2'], + // 只有数量0的行:不动(不把包裹删成无商品) + ['source_id' => 9003, 'sku_source_id' => 1, 'ship_time' => '2026-10-10 10:00:00', 'express_no' => 'ZERO3', 'barcode' => 'X', + 'qty' => 0, 'amount' => 0, 'pkg_count' => '1/1'], + // 窗口外:不动 + ['source_id' => 9004, 'sku_source_id' => 1, 'ship_time' => '2026-09-10 10:00:00', 'express_no' => 'OLD4', 'barcode' => 'A', + 'qty' => 0, 'amount' => 0, 'pkg_count' => '1/2'], + ['source_id' => 9004, 'sku_source_id' => 2, 'ship_time' => '2026-09-10 10:00:00', 'express_no' => 'OLD4', 'barcode' => 'B', + 'qty' => 1, 'amount' => 5, 'pkg_count' => '1/2'], + ] as $row) { + $db->table('jst_oms_shipping')->insert($row); + } + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->method('forSource')->willReturn($this->createMock(UpstreamReadOnlyQuery::class)); + + $n = (new ShippingReplicaSynchronizer($factory))->purgeZeroQtyPlaceholders('2026-10-01 00:00:00', '2026-10-26 00:00:00'); + + $this->assertSame(2, $n); + $kept = $db->table('jst_oms_shipping')->where('source_id', 9001)->get(); + $this->assertCount(1, $kept); + $this->assertSame('GG003-SZ', $kept[0]->barcode); + $this->assertSame('1/1', $kept[0]->pkg_count); + $this->assertEquals(2.5, $kept[0]->freight); + $this->assertEquals(0.01, $kept[0]->weight_kg); + $this->assertEquals(0.01, $kept[0]->online_weight); + $this->assertSame(['HCZDYMJ-50-KFSBD-292'], $db->table('jst_oms_shipping')->where('source_id', 9002)->pluck('barcode')->all()); + $this->assertSame('1/1', $db->table('jst_oms_shipping')->where('source_id', 9002)->value('pkg_count')); + $this->assertEquals(4.1, $db->table('jst_oms_shipping')->where('source_id', 9002)->value('freight')); + $this->assertSame(1, $db->table('jst_oms_shipping')->where('source_id', 9003)->count()); + $this->assertSame(2, $db->table('jst_oms_shipping')->where('source_id', 9004)->count()); + } + private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void { $reflection = new \ReflectionProperty($synchronizer, $property);