fix/shipping-ship-time-outbound-1030122606 #782

Merged
limeng merged 2 commits from fix/shipping-ship-time-outbound-1030122606 into main 2026-10-30 12:59:50 +08:00
2 changed files with 249 additions and 1 deletions

View File

@ -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<int,list<array<string,mixed>>> */
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 === '') {

View File

@ -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);