From 1ecdf9a6b7573e7141ff4cea21523847c85a7f3a Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Fri, 30 Oct 2026 12:29:39 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(=E5=8F=91=E8=B4=A7=E6=95=B0=E6=8D=AE):?= =?UTF-8?q?=20=E5=8F=91=E8=B4=A7=E6=97=B6=E9=97=B4=E6=8C=89=E5=87=BA?= =?UTF-8?q?=E5=BA=93=E5=8D=95=E5=87=BA=E5=BA=93=E6=97=B6=E9=97=B4=E6=A0=A1?= =?UTF-8?q?=E6=AD=A3=EF=BC=8C=E4=BF=9D=E7=95=99=E6=97=A7=E5=8C=85=E8=A3=B9?= =?UTF-8?q?=E6=97=B6=E5=90=8C=E6=AD=A5=E5=88=B7=E6=96=B0=E5=8F=91=E8=B4=A7?= =?UTF-8?q?=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 仓库次日才出库的包裹,晚间同步时出库单尚未生成,发货时间先落成发货明细(打单)时间; 之后同步若走"保留旧包裹"分支只刷新地址等字段,发货时间一直停在打单时间, 10月约 18% 包裹比聚水潭发货时间早一天,按执行日期选报价时会选错版本。 - 保留旧包裹时一并刷新 ship_time。 - 每次按日期窗口同步结束后,按快递单号对齐出库单 io_date(聚水潭发货/出库时间), 只把偏早的发货时间往后校正(1 分钟~7 天);同一单号多张出库单时有效单优先、再取最新。 校正失败不影响本次同步结果,结果里新增 ship_time_fixed。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf --- .../Jushuitan/ShippingReplicaSynchronizer.php | 85 ++++++++++++++++++- .../ShippingReplicaSynchronizerTest.php | 49 +++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index 218aca7d..afee438f 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -44,7 +44,7 @@ class ShippingReplicaSynchronizer ]; 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', + 'trade_no', 'express_name1', 'warehouse_name', 'shop_name', 'platform', 'pay_time', 'ship_time', 'receiver_state', 'receiver_city', 'receiver_district', ]; private const PRESERVED_ITEM_FIELDS = ['finished_barcode', 'finished_name', 'finished_supplier']; @@ -57,6 +57,7 @@ class ShippingReplicaSynchronizer private int $maxAutoShipBatches = 600; private bool $skipFreight = false; private bool $replaceWindow = false; + private int $shipTimeFixed = 0; /** @var array>> */ private array $replacementPackages = []; @@ -184,6 +185,7 @@ class ShippingReplicaSynchronizer $this->packagesRetainedForMissingItems = 0; $this->packagesRetainedForUnmatchedAmount = 0; $this->staleMirrorPackages = 0; + $this->shipTimeFixed = 0; $this->supersededRemoved = 0; $this->outboundScanned = 0; $this->purchaseScanned = 0; @@ -248,6 +250,16 @@ class ShippingReplicaSynchronizer $progress('去除@重复单号:' . $removed . ' 条'); } } + try { + $this->shipTimeFixed = $this->fixShipTimes($fromS, $toS); + if ($progress && $this->shipTimeFixed > 0) { + $progress('发货时间按出库时间校正:' . $this->shipTimeFixed . ' 个包裹'); + } + } catch (Throwable $e) { + if ($progress) { + $progress('发货时间校正跳过:' . $e->getMessage()); + } + } } else { $total = $this->syncExpress(false, $fromS, $toS, $limit, $now, $progress); $this->applyPendingWarehouses($progress); @@ -277,6 +289,7 @@ class ShippingReplicaSynchronizer $result['stale_mirror_packages'] = $this->staleMirrorPackages; $result['superseded_packages_removed'] = $this->supersededRemoved; $result['tracking_pairing_skipped_pages'] = $this->pairingSkipped; + $result['ship_time_fixed'] = $this->shipTimeFixed; } if ($skipFreight) { $result['freight_incomplete'] = true; @@ -2513,6 +2526,76 @@ class ShippingReplicaSynchronizer return $clean; } + /** + * 发货时间校正:仓库次日才出库的包裹,同步时出库单还没生成,发货时间先落成发货明细(打单)时间; + * 后续同步若走"保留旧包裹"分支不会改写。这里按快递单号对齐出库单 io_date(=聚水潭发货/出库时间), + * 只把偏早的发货时间往后校正(相差 1 分钟~7 天),同一单号多张出库单时有效单优先、再取最新。 + */ + public function fixShipTimes(string $fromS, string $toS): int + { + $ioTo = Carbon::parse($toS)->addDays(3)->format('Y-m-d H:i:s'); + $best = []; + $rows = $this->selectWithReconnect( + 'SELECT so.l_id, so.io_date, so.status FROM ' . self::OUTBOUND . ' so WHERE so.io_date >= ? AND so.io_date < ?', + [$fromS, $ioTo], + ); + foreach ($rows as $r) { + $r = (array) $r; + $l = ltrim(trim((string) ($r['l_id'] ?? '')), '@'); + $d = $this->ts($r['io_date'] ?? null); + if ($l === '' || $d === null) { + continue; + } + $st = strtolower(trim((string) ($r['status'] ?? ''))); + $ok = ! in_array($st, ['delete', 'deleted', 'cancelled', 'cancel', 'canceled'], true); + $rk = ($ok ? '1' : '0') . '|' . $d; + if (! isset($best[$l]) || strcmp($rk, $best[$l][0]) > 0) { + $best[$l] = [$rk, $d]; + } + } + unset($rows); + if ($best === []) { + return 0; + } + + $mirrorFrom = Carbon::parse($fromS)->subDays(7)->format('Y-m-d H:i:s'); + $fix = []; + $seen = []; + $this->db->table('jst_oms_shipping') + ->select(['id', 'source_id', 'express_no', 'ship_time']) + ->where('ship_time', '>=', $mirrorFrom)->where('ship_time', '<', $toS) + ->whereNotNull('express_no') + ->chunkById(20000, function ($chunk) use (&$fix, &$seen, $best): void { + foreach ($chunk as $row) { + $sid = (int) $row->source_id; + if (isset($seen[$sid])) { + continue; + } + $seen[$sid] = true; + $l = ltrim(trim((string) $row->express_no), '@'); + if ($l === '' || ! isset($best[$l])) { + continue; + } + $new = $best[$l][1]; + $diff = strtotime($new) - strtotime((string) $row->ship_time); + if ($diff >= 60 && $diff <= 7 * 86400) { + $fix[$new][] = $sid; + } + } + }, 'id'); + unset($seen, $best); + + $n = 0; + foreach ($fix as $new => $ids) { + foreach (array_chunk($ids, 500) as $chunk) { + $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk)->update(['ship_time' => $new]); + } + $n += count($ids); + } + + return $n; + } + 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 2a1fa97d..7edd2792 100644 --- a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -1434,6 +1434,55 @@ class ShippingReplicaSynchronizerTest extends TestCase $this->assertSame(2, $out['qty']); } + public function test_ship_time_is_moved_to_outbound_io_date_when_warehouse_ships_next_day(): void + { + $db = DB::connection('business_mirror'); + $db->table('jst_oms_shipping')->insert([ + // 晚上打单(发货明细时间)、次日早上出库:应改成出库时间(一个包裹两行商品都改) + ['source_id' => 7001, 'sku_source_id' => 1, 'ship_time' => '2026-10-20 20:40:51', 'express_no' => '79041235735054'], + ['source_id' => 7001, 'sku_source_id' => 2, 'ship_time' => '2026-10-20 20:40:51', 'express_no' => '79041235735054'], + // 已经是出库时间:不动 + ['source_id' => 7002, 'sku_source_id' => 1, 'ship_time' => '2026-10-21 09:00:00', 'express_no' => 'YT2'], + // 单号带 @ 前缀、出库单有作废+有效两张:取有效那张 + ['source_id' => 7003, 'sku_source_id' => 1, 'ship_time' => '2026-10-22 10:00:00', 'express_no' => '@JT3'], + // 出库时间比现有时间早:不往前改 + ['source_id' => 7004, 'sku_source_id' => 1, 'ship_time' => '2026-10-23 10:00:00', 'express_no' => 'SF4'], + // 相差超过 7 天:视为单号复用,不改 + ['source_id' => 7005, 'sku_source_id' => 1, 'ship_time' => '2026-10-10 10:00:00', 'express_no' => 'DPK5'], + ]); + $queries = []; + $upstream = $this->createMock(UpstreamReadOnlyQuery::class); + $upstream->method('select')->willReturnCallback(function (string $sql, array $bindings) use (&$queries): array { + if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo so WHERE so.io_date')) { + $queries[] = $bindings; + + return [ + ['l_id' => '79041235735054', 'io_date' => '2026-10-21 08:46:09', 'status' => 'Confirmed'], + ['l_id' => 'YT2', 'io_date' => '2026-10-21 09:00:00', 'status' => 'Confirmed'], + ['l_id' => 'JT3', 'io_date' => '2026-10-23 07:00:00', 'status' => 'Confirmed'], + ['l_id' => '@JT3', 'io_date' => '2026-10-24 07:00:00', 'status' => 'Cancelled'], + ['l_id' => 'SF4', 'io_date' => '2026-10-22 10:00:00', 'status' => 'Confirmed'], + ['l_id' => 'DPK5', 'io_date' => '2026-10-20 10:00:00', 'status' => 'Confirmed'], + ]; + } + + return []; + }); + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->method('forSource')->with('operations_replica')->willReturn($upstream); + + $n = (new ShippingReplicaSynchronizer($factory))->fixShipTimes('2026-10-20 00:00:00', '2026-10-26 00:00:00'); + + $this->assertSame(2, $n); + $this->assertSame([['2026-10-20 00:00:00', '2026-10-29 00:00:00']], $queries); + $this->assertSame(['2026-10-21 08:46:09'], $db->table('jst_oms_shipping')->where('source_id', 7001)->distinct()->pluck('ship_time')->all()); + $this->assertSame(2, $db->table('jst_oms_shipping')->where('source_id', 7001)->where('ship_time', '2026-10-21 08:46:09')->count()); + $this->assertSame('2026-10-21 09:00:00', $db->table('jst_oms_shipping')->where('source_id', 7002)->value('ship_time')); + $this->assertSame('2026-10-23 07:00:00', $db->table('jst_oms_shipping')->where('source_id', 7003)->value('ship_time')); + $this->assertSame('2026-10-23 10:00:00', $db->table('jst_oms_shipping')->where('source_id', 7004)->value('ship_time')); + $this->assertSame('2026-10-10 10:00:00', $db->table('jst_oms_shipping')->where('source_id', 7005)->value('ship_time')); + } + private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void { $reflection = new \ReflectionProperty($synchronizer, $property); -- 2.47.2 From 040d060fa36f4b3817aaa0d947cb2b9831771fd2 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Fri, 30 Oct 2026 12:54:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(=E5=8F=91=E8=B4=A7=E6=95=B0=E6=8D=AE):?= =?UTF-8?q?=20=E7=BA=BF=E4=B8=8A=E9=87=8D=E9=87=8F/=E5=AE=9E=E7=A7=B0?= =?UTF-8?q?=E9=87=8D=E9=87=8F=E6=8C=89=E9=94=80=E5=94=AE=E5=87=BA=E5=BA=93?= =?UTF-8?q?=E5=8D=95=20weight/f=5Fweight=20=E5=8F=96=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实称重量原取发货明细 e.weight:未称重时它是预估重量,称重后才变实称, 10 月约 20 万单"实称"其实是聚水潭预估重量;线上重量原为商品资料单重×数量,约 2.6 万单与聚水潭预估不符。 - 改为与聚水潭"销售出库单"界面一致:线上重量 = 出库单 weight(预估),实称重量 = 出库单 f_weight(未称重为空), 只记在包裹第一行(合并模式按包裹求和);与发货时间校正合并为 alignWithOutbound,每次窗口同步后执行。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf --- .../Jushuitan/ShippingReplicaSynchronizer.php | 112 ++++++++++++++---- .../ShippingReplicaSynchronizerTest.php | 56 +++++++++ 2 files changed, 142 insertions(+), 26 deletions(-) diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index afee438f..dfc9f1d6 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -58,6 +58,7 @@ class ShippingReplicaSynchronizer private bool $skipFreight = false; private bool $replaceWindow = false; private int $shipTimeFixed = 0; + private int $weightFixed = 0; /** @var array>> */ private array $replacementPackages = []; @@ -186,6 +187,7 @@ class ShippingReplicaSynchronizer $this->packagesRetainedForUnmatchedAmount = 0; $this->staleMirrorPackages = 0; $this->shipTimeFixed = 0; + $this->weightFixed = 0; $this->supersededRemoved = 0; $this->outboundScanned = 0; $this->purchaseScanned = 0; @@ -251,9 +253,11 @@ class ShippingReplicaSynchronizer } } try { - $this->shipTimeFixed = $this->fixShipTimes($fromS, $toS); - if ($progress && $this->shipTimeFixed > 0) { - $progress('发货时间按出库时间校正:' . $this->shipTimeFixed . ' 个包裹'); + $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 . ' 个包裹'); } } catch (Throwable $e) { if ($progress) { @@ -290,6 +294,7 @@ class ShippingReplicaSynchronizer $result['superseded_packages_removed'] = $this->supersededRemoved; $result['tracking_pairing_skipped_pages'] = $this->pairingSkipped; $result['ship_time_fixed'] = $this->shipTimeFixed; + $result['weight_fixed'] = $this->weightFixed; } if ($skipFreight) { $result['freight_incomplete'] = true; @@ -2532,13 +2537,37 @@ class ShippingReplicaSynchronizer * 只把偏早的发货时间往后校正(相差 1 分钟~7 天),同一单号多张出库单时有效单优先、再取最新。 */ public function fixShipTimes(string $fromS, string $toS): int + { + return $this->alignWithOutbound($fromS, $toS)['ship_time']; + } + + /** + * 按销售出库单对齐包裹级字段(聚水潭"销售出库单"界面口径): + * - 发货时间 = 出库时间 io_date(只往后校正,见 fixShipTimes) + * - 线上重量 = 出库单 weight(预估重量),实称重量 = 出库单 f_weight(没称重为空) + * 发货明细 e.weight 未称重时是预估重量、称重后才变实称,不能直接当实称; + * 商品资料单重×数量也不等于聚水潭预估重量。两个重量只写在包裹第一行(合并模式按包裹求和)。 + * + * @return array{ship_time:int, weight:int} + */ + public function alignWithOutbound(string $fromS, string $toS): array { $ioTo = Carbon::parse($toS)->addDays(3)->format('Y-m-d H:i:s'); + $withWeight = true; + try { + $rows = $this->selectWithReconnect( + 'SELECT so.l_id, so.io_date, so.status, so.weight, so.f_weight FROM ' . self::OUTBOUND . ' so WHERE so.io_date >= ? AND so.io_date < ?', + [$fromS, $ioTo], + ); + } catch (Throwable $e) { + // 副本库若没有重量列,只做发货时间校正 + $withWeight = false; + $rows = $this->selectWithReconnect( + 'SELECT so.l_id, so.io_date, so.status FROM ' . self::OUTBOUND . ' so WHERE so.io_date >= ? AND so.io_date < ?', + [$fromS, $ioTo], + ); + } $best = []; - $rows = $this->selectWithReconnect( - 'SELECT so.l_id, so.io_date, so.status FROM ' . self::OUTBOUND . ' so WHERE so.io_date >= ? AND so.io_date < ?', - [$fromS, $ioTo], - ); foreach ($rows as $r) { $r = (array) $r; $l = ltrim(trim((string) ($r['l_id'] ?? '')), '@'); @@ -2547,53 +2576,84 @@ class ShippingReplicaSynchronizer continue; } $st = strtolower(trim((string) ($r['status'] ?? ''))); - $ok = ! in_array($st, ['delete', 'deleted', 'cancelled', 'cancel', 'canceled'], true); + $ok = ! in_array($st, ['delete', 'deleted', 'cancelled', 'cancel', 'canceled', '作废'], true); $rk = ($ok ? '1' : '0') . '|' . $d; if (! isset($best[$l]) || strcmp($rk, $best[$l][0]) > 0) { - $best[$l] = [$rk, $d]; + $hasW = $withWeight && array_key_exists('weight', $r); + $w = $hasW ? (float) ($r['weight'] ?? 0) : 0.0; + $fw = $hasW ? (float) ($r['f_weight'] ?? 0) : 0.0; + $best[$l] = [$rk, $d, $hasW, $w > 0 ? round($w, 4) : null, $fw > 0 ? round($fw, 4) : null]; } } unset($rows); if ($best === []) { - return 0; + return ['ship_time' => 0, 'weight' => 0]; } $mirrorFrom = Carbon::parse($fromS)->subDays(7)->format('Y-m-d H:i:s'); - $fix = []; - $seen = []; + $fixTime = []; + $first = []; // source_id => [id, online, actual, best] + $clearIds = []; // 非首行残留的重量 -> 置空 $this->db->table('jst_oms_shipping') - ->select(['id', 'source_id', 'express_no', 'ship_time']) + ->select(['id', 'source_id', 'express_no', 'ship_time', 'online_weight', 'actual_weight']) ->where('ship_time', '>=', $mirrorFrom)->where('ship_time', '<', $toS) ->whereNotNull('express_no') - ->chunkById(20000, function ($chunk) use (&$fix, &$seen, $best): void { + ->chunkById(20000, function ($chunk) use (&$fixTime, &$first, &$clearIds, $best): void { foreach ($chunk as $row) { $sid = (int) $row->source_id; - if (isset($seen[$sid])) { - continue; - } - $seen[$sid] = true; $l = ltrim(trim((string) $row->express_no), '@'); if ($l === '' || ! isset($best[$l])) { continue; } - $new = $best[$l][1]; - $diff = strtotime($new) - strtotime((string) $row->ship_time); + $b = $best[$l]; + if (isset($first[$sid])) { + if ($b[2] && ($row->online_weight !== null || $row->actual_weight !== null)) { + $clearIds[] = (int) $row->id; + } + continue; + } + $first[$sid] = true; + $diff = strtotime($b[1]) - strtotime((string) $row->ship_time); if ($diff >= 60 && $diff <= 7 * 86400) { - $fix[$new][] = $sid; + $fixTime[$b[1]][] = $sid; + } + if ($b[2]) { + $on = $row->online_weight === null ? null : round((float) $row->online_weight, 4); + $ac = $row->actual_weight === null ? null : round((float) $row->actual_weight, 4); + if ($on !== $b[3] || $ac !== $b[4]) { + $first[$sid] = [(int) $row->id, $b[3], $b[4]]; + } } } }, 'id'); - unset($seen, $best); + unset($best); - $n = 0; - foreach ($fix as $new => $ids) { + $nTime = 0; + foreach ($fixTime as $new => $ids) { foreach (array_chunk($ids, 500) as $chunk) { $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk)->update(['ship_time' => $new]); } - $n += count($ids); + $nTime += count($ids); + } + $nWeight = 0; + $groups = []; + foreach ($first as $v) { + if (is_array($v)) { + $groups[json_encode([$v[1], $v[2]])][] = $v[0]; + $nWeight++; + } + } + foreach ($groups as $key => $ids) { + [$on, $ac] = json_decode($key, true); + foreach (array_chunk($ids, 500) as $chunk) { + $this->db->table('jst_oms_shipping')->whereIn('id', $chunk)->update(['online_weight' => $on, 'actual_weight' => $ac]); + } + } + foreach (array_chunk(array_values(array_unique($clearIds)), 500) as $chunk) { + $this->db->table('jst_oms_shipping')->whereIn('id', $chunk)->update(['online_weight' => null, 'actual_weight' => null]); } - return $n; + return ['ship_time' => $nTime, 'weight' => $nWeight]; } private function ts(mixed $v): ?string diff --git a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php index 7edd2792..9cd3e430 100644 --- a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -1483,6 +1483,62 @@ class ShippingReplicaSynchronizerTest extends TestCase $this->assertSame('2026-10-10 10:00:00', $db->table('jst_oms_shipping')->where('source_id', 7005)->value('ship_time')); } + public function test_online_and_actual_weight_follow_sales_outbound_weight_and_f_weight(): void + { + $db = DB::connection('business_mirror'); + $db->table('jst_oms_shipping')->insert([ + // 没称重:发货明细重量(=预估)被写进了实称、线上是商品单重×数量 -> 线上=出库单预估,实称置空 + ['source_id' => 8001, 'sku_source_id' => 1, 'ship_time' => '2026-10-10 10:00:00', 'express_no' => '302359667733', + 'online_weight' => 7.0, 'actual_weight' => 29.9], + // 两行商品:第二行残留的单重清空,重量只记在第一行 + ['source_id' => 8002, 'sku_source_id' => 1, 'ship_time' => '2026-10-10 11:00:00', 'express_no' => 'YT7650735469496', + 'online_weight' => 47.6, 'actual_weight' => 7.08], + ['source_id' => 8002, 'sku_source_id' => 2, 'ship_time' => '2026-10-10 11:00:00', 'express_no' => 'YT7650735469496', + 'online_weight' => 47.6, 'actual_weight' => null], + // 称过重:线上=预估,实称=称重重量 + ['source_id' => 8003, 'sku_source_id' => 1, 'ship_time' => '2026-10-19 15:46:24', 'express_no' => '428196459565777', + 'online_weight' => 1.344, 'actual_weight' => 1.34], + // 已经一致:不更新 + ['source_id' => 8004, 'sku_source_id' => 1, 'ship_time' => '2026-10-19 16:00:00', 'express_no' => 'OK4', + 'online_weight' => 2.195, 'actual_weight' => 2.04], + // 出库单里没有的包裹:不动 + ['source_id' => 8005, 'sku_source_id' => 1, 'ship_time' => '2026-10-19 16:00:00', 'express_no' => 'NOIO5', + 'online_weight' => 3.0, 'actual_weight' => 3.0], + ]); + $upstream = $this->createMock(UpstreamReadOnlyQuery::class); + $upstream->method('select')->willReturnCallback(function (string $sql): array { + if (str_contains($sql, 'FROM rpt.jst_salesOutboundInfo so WHERE so.io_date')) { + return [ + ['l_id' => '302359667733', 'io_date' => '2026-10-10 10:00:00', 'status' => '已出库', 'weight' => 29.9, 'f_weight' => null], + ['l_id' => 'YT7650735469496', 'io_date' => '2026-10-10 11:00:00', 'status' => '已出库', 'weight' => 7.08, 'f_weight' => null], + ['l_id' => '428196459565777', 'io_date' => '2026-10-19 15:46:24', 'status' => '已出库', 'weight' => 1.3442, 'f_weight' => 2.31], + ['l_id' => 'OK4', 'io_date' => '2026-10-19 16:00:00', 'status' => '已出库', 'weight' => 2.195, 'f_weight' => 2.04], + ]; + } + + return []; + }); + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->method('forSource')->with('operations_replica')->willReturn($upstream); + + $res = (new ShippingReplicaSynchronizer($factory))->alignWithOutbound('2026-10-01 00:00:00', '2026-10-26 00:00:00'); + + $this->assertSame(['ship_time' => 0, 'weight' => 3], $res); + $w = fn (int $sid, int $sku) => $db->table('jst_oms_shipping')->where('source_id', $sid)->where('sku_source_id', $sku)->first(['online_weight', 'actual_weight']); + $this->assertEquals(29.9, $w(8001, 1)->online_weight); + $this->assertNull($w(8001, 1)->actual_weight); + $this->assertEquals(7.08, $w(8002, 1)->online_weight); + $this->assertNull($w(8002, 1)->actual_weight); + $this->assertNull($w(8002, 2)->online_weight); + $this->assertNull($w(8002, 2)->actual_weight); + $this->assertEquals(1.3442, $w(8003, 1)->online_weight); + $this->assertEquals(2.31, $w(8003, 1)->actual_weight); + $this->assertEquals(2.195, $w(8004, 1)->online_weight); + $this->assertEquals(2.04, $w(8004, 1)->actual_weight); + $this->assertEquals(3.0, $w(8005, 1)->online_weight); + $this->assertEquals(3.0, $w(8005, 1)->actual_weight); + } + private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void { $reflection = new \ReflectionProperty($synchronizer, $property); -- 2.47.2