From 63799de416f9653cc2c6baef4e36761fc04ae9a5 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Thu, 8 Oct 2026 10:52:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=8F=91=E8=B4=A7=E6=95=B0=E6=8D=AE):=20?= =?UTF-8?q?=E5=BF=AB=E9=80=92=E5=85=AC=E5=8F=B8/=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E6=8C=89=E5=AE=9E=E9=99=85=E5=87=BA=E5=BA=93=E5=8D=95=E5=8F=96?= =?UTF-8?q?=EF=BC=8C=E5=90=8C=E6=AD=A5=E5=91=BD=E4=BB=A4=E5=86=85=E5=AD=98?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E8=B0=83=E8=87=B3=204G?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发货明细(jst_order_express_details)可能停在作废前的旧分配(如先分工厂代发仓、后改外采由总仓出库;或换单后仍是旧快递公司), 工作平台按实际出库单显示。快递公司与出库单不一致或为空时改用出库单 logistics_company, 仓库按出库单 wms_co_id 映射(从快递公司一致的明细行学习仓库名,扫描结束统一回填)。 - 9 月核对:快递名称不一致 434/331,612,仓库不一致 85,其中外采快递、线下快递、换单类为此原因。 - jushuitan:sync-shipping 内存上限 1536M -> 4096M:整月窗口重跑在扫描后段内存耗尽(退出码 255)。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf --- .../Console/SyncJushuitanShipping.php | 2 +- .../Jushuitan/ShippingReplicaSynchronizer.php | 107 +++++++++++++++++- .../ShippingReplicaSynchronizerTest.php | 49 ++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/modules/EcommerceFinance/Console/SyncJushuitanShipping.php b/modules/EcommerceFinance/Console/SyncJushuitanShipping.php index b14b5e00..611f7aa3 100644 --- a/modules/EcommerceFinance/Console/SyncJushuitanShipping.php +++ b/modules/EcommerceFinance/Console/SyncJushuitanShipping.php @@ -24,7 +24,7 @@ class SyncJushuitanShipping extends Command public function handle(): int { - @ini_set('memory_limit', '1536M'); + @ini_set('memory_limit', '4096M'); // 整月窗口重跑约需 2GB+,原 1536M 会在扫描后段内存耗尽 @set_time_limit(0); $from = $this->option('from') ? Carbon::parse($this->option('from')) : null; $to = $this->option('to') ? Carbon::parse($this->option('to')) : null; diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index 0f7a6dda..2b7a9e9f 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -109,6 +109,18 @@ class ShippingReplicaSynchronizer /** @var array io_id => 出库时间 io_date(工作平台发货时间口径) */ private array $ioDate = []; + /** @var array io_id => 出库单快递公司(工作平台按实际出库单取,发货明细可能停在作废前的旧分配) */ + private array $ioLc = []; + + /** @var array io_id => 出库单发货仓 wms_co_id */ + private array $ioWms = []; + + /** @var array> wms_co_id => [仓库名 => 次数]:从快递公司一致的发货明细行学习仓库名 */ + private array $wmsNames = []; + + /** @var array 包裹 source_id => 待补仓库的 wms_co_id(扫描时尚未学到仓库名,扫描结束后统一回填) */ + private array $pendingWarehouse = []; + /** @var array 最近一次 loadViewItems 的 io_id => 视图店铺名(发货明细缺店铺时兜底) */ private array $ioShop = []; /** @var array 已查询订单元数据缓存 o_id=>字段 */ @@ -169,6 +181,10 @@ class ShippingReplicaSynchronizer $this->doneTrackSo = []; $this->excludedSourceIds = []; $this->ioDate = []; + $this->ioLc = []; + $this->ioWms = []; + $this->wmsNames = []; + $this->pendingWarehouse = []; if ($windowed) { $this->preloadDws($from, $to); $this->preloadOutboundMap($fromS, $toS); @@ -181,6 +197,7 @@ class ShippingReplicaSynchronizer )); } $total = $this->syncExpress(true, $fromS, $toS, $limit, $now, $progress); + $this->applyPendingWarehouses($progress); $this->orderMetadata = []; // 全表扫描后释放(数百万订单),后续各步不再使用 gc_collect_cycles(); $auto = $this->syncAutoShip($fromS, $toS, $now, $progress); @@ -199,6 +216,7 @@ class ShippingReplicaSynchronizer } } else { $total = $this->syncExpress(false, $fromS, $toS, $limit, $now, $progress); + $this->applyPendingWarehouses($progress); } if ($windowed && ! $skipFreight && $this->staleMirrorPackages === 0) { @@ -455,6 +473,7 @@ class ShippingReplicaSynchronizer $shipTime = ($io !== '' ? $this->ts($this->ioDate[$io] ?? null) : null) ?? $this->ts($r['send_time'] ?? null) ?? $this->ts($r['jo_send'] ?? null); $pkg = $this->pkgFromExpress($r, $tracking, $shipTime); + $pkg = $this->withOutboundCarrier($io, $r, $pkg, $pkgId); $pkg['platform'] = $this->platformOf($io, $pkg['platform']); $pkg = $this->withViewAddr($io, $pkg); $total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now); @@ -1186,6 +1205,80 @@ class ShippingReplicaSynchronizer ]); } + /** + * 快递公司/仓库以实际出库单为准(工作平台口径):发货明细可能停在作废前的旧分配 + * (如先分给工厂代发仓、后改外采由总仓出库;或换单后仍是旧快递公司)。 + * 出库单快递公司与发货明细一致时不改,并借此学习 wms_co_id => 仓库名。 + */ + 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] ?? ''; + $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; + } + + return $pkg; + } + $pkg['express_name1'] = $lc; + if ($wms !== '') { + $name = $this->wmsName($wms); + if ($name !== null) { + $pkg['warehouse_name'] = $name; + } else { + $this->pendingWarehouse[$pkgId] = $wms; + } + } + + return $pkg; + } + + private function wmsName(string $wms): ?string + { + if (empty($this->wmsNames[$wms])) { + return null; + } + $names = $this->wmsNames[$wms]; + arsort($names); + + return (string) array_key_first($names); + } + + /** 扫描时还没学到仓库名的包裹:扫描结束后按学到的 wms_co_id => 仓库名统一回填。 */ + private function applyPendingWarehouses(?callable $progress = null): void + { + $byName = []; + foreach ($this->pendingWarehouse as $pkgId => $wms) { + $name = $this->wmsName($wms); + if ($name !== null) { + $byName[$name][] = $pkgId; + } + } + $n = 0; + 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) use ($name): void { + $q->whereNull('warehouse_name')->orWhere('warehouse_name', '!=', $name); + }) + ->update(['warehouse_name' => $name]); + } + } + if ($progress && $this->pendingWarehouse !== []) { + $progress(sprintf('出库单仓库回填:待补 %d 个包裹,更新 %d 行', count($this->pendingWarehouse), $n)); + } + $this->pendingWarehouse = []; + } + /** 发货明细表行 → 包裹级字段(省市区从地址拆) */ private function pkgFromExpress(array $r, ?string $tracking, ?string $shipTime): array { @@ -1390,7 +1483,7 @@ class ShippingReplicaSynchronizer /** 窗口同步按 io_date 一次预载,避免对无可用索引的 l_id/o_id 反复执行 IN 查询。 */ private function preloadOutboundMap(string $fromS, string $toS): void { - $sql = 'SELECT l_id, o_id, io_id, io_date, status FROM ' . self::OUTBOUND . ' WHERE io_date >= ? AND io_date < ?'; + $sql = 'SELECT l_id, o_id, io_id, io_date, status, logistics_company, wms_co_id FROM ' . self::OUTBOUND . ' WHERE io_date >= ? AND io_date < ?'; $this->outboundMap = $this->mapOutboundRows($this->selectWithReconnect($sql, [$fromS, $toS])); } @@ -1429,6 +1522,14 @@ class ShippingReplicaSynchronizer if (! empty($r['io_date'])) { $this->ioDate[$rio] = (string) $r['io_date']; } + $lc = trim((string) ($r['logistics_company'] ?? '')); + if ($lc !== '') { + $this->ioLc[$rio] = $lc; + } + $wms = trim((string) ($r['wms_co_id'] ?? '')); + if ($wms !== '' && $wms !== '0') { + $this->ioWms[$rio] = $wms; + } $l = ltrim(trim((string) ($r['l_id'] ?? '')), '@'); $o = (string) ($r['o_id'] ?? ''); $m['ioL'][$rio] = $l; @@ -1474,7 +1575,7 @@ class ShippingReplicaSynchronizer } $q = array_keys($q); $ph = implode(',', array_fill(0, count($q), '?')); - foreach ($this->selectWithReconnect('SELECT l_id, io_id, io_date, status, o_id FROM ' . self::OUTBOUND . " WHERE l_id IN ({$ph})", $q) as $r) { + foreach ($this->selectWithReconnect('SELECT l_id, io_id, io_date, status, o_id, logistics_company, wms_co_id FROM ' . self::OUTBOUND . " WHERE l_id IN ({$ph})", $q) as $r) { $this->addIoRow($map, $r, true, false); } } @@ -1483,7 +1584,7 @@ class ShippingReplicaSynchronizer continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); - foreach ($this->selectWithReconnect('SELECT o_id, io_id, io_date, l_id, status FROM ' . self::OUTBOUND . " WHERE o_id IN ({$ph})", $chunk) as $r) { + foreach ($this->selectWithReconnect('SELECT o_id, io_id, io_date, l_id, status, logistics_company, wms_co_id FROM ' . self::OUTBOUND . " WHERE o_id IN ({$ph})", $chunk) as $r) { $this->addIoRow($map, $r, false, true); } } diff --git a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php index d8d7d3e1..fb2e05b3 100644 --- a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -1178,6 +1178,55 @@ class ShippingReplicaSynchronizerTest extends TestCase $this->assertSame(0.0, $volume->invoke($sync, 10, 3, 2, 2.0, '普通仓', '中通礼品单')); } + public function test_stale_express_detail_carrier_and_warehouse_follow_the_actual_outbound(): void + { + $upstream = $this->createMock(UpstreamReadOnlyQuery::class); + $upstream->method('select')->willReturn([ + // 先分给工厂代发仓后作废,再由总仓按外采出库(实际发货单) + ['l_id' => 'JT1', 'o_id' => 'o1', 'io_id' => '11', 'io_date' => '2026-08-29 17:52:09', 'status' => 'Delete', 'logistics_company' => '浩浦极兔', 'wms_co_id' => 15170678], + ['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], + ]); + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->method('forSource')->willReturn($upstream); + $sync = new ShippingReplicaSynchronizer($factory); + $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->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); + + // 仓库名尚未学到:快递公司按出库单改,仓库待扫描结束后回填 + $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)); + + // 快递公司一致的行不改,并学习 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']); + + // 出库单没有快递公司时保留发货明细 + $none = $carrier->invoke($sync, '', ['logistic_company_name' => '中通速递'], ['express_name1' => '中通速递', 'warehouse_name' => 'X'], 4); + $this->assertSame('中通速递', $none['express_name1']); + } + private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void { $reflection = new \ReflectionProperty($synchronizer, $property);