fix/shipping-ship-time-outbound-1030122606 #782
@ -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 === '') {
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user