fix(发货数据): 快递公司/仓库按实际出库单取,同步命令内存上限调至 4G

- 发货明细(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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf
This commit is contained in:
limeng 2026-10-08 10:52:10 +08:00
parent c515da02d0
commit 63799de416
3 changed files with 154 additions and 4 deletions

View File

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

View File

@ -109,6 +109,18 @@ class ShippingReplicaSynchronizer
/** @var array<string,string> io_id => 出库时间 io_date(工作平台发货时间口径) */
private array $ioDate = [];
/** @var array<string,string> io_id => 出库单快递公司(工作平台按实际出库单取,发货明细可能停在作废前的旧分配) */
private array $ioLc = [];
/** @var array<string,string> io_id => 出库单发货仓 wms_co_id */
private array $ioWms = [];
/** @var array<string,array<string,int>> wms_co_id => [仓库名 => 次数]:从快递公司一致的发货明细行学习仓库名 */
private array $wmsNames = [];
/** @var array<int,string> 包裹 source_id => 待补仓库的 wms_co_id(扫描时尚未学到仓库名,扫描结束后统一回填) */
private array $pendingWarehouse = [];
/** @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=>字段 */
@ -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);
}
}

View File

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