From d8dfb33437839be6b605f645bf9465c2e9c831ae Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Mon, 12 Oct 2026 09:58:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=8F=91=E8=B4=A7=E6=95=B0=E6=8D=AE):=20?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E6=8C=89=E5=AE=9E=E9=99=85=E5=87=BA=E5=BA=93?= =?UTF-8?q?=E5=8D=95=E5=8F=91=E8=B4=A7=E4=BB=93=E3=80=81=E8=A1=A5=E5=85=A5?= =?UTF-8?q?=E6=97=A0=E9=9C=80=E7=89=A9=E6=B5=81=E8=99=9A=E6=8B=9F=E5=8F=91?= =?UTF-8?q?=E8=B4=A7=E3=80=81=E4=BD=9C=E5=BA=9F=E5=8D=95=E5=8F=B7=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E6=8D=A2=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 仓库一律按实际出库单 wms_co_id 映射(从快递公司一致的明细行学习仓库名,样本>=3 且占比>=90% 才回填), 修正发货明细写总仓而实际由工厂代发仓出库(如 710099981090 千优代发仓)的情况。 - 快递名称为"无需物流"(补差/信誉/运费等虚拟发货,如 X00006)的包裹仓库统一为"简尚-自动发货仓",与工作平台一致。 - 换单配对:快递明细单号只对应作废出库单时视为已换单,改用有效出库单的新单号(如 DPK366101313659 -> SF1549142274850)。 - 出库单兜底不再跳过"无需物流"的无单号出库单(信誉订单/补差价/运费专用等虚拟发货),按工作平台口径补入自动发货仓、快递单号为空; 9 月核对工作平台有、线上无的 565 条虚拟链接,抽查确认为此原因(出库单无单号被跳过)。 - 换单配对查询(logistic_id 无索引)副本库超时时跳过本页,不再中断整轮同步;结果返回 tracking_pairing_skipped_pages。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf --- .../Jushuitan/ShippingReplicaSynchronizer.php | 98 +++++++++++++------ .../ShippingReplicaSynchronizerTest.php | 76 +++++++++++--- 2 files changed, 128 insertions(+), 46 deletions(-) diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index 2b7a9e9f..b0e1175a 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -118,9 +118,19 @@ class ShippingReplicaSynchronizer /** @var array> wms_co_id => [仓库名 => 次数]:从快递公司一致的发货明细行学习仓库名 */ private array $wmsNames = []; - /** @var array 包裹 source_id => 待补仓库的 wms_co_id(扫描时尚未学到仓库名,扫描结束后统一回填) */ + /** @var array 包裹 source_id => 出库单 wms_co_id(扫描结束后按学到的仓库名统一回填) */ private array $pendingWarehouse = []; + /** @var array io_id => 是否有效出库单(非作废/取消) */ + private array $ioOk = []; + + /** 换单配对查询超时而跳过的页数(白天副本库繁忙时,不让单页超时中断整轮同步) */ + private int $pairingSkipped = 0; + + /** 工作平台口径:快递名称为"无需物流"(补差/信誉/运费等虚拟发货)的包裹,仓库统一显示自动发货仓 */ + private const NO_LOGISTICS = '无需物流'; + private const NO_LOGISTICS_WAREHOUSE = '简尚-自动发货仓'; + /** @var array 最近一次 loadViewItems 的 io_id => 视图店铺名(发货明细缺店铺时兜底) */ private array $ioShop = []; /** @var array 已查询订单元数据缓存 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; } } diff --git a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php index fb2e05b3..213392af 100644 --- a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -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