diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index 218aca7d..dfc9f1d6 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,8 @@ class ShippingReplicaSynchronizer private int $maxAutoShipBatches = 600; private bool $skipFreight = false; private bool $replaceWindow = false; + private int $shipTimeFixed = 0; + private int $weightFixed = 0; /** @var array>> */ private array $replacementPackages = []; @@ -184,6 +186,8 @@ class ShippingReplicaSynchronizer $this->packagesRetainedForMissingItems = 0; $this->packagesRetainedForUnmatchedAmount = 0; $this->staleMirrorPackages = 0; + $this->shipTimeFixed = 0; + $this->weightFixed = 0; $this->supersededRemoved = 0; $this->outboundScanned = 0; $this->purchaseScanned = 0; @@ -248,6 +252,18 @@ class ShippingReplicaSynchronizer $progress('去除@重复单号:' . $removed . ' 条'); } } + try { + $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) { + $progress('发货时间校正跳过:' . $e->getMessage()); + } + } } else { $total = $this->syncExpress(false, $fromS, $toS, $limit, $now, $progress); $this->applyPendingWarehouses($progress); @@ -277,6 +293,8 @@ 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; + $result['weight_fixed'] = $this->weightFixed; } if ($skipFreight) { $result['freight_incomplete'] = true; @@ -2513,6 +2531,131 @@ class ShippingReplicaSynchronizer return $clean; } + /** + * 发货时间校正:仓库次日才出库的包裹,同步时出库单还没生成,发货时间先落成发货明细(打单)时间; + * 后续同步若走"保留旧包裹"分支不会改写。这里按快递单号对齐出库单 io_date(=聚水潭发货/出库时间), + * 只把偏早的发货时间往后校正(相差 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 = []; + 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) { + $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 ['ship_time' => 0, 'weight' => 0]; + } + + $mirrorFrom = Carbon::parse($fromS)->subDays(7)->format('Y-m-d H:i:s'); + $fixTime = []; + $first = []; // source_id => [id, online, actual, best] + $clearIds = []; // 非首行残留的重量 -> 置空 + $this->db->table('jst_oms_shipping') + ->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 (&$fixTime, &$first, &$clearIds, $best): void { + foreach ($chunk as $row) { + $sid = (int) $row->source_id; + $l = ltrim(trim((string) $row->express_no), '@'); + if ($l === '' || ! isset($best[$l])) { + continue; + } + $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) { + $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($best); + + $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]); + } + $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 ['ship_time' => $nTime, 'weight' => $nWeight]; + } + 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..9cd3e430 100644 --- a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -1434,6 +1434,111 @@ 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')); + } + + 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);