fix(发货数据): 仓库按实际出库单发货仓、补入无需物流虚拟发货、作废单号识别换单
- 仓库一律按实际出库单 wms_co_id 映射(从快递公司一致的明细行学习仓库名,样本>=3 且占比>=90% 才回填), 修正发货明细写总仓而实际由工厂代发仓出库(如 710099981090 千优代发仓)的情况。 - 快递名称为"无需物流"(补差/信誉/运费等虚拟发货,如 X00006)的包裹仓库统一为"简尚-自动发货仓",与工作平台一致。 - 换单配对:快递明细单号只对应作废出库单时视为已换单,改用有效出库单的新单号(如 DPK366101313659 -> SF1549142274850)。 - 出库单兜底不再跳过"无需物流"的无单号出库单(信誉订单/补差价/运费专用等虚拟发货),按工作平台口径补入自动发货仓、快递单号为空; 9 月核对工作平台有、线上无的 565 条虚拟链接,抽查确认为此原因(出库单无单号被跳过)。 - 换单配对查询(logistic_id 无索引)副本库超时时跳过本页,不再中断整轮同步;结果返回 tracking_pairing_skipped_pages。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf
This commit is contained in:
parent
1e689d254e
commit
d8dfb33437
@ -118,9 +118,19 @@ class ShippingReplicaSynchronizer
|
||||
/** @var array<string,array<string,int>> wms_co_id => [仓库名 => 次数]:从快递公司一致的发货明细行学习仓库名 */
|
||||
private array $wmsNames = [];
|
||||
|
||||
/** @var array<int,string> 包裹 source_id => 待补仓库的 wms_co_id(扫描时尚未学到仓库名,扫描结束后统一回填) */
|
||||
/** @var array<int,string> 包裹 source_id => 出库单 wms_co_id(扫描结束后按学到的仓库名统一回填) */
|
||||
private array $pendingWarehouse = [];
|
||||
|
||||
/** @var array<string,bool> io_id => 是否有效出库单(非作废/取消) */
|
||||
private array $ioOk = [];
|
||||
|
||||
/** 换单配对查询超时而跳过的页数(白天副本库繁忙时,不让单页超时中断整轮同步) */
|
||||
private int $pairingSkipped = 0;
|
||||
|
||||
/** 工作平台口径:快递名称为"无需物流"(补差/信誉/运费等虚拟发货)的包裹,仓库统一显示自动发货仓 */
|
||||
private const NO_LOGISTICS = '无需物流';
|
||||
private const NO_LOGISTICS_WAREHOUSE = '简尚-自动发货仓';
|
||||
|
||||
/** @var array<string,string> 最近一次 loadViewItems 的 io_id => 视图店铺名(发货明细缺店铺时兜底) */
|
||||
private array $ioShop = [];
|
||||
/** @var array<string,array{l_id:mixed,send_date:mixed,status:?string,online:bool}> 已查询订单元数据缓存 o_id=>字段 */
|
||||
@ -185,6 +195,8 @@ class ShippingReplicaSynchronizer
|
||||
$this->ioWms = [];
|
||||
$this->wmsNames = [];
|
||||
$this->pendingWarehouse = [];
|
||||
$this->ioOk = [];
|
||||
$this->pairingSkipped = 0;
|
||||
if ($windowed) {
|
||||
$this->preloadDws($from, $to);
|
||||
$this->preloadOutboundMap($fromS, $toS);
|
||||
@ -241,6 +253,7 @@ class ShippingReplicaSynchronizer
|
||||
$result['packages_retained_for_unmatched_amount'] = $this->packagesRetainedForUnmatchedAmount;
|
||||
$result['dws_failed_days'] = $this->dwsFailedDays;
|
||||
$result['stale_mirror_packages'] = $this->staleMirrorPackages;
|
||||
$result['tracking_pairing_skipped_pages'] = $this->pairingSkipped;
|
||||
}
|
||||
if ($skipFreight) {
|
||||
$result['freight_incomplete'] = true;
|
||||
@ -811,6 +824,7 @@ class ShippingReplicaSynchronizer
|
||||
$scanned = 0;
|
||||
$emitted = 0;
|
||||
$skip = ['done' => 0, 'notrack' => 0, 'status' => 0, 'label' => 0, 'noitem' => 0];
|
||||
$virtualEmitted = 0;
|
||||
$seen = [];
|
||||
$page = 0;
|
||||
for ($a = $lo; $a <= $hi; $a += $step) {
|
||||
@ -828,7 +842,9 @@ class ShippingReplicaSynchronizer
|
||||
}
|
||||
$seen[$io] = true;
|
||||
$lid = trim((string) ($r['l_id'] ?? ''));
|
||||
if ($lid === '') {
|
||||
// 无单号出库单一般跳过;但"无需物流"的虚拟发货(信誉/补差/运费等)工作平台收在自动发货仓、快递单号为空,需补吐
|
||||
$virtual = trim((string) ($r['logistics_company'] ?? '')) === self::NO_LOGISTICS;
|
||||
if ($lid === '' && ! $virtual) {
|
||||
$skip['notrack']++;
|
||||
continue;
|
||||
}
|
||||
@ -837,7 +853,7 @@ class ShippingReplicaSynchronizer
|
||||
continue;
|
||||
}
|
||||
// 单号已处理过:若是别的交易单号共用此单号(合包/拼单),仍要补吐;交易单号要等视图表头加载后判断
|
||||
$r['_trk_done'] = isset($this->doneTrack[ltrim($lid, '@')]);
|
||||
$r['_trk_done'] = $lid !== '' && isset($this->doneTrack[ltrim($lid, '@')]);
|
||||
$st = (string) ($r['status'] ?? '');
|
||||
if (stripos($st, 'cancel') !== false || stripos($st, 'delete') !== false) {
|
||||
$skip['status']++;
|
||||
@ -870,7 +886,7 @@ class ShippingReplicaSynchronizer
|
||||
}
|
||||
$h = $heads[$io] ?? [];
|
||||
$so = $this->outboundSo($r, $h);
|
||||
$tracking = trim((string) $r['l_id']);
|
||||
$tracking = trim((string) ($r['l_id'] ?? ''));
|
||||
if (! empty($r['_trk_done'])) {
|
||||
$t0 = ltrim($tracking, '@');
|
||||
if ($so === '' || empty($this->doneTrackSo[$t0]) || isset($this->doneTrackSo[$t0][$so])) {
|
||||
@ -878,9 +894,13 @@ class ShippingReplicaSynchronizer
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$pkg = $this->pkgFromOutbound($r, $tracking, $this->ts($r['io_date'] ?? null), $h, $so);
|
||||
$pkg = $this->pkgFromOutbound($r, $tracking !== '' ? $tracking : null, $this->ts($r['io_date'] ?? null), $h, $so);
|
||||
$pkg = $this->withViewAddr($io, $pkg);
|
||||
$total += $this->emitPackage(self::OUTBOUND_OFFSET + (int) $io, $pkg, $viewItems[$io], $skus, $freight, $so, $tracking, $now, true);
|
||||
if (trim((string) ($pkg['express_name1'] ?? '')) === self::NO_LOGISTICS) {
|
||||
$pkg['warehouse_name'] = self::NO_LOGISTICS_WAREHOUSE;
|
||||
$virtualEmitted++;
|
||||
}
|
||||
$total += $this->emitPackage(self::OUTBOUND_OFFSET + (int) $io, $pkg, $viewItems[$io], $skus, $freight, $so, $tracking !== '' ? $tracking : null, $now, true);
|
||||
$this->markDone($io, $tracking);
|
||||
$this->markTrackSo($so, $tracking);
|
||||
$emitted++;
|
||||
@ -894,8 +914,8 @@ class ShippingReplicaSynchronizer
|
||||
$this->flushPending();
|
||||
if ($progress) {
|
||||
$progress(sprintf(
|
||||
'出库单兜底完成:扫描 %d 行,补吐 %d 包裹 / %d 行;跳过 已处理%d 无单号%d 取消删除%d 标签%d 无商品%d',
|
||||
$scanned, $emitted, $total, $skip['done'], $skip['notrack'], $skip['status'], $skip['label'], $skip['noitem']
|
||||
'出库单兜底完成:扫描 %d 行,补吐 %d 包裹 / %d 行(其中无需物流虚拟发货 %d 包裹);跳过 已处理%d 无单号%d 取消删除%d 标签%d 无商品%d',
|
||||
$scanned, $emitted, $total, $virtualEmitted, $skip['done'], $skip['notrack'], $skip['status'], $skip['label'], $skip['noitem']
|
||||
));
|
||||
}
|
||||
|
||||
@ -1212,32 +1232,26 @@ class ShippingReplicaSynchronizer
|
||||
*/
|
||||
private function withOutboundCarrier(string $io, array $r, array $pkg, int $pkgId): array
|
||||
{
|
||||
if ($io === '') {
|
||||
return $pkg;
|
||||
}
|
||||
$lc = $this->ioLc[$io] ?? '';
|
||||
if ($lc === '') {
|
||||
return $pkg;
|
||||
}
|
||||
$wms = $this->ioWms[$io] ?? '';
|
||||
$lc = $io !== '' ? ($this->ioLc[$io] ?? '') : '';
|
||||
$wms = $io !== '' ? ($this->ioWms[$io] ?? '') : '';
|
||||
$eLc = trim((string) ($r['logistic_company_name'] ?? ''));
|
||||
$eWh = trim((string) ($r['warehouse_name'] ?? ''));
|
||||
if ($lc === $eLc) {
|
||||
if ($wms !== '' && $eWh !== '' && $eWh !== '{未指定发货仓}') {
|
||||
$this->wmsNames[$wms][$eWh] = ($this->wmsNames[$wms][$eWh] ?? 0) + 1;
|
||||
}
|
||||
if ($lc !== '' && $lc !== $eLc) {
|
||||
$pkg['express_name1'] = $lc;
|
||||
}
|
||||
if (trim((string) ($pkg['express_name1'] ?? '')) === self::NO_LOGISTICS) {
|
||||
$pkg['warehouse_name'] = self::NO_LOGISTICS_WAREHOUSE;
|
||||
|
||||
return $pkg;
|
||||
}
|
||||
$pkg['express_name1'] = $lc;
|
||||
if ($wms !== '') {
|
||||
$name = $this->wmsName($wms);
|
||||
if ($name !== null) {
|
||||
$pkg['warehouse_name'] = $name;
|
||||
} else {
|
||||
$this->pendingWarehouse[$pkgId] = $wms;
|
||||
}
|
||||
if ($wms === '') {
|
||||
return $pkg;
|
||||
}
|
||||
// 快递公司一致的明细行用来学习 wms_co_id => 仓库名;仓库统一在扫描结束后按出库单发货仓回填
|
||||
if ($lc !== '' && $lc === $eLc && $eWh !== '' && $eWh !== '{未指定发货仓}') {
|
||||
$this->wmsNames[$wms][$eWh] = ($this->wmsNames[$wms][$eWh] ?? 0) + 1;
|
||||
}
|
||||
$this->pendingWarehouse[$pkgId] = $wms;
|
||||
|
||||
return $pkg;
|
||||
}
|
||||
@ -1249,6 +1263,12 @@ class ShippingReplicaSynchronizer
|
||||
}
|
||||
$names = $this->wmsNames[$wms];
|
||||
arsort($names);
|
||||
$top = (int) reset($names);
|
||||
$sum = array_sum($names);
|
||||
// 只在仓库名明确(样本>=3 且占比>=90%)时回填,避免一个发货主体对应多个仓库时误改
|
||||
if ($top < 3 || $top < 0.9 * $sum) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) array_key_first($names);
|
||||
}
|
||||
@ -1267,6 +1287,9 @@ class ShippingReplicaSynchronizer
|
||||
foreach ($byName as $name => $ids) {
|
||||
foreach (array_chunk($ids, 500) as $chunk) {
|
||||
$n += $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk)
|
||||
->where(function ($q): void {
|
||||
$q->whereNull('express_name1')->orWhere('express_name1', '!=', self::NO_LOGISTICS);
|
||||
})
|
||||
->where(function ($q) use ($name): void {
|
||||
$q->whereNull('warehouse_name')->orWhere('warehouse_name', '!=', $name);
|
||||
})
|
||||
@ -1535,6 +1558,7 @@ class ShippingReplicaSynchronizer
|
||||
$m['ioL'][$rio] = $l;
|
||||
$st = strtolower(trim((string) ($r['status'] ?? '')));
|
||||
$ok = ! in_array($st, ['delete', 'deleted', 'cancelled', 'cancel', 'canceled'], true);
|
||||
$this->ioOk[$rio] = $ok;
|
||||
$rk = ($ok ? '1' : '0') . '|' . (string) ($r['io_date'] ?? '') . '|' . str_pad($rio, 12, '0', STR_PAD_LEFT);
|
||||
if ($indexByL && $l !== '' && (! isset($m['rank']['L'][$l]) || strcmp($rk, $m['rank']['L'][$l]) > 0)) {
|
||||
$m['byL'][$l] = $rio;
|
||||
@ -1606,7 +1630,10 @@ class ShippingReplicaSynchronizer
|
||||
foreach ($rows as $r) {
|
||||
$lg = ltrim(trim((string) ($r['logistic_id'] ?? '')), '@');
|
||||
$oid = (string) ($r['inner_order_id'] ?? '');
|
||||
if ($lg === '' || $oid === '' || isset($ioMap['byL'][$lg]) || empty($ioMap['byOAll'][$oid])
|
||||
$byL = $ioMap['byL'][$lg] ?? null;
|
||||
// 快递明细单号只对应作废出库单时,视为已换单(工作平台显示有效出库单的新单号)
|
||||
$hasValidIo = $byL !== null && ($this->ioOk[(string) $byL] ?? true);
|
||||
if ($lg === '' || $oid === '' || $hasValidIo || empty($ioMap['byOAll'][$oid])
|
||||
|| in_array((string) ($r['jo_status'] ?? ''), ['Cancelled', 'Split', 'Merged'], true)) {
|
||||
continue;
|
||||
}
|
||||
@ -1629,7 +1656,18 @@ class ShippingReplicaSynchronizer
|
||||
$q[] = '@' . $c;
|
||||
}
|
||||
$ph = implode(',', array_fill(0, count($q), '?'));
|
||||
foreach ($this->selectWithReconnect("SELECT DISTINCT logistic_id FROM rpt.jst_order_express_details WHERE logistic_id IN ({$ph})", $q) as $x) {
|
||||
try {
|
||||
$found = $this->selectWithReconnect("SELECT DISTINCT logistic_id FROM rpt.jst_order_express_details WHERE logistic_id IN ({$ph})", $q);
|
||||
} catch (PDOException $e) {
|
||||
if (! $this->isTransientCursorFailure($e)) {
|
||||
throw $e;
|
||||
}
|
||||
// logistic_id 无索引,副本库繁忙时会超时:本页跳过换单配对(保留原单号),不中断整轮
|
||||
$this->pairingSkipped++;
|
||||
|
||||
return [];
|
||||
}
|
||||
foreach ($found as $x) {
|
||||
$inExpress[ltrim(trim((string) ($x['logistic_id'] ?? '')), '@')] = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1187,6 +1187,7 @@ class ShippingReplicaSynchronizerTest extends TestCase
|
||||
['l_id' => 'JT1', 'o_id' => 'o1', 'io_id' => '12', 'io_date' => '2026-09-01 10:17:16', 'status' => 'Confirmed', 'logistics_company' => '外采快递', 'wms_co_id' => 14138566],
|
||||
['l_id' => 'DP2', 'o_id' => 'o2', 'io_id' => '21', 'io_date' => '2026-09-02 09:00:00', 'status' => 'Confirmed', 'logistics_company' => '外采快递', 'wms_co_id' => 14138566],
|
||||
['l_id' => 'YT3', 'o_id' => 'o3', 'io_id' => '31', 'io_date' => '2026-09-02 09:00:00', 'status' => 'Confirmed', 'logistics_company' => '线下快递', 'wms_co_id' => 15137729],
|
||||
['l_id' => '', 'o_id' => 'o4', 'io_id' => '41', 'io_date' => '2026-09-02 09:00:00', 'status' => 'Confirmed', 'logistics_company' => '无需物流', 'wms_co_id' => 15170001],
|
||||
]);
|
||||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||||
$factory->method('forSource')->willReturn($upstream);
|
||||
@ -1194,37 +1195,80 @@ class ShippingReplicaSynchronizerTest extends TestCase
|
||||
$preload = new \ReflectionMethod($sync, 'preloadOutboundMap');
|
||||
$preload->setAccessible(true);
|
||||
$preload->invoke($sync, '2026-08-25 00:00:00', '2026-09-05 00:00:00');
|
||||
$map = (new \ReflectionProperty($sync, 'outboundMap'));
|
||||
$map = new \ReflectionProperty($sync, 'outboundMap');
|
||||
$map->setAccessible(true);
|
||||
$ioFor = new \ReflectionMethod($sync, 'ioFor');
|
||||
$ioFor->setAccessible(true);
|
||||
$this->assertSame('12', $ioFor->invoke($sync, $map->getValue($sync), 'JT1', 'o1'));
|
||||
$carrier = new \ReflectionMethod($sync, 'withOutboundCarrier');
|
||||
$carrier->setAccessible(true);
|
||||
$wmsName = new \ReflectionMethod($sync, 'wmsName');
|
||||
$wmsName->setAccessible(true);
|
||||
$pending = new \ReflectionProperty($sync, 'pendingWarehouse');
|
||||
$pending->setAccessible(true);
|
||||
|
||||
// 仓库名尚未学到:快递公司按出库单改,仓库待扫描结束后回填
|
||||
// 出库单快递公司不同:快递公司按出库单,仓库登记待回填
|
||||
$early = $carrier->invoke($sync, '31', ['logistic_company_name' => '霸州百世快运-雅柏', 'warehouse_name' => '工厂-河北雅柏代发仓'],
|
||||
['express_name1' => '霸州百世快运-雅柏', 'warehouse_name' => '工厂-河北雅柏代发仓'], 3);
|
||||
$this->assertSame('线下快递', $early['express_name1']);
|
||||
$this->assertSame('工厂-河北雅柏代发仓', $early['warehouse_name']);
|
||||
$pending = new \ReflectionProperty($sync, 'pendingWarehouse');
|
||||
$pending->setAccessible(true);
|
||||
$this->assertSame([3 => '15137729'], $pending->getValue($sync));
|
||||
$this->assertSame('15137729', $pending->getValue($sync)[3]);
|
||||
|
||||
// 快递公司一致的行不改,并学习 wms_co_id => 仓库名
|
||||
$same = $carrier->invoke($sync, '21', ['logistic_company_name' => '外采快递', 'warehouse_name' => '常州简尚家居有限公司'],
|
||||
['express_name1' => '外采快递', 'warehouse_name' => '常州简尚家居有限公司'], 2);
|
||||
$this->assertSame('外采快递', $same['express_name1']);
|
||||
|
||||
// 发货明细停在作废前的工厂代发分配:快递公司、仓库都按实际出库单
|
||||
// 发货明细停在作废前的工厂代发分配:快递公司按出库单;仓库按出库单发货主体回填
|
||||
$stale = $carrier->invoke($sync, '12', ['logistic_company_name' => '浩浦极兔', 'warehouse_name' => '工厂-河北浩浦代发仓'],
|
||||
['express_name1' => '浩浦极兔', 'warehouse_name' => '工厂-河北浩浦代发仓'], 1);
|
||||
$this->assertSame('外采快递', $stale['express_name1']);
|
||||
$this->assertSame('常州简尚家居有限公司', $stale['warehouse_name']);
|
||||
$this->assertSame('14138566', $pending->getValue($sync)[1]);
|
||||
$this->assertNull($wmsName->invoke($sync, '14138566')); // 样本不足不回填
|
||||
foreach ([20, 22, 23] as $id) {
|
||||
$carrier->invoke($sync, '21', ['logistic_company_name' => '外采快递', 'warehouse_name' => '常州简尚家居有限公司'],
|
||||
['express_name1' => '外采快递', 'warehouse_name' => '常州简尚家居有限公司'], $id);
|
||||
}
|
||||
$this->assertSame('常州简尚家居有限公司', $wmsName->invoke($sync, '14138566'));
|
||||
|
||||
// 出库单没有快递公司时保留发货明细
|
||||
$none = $carrier->invoke($sync, '', ['logistic_company_name' => '中通速递'], ['express_name1' => '中通速递', 'warehouse_name' => 'X'], 4);
|
||||
$this->assertSame('中通速递', $none['express_name1']);
|
||||
// 无需物流(补差/信誉/运费):仓库统一自动发货仓,不按发货主体回填
|
||||
$none = $carrier->invoke($sync, '41', ['logistic_company_name' => '', 'warehouse_name' => '工厂-山东祥和代发仓'],
|
||||
['express_name1' => '', 'warehouse_name' => '工厂-山东祥和代发仓'], 4);
|
||||
$this->assertSame('无需物流', $none['express_name1']);
|
||||
$this->assertSame('简尚-自动发货仓', $none['warehouse_name']);
|
||||
$this->assertArrayNotHasKey(4, $pending->getValue($sync));
|
||||
|
||||
// 同一发货主体对应多个仓库名(无明显多数)时不回填
|
||||
$names = new \ReflectionProperty($sync, 'wmsNames');
|
||||
$names->setAccessible(true);
|
||||
$names->setValue($sync, ['9' => ['仓A' => 5, '仓B' => 4]]);
|
||||
$this->assertNull($wmsName->invoke($sync, '9'));
|
||||
}
|
||||
|
||||
public function test_tracking_pairing_treats_deleted_only_outbound_as_changed_and_survives_lookup_timeout(): void
|
||||
{
|
||||
$calls = 0;
|
||||
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
|
||||
$upstream->method('select')->willReturnCallback(function () use (&$calls): array {
|
||||
$calls++;
|
||||
if ($calls === 1) {
|
||||
return [];
|
||||
}
|
||||
throw new PDOException('SQLSTATE[HY000]: General error: 20003 Adaptive Server connection timed out [20003] (severity 6)');
|
||||
});
|
||||
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
|
||||
$factory->method('forSource')->willReturn($upstream);
|
||||
$sync = new ShippingReplicaSynchronizer($factory);
|
||||
$ok = new \ReflectionProperty($sync, 'ioOk');
|
||||
$ok->setAccessible(true);
|
||||
$ok->setValue($sync, ['11' => false, '12' => true]);
|
||||
$pair = new \ReflectionMethod($sync, 'pairChangedTracking');
|
||||
$pair->setAccessible(true);
|
||||
$ioMap = ['byL' => ['DPK1' => '11', 'SF2' => '12'], 'byO' => ['o1' => '12'], 'byLO' => [], 'byOAll' => ['o1' => ['12' => 'SF2']], 'ioL' => ['11' => 'DPK1', '12' => 'SF2']];
|
||||
$rows = [['id' => 7, 'logistic_id' => 'DPK1', 'inner_order_id' => 'o1']];
|
||||
|
||||
$swap = $pair->invoke($sync, $rows, $ioMap);
|
||||
$this->assertSame(['io' => '12', 'l_id' => 'SF2', 'old' => 'DPK1'], $swap['7']);
|
||||
|
||||
// 副本库超时:本页跳过配对,不抛错
|
||||
$this->assertSame([], $pair->invoke($sync, $rows, $ioMap));
|
||||
$skipped = new \ReflectionProperty($sync, 'pairingSkipped');
|
||||
$skipped->setAccessible(true);
|
||||
$this->assertSame(1, $skipped->getValue($sync));
|
||||
}
|
||||
|
||||
private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user