>> */ private array $replacementPackages = []; /** @var array */ private array $replacementHasFreight = []; /** @var array */ private array $replacementHasItems = []; /** @var array> */ private array $replacementSyntheticSkuIds = []; private int $replacementRowCount = 0; private int $packagesProcessed = 0; private int $packagesWithoutItems = 0; private int $packagesRetainedForMissingItems = 0; private int $packagesRetainedForUnmatchedAmount = 0; /** 本轮因商品已更正(新旧条码完全不同)而覆盖旧记录的包裹数;旧金额属于错配商品,不再阻止更新。 */ private int $packagesItemsCorrected = 0; /** 当前正在写入的包裹,商品是否取自出库单实发明细(发货数据视图)。 */ private bool $itemsFromView = false; /** @var array 待替换包裹 => 商品取自出库单实发明细 */ private array $replacementFromView = []; /** 本轮按条码沿用旧金额的明细行数(商品行 ID 变化但条码相同)。 */ private int $amountsMatchedByBarcode = 0; private int $staleMirrorPackages = 0; private int $supersededRemoved = 0; private int $outboundScanned = 0; private int $purchaseScanned = 0; /** @var array */ private array $seenSourceIds = []; /** @var array 发货明细已占用的窗口内订单号 */ private array $expressOrderIds = []; /** @var list */ private array $dwsFailedDays = []; private readonly \Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery $up; private readonly \Illuminate\Database\ConnectionInterface $db; /** @var array */ private array $buffer = []; /** @var array 窗口内 dws 销售全量商品缓存 sourceno=>items */ private array $dwsCache = []; /** @var array> 本批 交易单号|快递单号(去@) => [sku => 行数],来自聚水潭运费明细,按包裹记录实发商品 */ private array $fysPackageSkus = []; /** @var array 本批 交易单号 => 运费明细里的快递单号个数(去@),>1 即多包裹订单 */ private array $fysSoTrackCount = []; /** @var array 本轮 express/autoship 已处理(含按标签排除)的出库单 io_id */ private array $doneIo = []; /** @var array 本轮已处理的快递单号(去@) */ private array $doneTrack = []; /** 已实际输出的快递单号(去@),用于判断虚拟/补差单是否借用了别的包裹单号 */ private array $emittedTrack = []; /** @var array 本次已吐出包裹的交易单号 */ private array $emittedSo = []; /** 快递单号(去@) => [交易单号 => true]:同一单号被不同交易单号共用(合包/拼单)时,按交易单号区分是否已处理 */ private array $doneTrackSo = []; /** @var array 本轮按规则排除的包裹 source_id(取消/拆分/合并/线上发货),窗口内旧记录需清掉 */ private array $excludedSourceIds = []; /** @var array 最近一次 loadViewItems 的 io_id => 平台(视图 shop_site,工作平台口径) */ private array $ioSite = []; /** @var array 最近一次 loadViewItems 的 io_id => 视图收货省市区(工作平台口径) */ private array $ioAddr = []; /** @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 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 最近一次 loadViewItems 的 io_id => 视图快递名称 expressName(工作平台显示的快递名称) */ private array $ioExpress = []; /** @var array 最近一次 loadViewItems 的 io_id => 视图发货仓 wareHoursName(工作平台显示的仓库) */ private array $ioWare = []; /** @var array 已查询订单元数据缓存 o_id=>字段 */ private array $orderMetadata = []; /** @var array 窗口内出库单映射(见 emptyIoMap) */ private array $outboundMap = ['byL' => [], 'byO' => [], 'byLO' => [], 'byOAll' => [], 'ioL' => []]; public function __construct(UpstreamReadOnlyQueryFactory $factory) { $this->up = $factory->forSource(self::SOURCE); $this->db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local')); $this->db->disableQueryLog(); } public static function make(): self { return new self(app(UpstreamReadOnlyQueryFactory::class)); } public function sync(?Carbon $from = null, ?Carbon $to = null, int $limit = 2000, ?callable $progress = null, bool $skipFreight = false): array { $this->markRunning(); try { $windowed = $from && $to; if ($skipFreight && ! $windowed) { throw new InvalidArgumentException('跳过运费仅支持指定起止日期的历史补录。'); } $this->skipFreight = $skipFreight; $this->replaceWindow = (bool) $windowed; $this->packagesProcessed = 0; $this->packagesWithoutItems = 0; $this->packagesRetainedForMissingItems = 0; $this->packagesRetainedForUnmatchedAmount = 0; $this->packagesItemsCorrected = 0; $this->amountsMatchedByBarcode = 0; $this->staleMirrorPackages = 0; $this->shipTimeFixed = 0; $this->weightFixed = 0; $this->placeholderPurged = 0; $this->supersededRemoved = 0; $this->outboundScanned = 0; $this->purchaseScanned = 0; $this->seenSourceIds = []; $this->expressOrderIds = []; $this->dwsFailedDays = []; $now = now(); $fromS = $windowed ? $from->format('Y-m-d H:i:s') : null; $toS = $windowed ? $to->format('Y-m-d H:i:s') : null; if ($skipFreight) { $this->markFreightIncomplete($fromS, $toS); } $this->db->table('jst_oms_shipping')->whereIn('source_id', self::DEMO_SOURCE_IDS)->delete(); if ($skipFreight && $progress) { $progress('仅补数量:按包裹分批替换,已有运费/结算重量在事务中保留;不预删日期窗口'); } $total = 0; $auto = 0; $outb = 0; $pout = 0; $this->doneIo = []; $this->doneTrack = []; $this->emittedTrack = []; $this->emittedSo = []; $this->doneTrackSo = []; $this->excludedSourceIds = []; $this->ioDate = []; $this->ioLc = []; $this->ioWms = []; $this->wmsNames = []; $this->pendingWarehouse = []; $this->ioOk = []; $this->pairingSkipped = 0; if ($windowed) { $this->preloadDws($from, $to); $this->preloadOutboundMap($fromS, $toS); if ($progress) { $progress('dws 预载完成:' . count($this->dwsCache) . ' 个 sourceno'); $progress(sprintf( '出库映射预载完成:%d 个快递单号,%d 个内部单号', count($this->outboundMap['byL']), count($this->outboundMap['byO']), )); } $total = $this->syncExpress(true, $fromS, $toS, $limit, $now, $progress); $this->applyPendingWarehouses($progress); $this->orderMetadata = []; // 全表扫描后释放(数百万订单),后续各步不再使用 gc_collect_cycles(); $auto = $this->syncAutoShip($fromS, $toS, $now, $progress); $outb = $this->syncOutbound($fromS, $toS, $now, $progress); $pout = $this->syncPurchaseOut($fromS, $toS, $now, $progress); $purged = $this->purgeExcludedPackages($fromS, $toS); if ($progress && $purged > 0) { $progress('清除已排除包裹(取消/拆分/合并/线上发货):' . $purged . ' 行'); } $this->staleMirrorPackages = $this->countUnseenMirrorPackages($fromS, $toS); if (! $skipFreight && $this->staleMirrorPackages === 0) { $removed = $this->dedupAtPrefix($fromS, $toS); if ($progress && $removed > 0) { $progress('去除@重复单号:' . $removed . ' 条'); } } try { $this->placeholderPurged = $this->purgeZeroQtyPlaceholders($fromS, $toS); $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 . ' 个包裹'); } if ($progress && $this->placeholderPurged > 0) { $progress('清理订单占位商品行(数量0):' . $this->placeholderPurged . ' 个包裹'); } } catch (Throwable $e) { if ($progress) { $progress('发货时间校正跳过:' . $e->getMessage()); } } } else { $total = $this->syncExpress(false, $fromS, $toS, $limit, $now, $progress); $this->applyPendingWarehouses($progress); } if ($windowed && ! $skipFreight && $this->staleMirrorPackages === 0) { $this->markCovered(self::FREIGHT_DATASET_PREFIX, $fromS, $toS); } if ($windowed && ($this->dwsFailedDays !== [] || $this->packagesWithoutItems > 0 || $this->packagesRetainedForUnmatchedAmount > 0)) { $this->markItemsIncomplete($fromS, $toS); } elseif ($windowed && ! $skipFreight && $this->staleMirrorPackages === 0) { $this->markCovered(self::ITEMS_DATASET_PREFIX, $fromS, $toS); } $result = ['processed' => $total, 'autoship' => $auto]; if ($windowed) { $result['outbound'] = $outb; $result['purchase_out'] = $pout; $result['packages'] = $this->packagesProcessed; $result['unique_packages'] = count($this->seenSourceIds); $result['packages_without_items'] = $this->packagesWithoutItems; $result['packages_retained_for_missing_items'] = $this->packagesRetainedForMissingItems; $result['packages_retained_for_unmatched_amount'] = $this->packagesRetainedForUnmatchedAmount; $result['packages_items_corrected'] = $this->packagesItemsCorrected; $result['amounts_matched_by_barcode'] = $this->amountsMatchedByBarcode; $result['dws_failed_days'] = $this->dwsFailedDays; $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; $result['placeholder_packages_cleaned'] = $this->placeholderPurged; } if ($skipFreight) { $result['freight_incomplete'] = true; } if ($this->staleMirrorPackages > 0) { $this->markIncompleteWindow($this->staleMirrorPackages); } else { $this->markSucceeded(); } return $result; } catch (Throwable $e) { $this->markFailed($e->getMessage()); throw $e; } finally { $this->dwsCache = []; $this->orderMetadata = []; $this->outboundMap = ['byL' => [], 'byO' => [], 'byLO' => [], 'byOAll' => [], 'ioL' => []]; $this->replacementPackages = []; $this->replacementHasFreight = []; $this->replacementHasItems = []; $this->replacementSyntheticSkuIds = []; $this->replacementFromView = []; $this->itemsFromView = false; $this->replacementRowCount = 0; $this->skipFreight = false; $this->replaceWindow = false; $this->dwsFailedDays = []; $this->seenSourceIds = []; $this->expressOrderIds = []; gc_collect_cycles(); } } /** @X 与 X 仅在同一订单、同一发货日去重;提供日期窗口时不使用窗口外记录作匹配;剩余 @ 前缀统一去掉。 */ public function dedupAtPrefix(?string $fromS = null, ?string $toS = null): int { $query = $this->db->table('jst_oms_shipping') ->whereNotNull('express_no')->where('express_no', '!=', ''); if ($fromS !== null && $toS !== null) { $query->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS); } $clean = []; $prefixed = []; foreach ($query->distinct()->cursor(['source_id', 'trade_no', 'express_no', 'ship_time']) as $row) { $tradeNo = trim((string) $row->trade_no); $expressNo = (string) $row->express_no; $sourceId = (int) $row->source_id; $shipDate = substr((string) $row->ship_time, 0, 10); if ($tradeNo === '' || $sourceId <= 0 || $shipDate === '') { continue; } if ($expressNo[0] === '@') { $prefixed[$sourceId] = [$tradeNo, $shipDate, substr($expressNo, 1)]; } else { $clean[$tradeNo][$shipDate][$expressNo] = true; } } $toDel = []; foreach ($prefixed as $sourceId => [$tradeNo, $shipDate, $expressNo]) { if ($expressNo !== '' && isset($clean[$tradeNo][$shipDate][$expressNo])) { $toDel[] = $sourceId; } } $removed = 0; foreach (array_chunk($toDel, 500) as $chunk) { $delete = $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk); if ($fromS !== null && $toS !== null) { $delete->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS); } $removed += (int) $delete->delete(); } // 剩余 @X:工作平台导出单号不带 @,统一去掉前缀(只处理本次窗口) $strip = $this->db->table('jst_oms_shipping')->where('express_no', 'like', '@%'); if ($fromS !== null && $toS !== null) { $strip->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS); } $strip->update(['express_no' => $this->db->raw('SUBSTR(express_no, 2)')]); return $removed; } /** 主口径:发货单(dlv 镜像) × 每件商品。 */ private function syncExpress(bool $windowed, ?string $fromS, ?string $toS, int $limit, $now, ?callable $progress = null): int { $batch = 1500; $maxBatches = $this->maxExpressBatches; $cols = 'e.id, e.so_id, e.inner_order_id, e.logistic_id, e.logistic_company_name, e.send_time, e.pay_time, ' . 'e.shop_name, e.platform_name, e.dis_co_name, e.warehouse_name, e.receiver_address, ' . 'e.weight, e.f_weight, e.buyer_message, e.order_labels'; // 窗口模式:只按主键 e.id 翻页(有索引,毫秒级),send_time 窗口过滤放到 PHP, // 避免每页都对无索引的 send_time 做全表扫描(那是"卡死"的真凶)。 $fromTs = $fromS !== null ? strtotime($fromS) : null; $toTs = $toS !== null ? strtotime($toS) : null; $total = 0; $scanned = 0; $lastId = null; for ($b = 0; $b <= $maxBatches; $b++) { $where = $windowed ? '1 = 1' : 'e.send_time IS NOT NULL'; $bind = []; if ($lastId !== null) { $where .= ' AND e.id < ?'; $bind[] = $lastId; } $sql = "SELECT TOP {$batch} {$cols} FROM rpt.jst_order_express_details e " . "WHERE {$where} ORDER BY e.id DESC"; $rows = $this->selectWithReconnect($sql, $bind); if ($rows === []) { break; } if ($b === $maxBatches) { throw new RuntimeException('发货扫描达到批次上限,仍有未读取的上游数据。'); } $cnt = count($rows); $scanned += $cnt; foreach ($rows as $r) { $lastId = $r['id']; } // 主表分页不联接订单表;每页只按 inner_order_id 分块补齐订单字段, // 并复用已经缓存的结果,避免 LEFT JOIN 在大历史窗口上拖垮游标。 $this->loadOrderMetadataForRows($rows); $rows = array_map(fn (array $row): array => $this->mergeOrderMetadata($row), $rows); if ($windowed) { $kept = []; foreach ($rows as $r) { $jd = $r['jo_send'] ?? null; $joTs = ($jd !== null && $jd !== '') ? strtotime((string) $jd) : null; $oid = (string) ($r['inner_order_id'] ?? ''); if ($oid !== '' && $joTs !== null && $joTs !== false && $joTs >= $fromTs && $joTs < $toTs) { $this->expressOrderIds[$oid] = true; } $st = $r['send_time'] ?? null; $ts = ($st !== null && $st !== '') ? strtotime((string) $st) : null; if ($ts === null) { $ts = $joTs; } if ($ts !== null && $ts >= $fromTs && $ts < $toTs) { $kept[] = $r; } } $rows = $kept; } $oids = []; $soIds = []; $logs = []; foreach ($rows as $r) { $so = (string) ($r['so_id'] ?? ''); if ($so !== '') { $soIds[$so] = true; } $oid = (string) ($r['inner_order_id'] ?? ''); if ($oid !== '') { $oids[$oid] = true; } $lg = (string) ($r['logistic_id'] ?? ''); if ($lg !== '') { $logs[$lg] = true; } } [$byOid, $bySo] = $this->loadAllItems(array_keys($oids), array_keys($soIds), ! $windowed); $dws = []; foreach (array_keys($soIds) as $mso) { if (empty($bySo[$mso]) && ! empty($this->dwsCache[$mso])) { $dws[$mso] = $this->dwsCache[$mso]; } } // 主口径:发货数据视图按出库单(io_id)取实发商品 $ioMap = $this->ioMapFor($windowed, array_keys($logs), array_keys($oids)); $swap = $this->pairChangedTracking($rows, $ioMap); $ioIds = []; foreach ($rows as $r) { $io = isset($swap[(string) ($r['id'] ?? '')]) ? $swap[(string) $r['id']]['io'] : $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), (string) ($r['inner_order_id'] ?? '')); if ($io !== '') { $ioIds[$io] = true; } } $viewItems = $this->loadViewItems(array_keys($ioIds)); $fysItems = $this->loadFysPackageSkus(array_keys($soIds)); $skus = $this->loadSkus($this->collectSkuIds($byOid, $bySo, $dws, $viewItems, $fysItems)); $freight = $this->skipFreight ? [] : $this->loadFreight(array_keys($soIds)); foreach ($rows as $r) { $pkgId = (int) ($r['id'] ?? 0); if ($pkgId === 0) { continue; } if (in_array((string) ($r['jo_status'] ?? ''), ['Cancelled', 'Split', 'Merged'], true)) { // 订单已取消/已拆分/已合并:实际发货在子单或合并单上,工作平台不出此行。 // 只登记本行自己的单号,不登记订单单号(可能是子单真实单号,需留给出库单兜底) $this->markDone('', (string) ($r['logistic_id'] ?? '')); $this->markTrackSo((string) ($r['so_id'] ?? ''), (string) ($r['logistic_id'] ?? '')); $this->excludeSource($pkgId); continue; } $sw = $swap[(string) $pkgId] ?? null; if ($sw !== null) { // 换单:快递明细仍是旧单号,出库单已换成新单号 → 用新单号与对应出库单(工作平台口径) $r['logistic_id'] = $sw['l_id']; } $this->markDone( $sw !== null ? $sw['io'] : $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), (string) ($r['inner_order_id'] ?? '')), (string) ($r['logistic_id'] ?? ''), (string) ($r['jo_lid'] ?? ''), $sw !== null ? $sw['old'] : '' ); $this->markTrackSo((string) ($r['so_id'] ?? ''), (string) ($r['logistic_id'] ?? ''), (string) ($r['jo_lid'] ?? '')); if ($this->excludedByLabel($r['order_labels'] ?? null) || $this->excludedByLabel($r['jo_labels'] ?? null)) { $this->excludeSource($pkgId); continue; // 线上发货(平台代发/云仓):工作平台不拉,对齐排除(发货明细标签可能滞后,订单标签一并判断) } $so = (string) ($r['so_id'] ?? ''); $oid = (string) ($r['inner_order_id'] ?? ''); $io = $sw !== null ? $sw['io'] : $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), $oid); $items = $this->packageItems($io, $viewItems, $byOid, $bySo, $dws, $oid, $so, (string) ($r['logistic_id'] ?? '')); if (trim((string) ($r['logistic_id'] ?? '')) === '') { // 发货明细无单号:出库单单号 → 订单单号 → 空(不借运费表里同订单其它包裹的单号) $ioLid = ($io !== '') ? (string) ($ioMap['ioL'][$io] ?? '') : ''; $joLid = trim((string) ($r['jo_lid'] ?? '')); $tracking = $ioLid !== '' ? $ioLid : ($joLid !== '' ? $joLid : null); } else { $tracking = $this->tracking((string) ($r['logistic_id'] ?? ''), (string) ($r['jo_lid'] ?? ''), $freight, $so); } // 发货时间 = 出库单 io_date(工作平台口径,样本 400/400 一致),缺失时回落发货明细时间 $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 = $this->withViewCarrier($io, $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); } unset($rows, $byOid, $bySo, $dws, $skus, $freight); if ($b % 20 === 0) { gc_collect_cycles(); } if ($progress && $b % 20 === 0) { $progress(sprintf('发货:已扫描 %d 行,命中窗口 %d 件', $scanned, $total)); } if ($cnt < $batch) { break; } if (! $windowed && $total >= $limit) { break; } } if ($progress) { $progress(sprintf('发货完成:扫描 %d 行,处理 %d 件', $scanned, $total)); } $this->flushPending(); return $total; } /** 按当前页订单键补齐订单字段,并复用已缓存的结果。 */ private function loadOrderMetadataForRows(array $rows): void { $oids = []; foreach ($rows as $row) { $oid = (string) ($row['inner_order_id'] ?? ''); if ($oid !== '' && ! array_key_exists($oid, $this->orderMetadata)) { $oids[$oid] = true; } } foreach (array_chunk(array_keys($oids), 800) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); $sql = "SELECT o_id, l_id, send_date, status, labels FROM rpt.jst_Orders om WHERE om.o_id IN ({$ph})"; foreach ($this->selectWithReconnect($sql, $chunk) as $row) { $oid = (string) ($row['o_id'] ?? ''); if ($oid === '') { continue; } // 全表扫描会缓存数百万订单:只留判断需要的精简字段(状态仅存会被排除的三种,标签只存是否线上发货) $st = (string) ($row['status'] ?? ''); $this->orderMetadata[$oid] = [ 'l_id' => $row['l_id'] ?? null, 'send_date' => $row['send_date'] ?? null, 'status' => in_array($st, ['Cancelled', 'Split', 'Merged'], true) ? $st : null, 'online' => $this->excludedByLabel($row['labels'] ?? null), ]; } foreach ($chunk as $oid) { // 记录无匹配订单,避免后续分页反复查询同一个缺失键。 $this->orderMetadata[$oid] ??= ['l_id' => null, 'send_date' => null, 'status' => null, 'online' => false]; } } } /** 将订单元数据映射到发货明细,保留测试/兼容来源已提供的字段。 */ private function mergeOrderMetadata(array $row): array { $oid = (string) ($row['inner_order_id'] ?? ''); $metadata = $oid !== '' ? ($this->orderMetadata[$oid] ?? []) : []; if (($row['jo_lid'] ?? '') === '' && ($metadata['l_id'] ?? '') !== '') { $row['jo_lid'] = $metadata['l_id']; } if (($row['jo_send'] ?? '') === '' && ($metadata['send_date'] ?? '') !== '') { $row['jo_send'] = $metadata['send_date']; } // 订单状态/标签:取消/拆分/合并 与 订单级"线上发货"标签的排除判断用 if (($row['jo_status'] ?? '') === '' && ($metadata['status'] ?? '') !== '') { $row['jo_status'] = $metadata['status']; } if (($row['jo_labels'] ?? '') === '' && ! empty($metadata['online'])) { $row['jo_labels'] = '线上发货'; } return $row; } /** @return list> */ private function selectWithReconnect(string $sql, array $bindings): array { for ($retry = 0; ; $retry++) { try { return $this->up->select($sql, $bindings); } catch (PDOException $e) { if (! $this->isTransientCursorFailure($e) || $retry >= self::UPSTREAM_RECONNECT_RETRIES) { throw $e; } // select() returns only after fetchAll(), so no partial result was consumed. UpstreamConnection::reset(self::SOURCE); if (! app()->runningUnitTests()) { usleep(1000 * (self::UPSTREAM_RECONNECT_BACKOFF_MS[$retry] ?? 30000)); } } } } private function isTransientCursorFailure(PDOException $e): bool { $message = strtolower($e->getMessage()); return str_contains($message, 'pdo_dblib: dbnextrow() returned fail') || str_contains($message, 'adaptive server connection timed out [20003]'); } /** 并入自动发货仓:jst_Orders 已发货但不在发货明细表。 */ private function syncAutoShip(string $fromS, string $toS, $now, ?callable $progress = null): int { $batch = 400; $maxBatches = $this->maxAutoShipBatches; $cols = 'o.Id AS rid, o.o_id, o.so_id, o.l_id, o.logistics_company, o.order_from, o.labels, ' . 'o.shop_name, o.send_date, o.pay_date, o.weight, o.buyer_message, ' . 'o.receiver_state, o.receiver_city, o.receiver_district, o.receiver_address, ' . 'o.receiver_name, o.receiver_mobile, o.receiver_phone'; $total = 0; $lastRid = null; for ($b = 0; $b <= $maxBatches; $b++) { $where = "o.status = 'Sent' AND o.send_date >= ? AND o.send_date < ?"; $bind = [$fromS, $toS]; if ($lastRid !== null) { $where .= ' AND o.Id < ?'; $bind[] = $lastRid; } $sql = "SELECT TOP {$batch} {$cols} FROM rpt.jst_Orders o WHERE {$where} ORDER BY o.Id DESC"; $rows = $this->selectWithReconnect($sql, $bind); if ($rows === []) { break; } if ($b === $maxBatches) { throw new RuntimeException('代发仓扫描达到批次上限,仍有未读取的上游数据。'); } $cnt = count($rows); $eligible = []; foreach ($rows as $r) { $lastRid = $r['rid']; $oid = (string) ($r['o_id'] ?? ''); if ($oid !== '' && ! isset($this->expressOrderIds[$oid])) { $eligible[] = $r; } } $rows = $eligible; $oids = []; $soIds = []; $logs = []; foreach ($rows as $r) { $so = (string) ($r['so_id'] ?? ''); if ($so !== '') { $soIds[$so] = true; } $oid = (string) ($r['o_id'] ?? ''); if ($oid !== '') { $oids[$oid] = true; } $lg = (string) ($r['l_id'] ?? ''); if ($lg !== '') { $logs[$lg] = true; } } [$byOid, $bySo] = $this->loadAllItems(array_keys($oids), array_keys($soIds), false); $dws = []; foreach (array_keys($soIds) as $mso) { if (empty($bySo[$mso]) && ! empty($this->dwsCache[$mso])) { $dws[$mso] = $this->dwsCache[$mso]; } } $ioMap = $this->ioMapFor(true, array_keys($logs), array_keys($oids)); $ioIds = []; foreach ($rows as $r) { $io = $this->ioFor($ioMap, (string) ($r['l_id'] ?? ''), (string) ($r['o_id'] ?? '')); if ($io !== '') { $ioIds[$io] = true; } } $viewItems = $this->loadViewItems(array_keys($ioIds)); $fysItems = $this->loadFysPackageSkus(array_keys($soIds)); $skus = $this->loadSkus($this->collectSkuIds($byOid, $bySo, $dws, $viewItems, $fysItems)); $freight = $this->skipFreight ? [] : $this->loadFreight(array_keys($soIds)); foreach ($rows as $r) { $oid = (string) ($r['o_id'] ?? ''); if ($oid === '') { continue; } // 单号已属于其它包裹(发货明细已出):本单是虚拟/补差单,工作平台此类行快递单号为空 $tracking = $this->tracking((string) ($r['l_id'] ?? ''), '', $freight, (string) ($r['so_id'] ?? '')); if ($tracking !== null && isset($this->emittedTrack[ltrim(trim($tracking), '@')])) { $tracking = null; } $this->markDone($this->ioFor($ioMap, (string) ($r['l_id'] ?? ''), $oid), (string) ($r['l_id'] ?? '')); $this->markTrackSo((string) ($r['so_id'] ?? ''), (string) ($r['l_id'] ?? '')); if ($this->excludedByLabel($r['labels'] ?? null)) { $this->excludeSource(self::AUTOSHIP_OFFSET + (int) $oid); continue; // 线上发货:对齐排除 } $so = (string) ($r['so_id'] ?? ''); $io = $this->ioFor($ioMap, (string) ($r['l_id'] ?? ''), $oid); $items = $this->packageItems($io, $viewItems, $byOid, $bySo, $dws, $oid, $so, (string) ($r['l_id'] ?? '')); $shipTime = ($io !== '' ? $this->ts($this->ioDate[$io] ?? null) : null) ?? $this->ts($r['send_date'] ?? null); $pkg = $this->pkgFromOrder($r, $tracking, $shipTime); $pkg['platform'] = $this->platformOf($io, $pkg['platform']); $pkg = $this->withViewAddr($io, $pkg); $pkgId = self::AUTOSHIP_OFFSET + (int) $oid; $pkg = $this->withViewCarrier($io, $pkg, $pkgId); $total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now, true); } unset($rows, $byOid, $bySo, $dws, $skus, $freight); if ($b % 30 === 0) { gc_collect_cycles(); } if ($cnt < $batch) { break; } } if ($progress) { $progress(sprintf('代发仓完成:处理 %d 件', $total)); } $this->flushPending(); return $total; } /** * 平台统一为工作平台口径:优先取发货数据视图 shop_site;视图没有时把原始平台名映射到工作平台归类名。 */ private function platformOf(string $io, mixed $raw): ?string { if ($io !== '' && isset($this->ioSite[$io])) { return $this->ioSite[$io]; } $raw = trim((string) ($raw ?? '')); if ($raw === '') { return null; } $base = strtoupper(explode(',', $raw)[0]); $map = [ '天猫' => '淘宝天猫', '淘宝' => '淘宝天猫', '京东' => '京东商城', 'MOBILE' => '京东商城', 'PC' => '京东商城', '1688' => '阿里巴巴', '抖音' => '头条放心购', 'TOUTIAOFXG' => '头条放心购', 'PINDUODUO' => '拼多多', 'DEWU' => '得物', '线下' => '线下店铺', ]; return $map[$base] ?? $raw; } /** 记下按规则排除的包裹;同步结束后清掉它在本窗口里的旧记录(此前版本可能写入过)。 */ private function excludeSource(int $sourceId): void { if ($sourceId > 0) { $this->excludedSourceIds[$sourceId] = true; } } /** * 清除本窗口内"本轮明确排除"的包裹旧记录,并视为已扫描(不计入未见镜像包裹)。 * 只删明确排除的 source_id,未出现在上游扫描里的其它旧记录仍按原逻辑保留待核。 */ private function purgeExcludedPackages(string $fromS, string $toS): int { $this->flushPending(); $ids = []; foreach (array_keys($this->excludedSourceIds) as $id) { if (! isset($this->seenSourceIds[$id])) { $ids[] = $id; $this->seenSourceIds[$id] = true; } } $removed = 0; foreach (array_chunk($ids, 500) as $chunk) { $removed += (int) $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk) ->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS)->delete(); } $this->excludedSourceIds = []; return $removed; } /** 登记 单号×交易单号 已处理。 */ private function markTrackSo(string $so, string ...$tracks): void { $so = trim($so); foreach ($tracks as $t) { $t = ltrim(trim($t), '@'); if ($t !== '') { $this->doneTrackSo[$t][$so] = true; } } } /** 登记已处理的出库单/快递单号,供兜底 pass 防重复。 */ private function markDone(string $io, string ...$tracks): void { if ($io !== '') { $this->doneIo[$io] = true; } foreach ($tracks as $t) { $t = ltrim(trim($t), '@'); if ($t !== '') { $this->doneTrack[$t] = true; } } } /** * 兜底:出库单(按 io_date 窗口)里 express/autoship 都没处理过的包裹,按发货数据视图补吐。 * 典型:工厂代发仓/自有仓出库单无订单行、或同订单其它包裹在发货明细而本包裹不在。 * 防重复按 io_id + 快递单号(去@),不按订单号。 */ private function syncOutbound(string $fromS, string $toS, $now, ?callable $progress = null): int { $mm = $this->selectWithReconnect('SELECT MIN(Id) AS lo, MAX(Id) AS hi FROM ' . self::OUTBOUND . ' WHERE io_date >= ? AND io_date < ?', [$fromS, $toS]); $lo = (int) ($mm[0]['lo'] ?? 0); $hi = (int) ($mm[0]['hi'] ?? 0); if ($lo <= 0 || $hi < $lo) { if ($progress) { $progress('出库单兜底:窗口内无出库单'); } return 0; } $step = max(5000, (int) ceil(($hi - $lo + 1) / 400)); $cols = 'io_id, o_id, so_id, l_id, io_date, pay_date, shop_name, labels, status, logistics_company, weight, ' . 'buyer_message, receiver_state, receiver_city, receiver_district, receiver_phone'; $total = 0; $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) { $page++; $rows = $this->selectWithReconnect( "SELECT {$cols} FROM " . self::OUTBOUND . ' WHERE Id >= ? AND Id < ? AND io_date >= ? AND io_date < ?', [$a, $a + $step, $fromS, $toS] ); $scanned += count($rows); $this->outboundScanned += count($rows); $pick = []; foreach ($rows as $r) { $io = (string) ($r['io_id'] ?? ''); if ($io === '' || isset($seen[$io])) { continue; } $seen[$io] = true; $lid = trim((string) ($r['l_id'] ?? '')); // 无单号出库单一般跳过;但"无需物流"的虚拟发货(信誉/补差/运费等)工作平台收在自动发货仓、快递单号为空,需补吐 $virtual = trim((string) ($r['logistics_company'] ?? '')) === self::NO_LOGISTICS; if ($lid === '' && ! $virtual) { $skip['notrack']++; continue; } if (isset($this->doneIo[$io])) { $skip['done']++; continue; } // 单号已处理过:若是别的交易单号共用此单号(合包/拼单),仍要补吐;交易单号要等视图表头加载后判断 $r['_trk_done'] = $lid !== '' && isset($this->doneTrack[ltrim($lid, '@')]); $st = (string) ($r['status'] ?? ''); if (stripos($st, 'cancel') !== false || stripos($st, 'delete') !== false) { $skip['status']++; continue; } if ($this->excludedByLabel($r['labels'] ?? null)) { $skip['label']++; continue; } $pick[$io] = $r; } if ($pick !== []) { $ioIds = array_map('strval', array_keys($pick)); $viewItems = $this->loadViewItems($ioIds); $heads = $this->loadViewHeads($ioIds); $soIds = []; foreach ($pick as $pio => $r) { $so = $this->outboundSo($r, $heads[(string) $pio] ?? []); if ($so !== '') { $soIds[$so] = true; } } $skus = $this->loadSkus($this->collectSkuIds($viewItems)); $freight = $this->skipFreight ? [] : $this->loadFreight(array_map('strval', array_keys($soIds))); foreach ($pick as $io => $r) { $io = (string) $io; if (empty($viewItems[$io])) { $skip['noitem']++; continue; } $h = $heads[$io] ?? []; $so = $this->outboundSo($r, $h); $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])) { $skip['done']++; continue; } } $pkg = $this->pkgFromOutbound($r, $tracking !== '' ? $tracking : null, $this->ts($r['io_date'] ?? null), $h, $so); $pkg = $this->withViewAddr($io, $pkg); $pkg = $this->withViewCarrier($io, $pkg, self::OUTBOUND_OFFSET + (int) $io); if (trim((string) ($pkg['express_name1'] ?? '')) === self::NO_LOGISTICS) { if (empty($this->ioWare[$io])) { $pkg['warehouse_name'] = self::NO_LOGISTICS_WAREHOUSE; } $virtualEmitted++; } $this->itemsFromView = true; $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++; } unset($viewItems, $heads, $skus, $freight); } if ($progress && $page % 40 === 0) { $progress(sprintf('出库单兜底:已扫描 %d 行,补吐 %d 包裹', $scanned, $emitted)); } } $this->flushPending(); if ($progress) { $progress(sprintf( '出库单兜底完成:扫描 %d 行,补吐 %d 包裹 / %d 行(其中无需物流虚拟发货 %d 包裹);跳过 已处理%d 无单号%d 取消删除%d 标签%d 无商品%d', $scanned, $emitted, $total, $virtualEmitted, $skip['done'], $skip['notrack'], $skip['status'], $skip['label'], $skip['noitem'] )); } return $total; } /** * 采购退货出库的商品行:以采购退货明细为准。 * 发货视图对采购退货单会重复出很多行、拼接条码还会被截断,按视图累加会把数量放大(如 93 件变成 1083 件)。 * 明细里有的条码:沿用视图同条码的名称/长宽高等字段,数量取明细;视图没有的条码按明细补一行; * 视图里多出来的条码(截断残码、重复行)丢弃。明细为空时仍按视图。 * * @param list> $viewItems * @param array $poRows 小写条码 => 明细 * @return list> */ private function purchaseOutItems(array $viewItems, array $poRows, ?string $warehouse): array { $byKey = []; foreach ($viewItems as $it) { $k = mb_strtolower(trim((string) ($it['sku'] ?? ''))); if ($k !== '' && ! isset($byKey[$k])) { $byKey[$k] = $it; } } $out = []; foreach ($poRows as $k => $p) { $qty = (int) round((float) ($p['qty'] ?? 0)); if ($qty <= 0) { continue; } $it = $byKey[(string) $k] ?? [ 'id' => null, 'sku' => $p['sku'], 'iid' => null, 'shopid' => null, 'name' => $p['name'] ?? null, 'amount' => null, 'warehouse' => $warehouse, 'l' => null, 'w' => null, 'h' => null, 'vol' => null, 'weight' => null, 'supplier' => null, ]; $it['qty'] = $qty; $out[] = $it; } return $out === [] ? $viewItems : $out; } /** * 采购退货出库(退回供应商):工作平台的发货数据视图收录了其中走物流的那部分,按视图补吐。 * 无交易单号/店铺/平台;仓库取视图(发货仓),收货信息取采购退货单。 */ private function syncPurchaseOut(string $fromS, string $toS, $now, ?callable $progress = null): int { $rows = $this->selectWithReconnect( 'SELECT io_id, io_date, status, l_id, so_id, logistics_company, receiver_state, receiver_city, receiver_district, ' . 'receiver_address, receiver_name, receiver_mobile FROM ' . self::PURCHASE_OUT . ' WHERE io_date >= ? AND io_date < ?', [$fromS, $toS] ); $this->purchaseScanned += count($rows); $pick = []; $skip = ['done' => 0, 'cancel' => 0, 'noitem' => 0]; foreach ($rows as $r) { $io = (string) ($r['io_id'] ?? ''); if ($io === '' || isset($pick[$io])) { continue; } $lid = trim((string) ($r['l_id'] ?? '')); if (isset($this->doneIo[$io]) || ($lid !== '' && isset($this->doneTrack[ltrim($lid, '@')]))) { $skip['done']++; continue; } if (stripos((string) ($r['status'] ?? ''), 'cancel') !== false) { $skip['cancel']++; continue; } $pick[$io] = $r; } $total = 0; $emitted = 0; if ($pick !== []) { $ioIds = array_map('strval', array_keys($pick)); $viewItems = $this->loadViewItems($ioIds); // 采购退货:商品明细以采购退货明细(jst_purchaseoutItem)为准,体积=单件长宽高×数量(对齐工作平台) $poItems = []; foreach (array_chunk($ioIds, 800) as $ck) { $ph = implode(',', array_fill(0, count($ck), '?')); foreach ($this->selectWithReconnect("SELECT io_id, sku_id, qty, name FROM rpt.jst_purchaseoutItem WHERE io_id IN ({$ph})", $ck) as $pr) { $sku = trim((string) ($pr['sku_id'] ?? '')); if ($sku === '') { continue; } $pk = mb_strtolower($sku); $pio = (string) $pr['io_id']; if (! isset($poItems[$pio][$pk])) { $name = trim((string) ($pr['name'] ?? '')); $poItems[$pio][$pk] = ['sku' => $sku, 'qty' => 0.0, 'name' => $name !== '' ? $name : null]; } $poItems[$pio][$pk]['qty'] += (float) ($pr['qty'] ?? 0); } } foreach ($poItems as $pio => $prows) { if (! empty($viewItems[$pio])) { $viewItems[$pio] = $this->purchaseOutItems($viewItems[$pio], $prows, $this->ioWare[$pio] ?? null); } } $skus = $this->loadSkus($this->collectSkuIds($viewItems)); foreach ($pick as $io => $r) { $io = (string) $io; if (empty($viewItems[$io])) { $skip['noitem']++; continue; } $lid = trim((string) ($r['l_id'] ?? '')); $tracking = $lid !== '' ? $lid : null; $so = trim((string) ($r['so_id'] ?? '')); $pkg = [ 'tracking' => $tracking, 'so_id' => $so !== '' ? $so : null, 'express_name1' => $r['logistics_company'] ?? null, 'ship_time' => $this->ts($r['io_date'] ?? null), 'pay_time' => null, 'shop_name' => null, 'platform' => $this->ioSite[$io] ?? null, 'dis_co_name' => null, 'warehouse_name' => null, 'r_state' => $r['receiver_state'] ?? null, 'r_city' => $r['receiver_city'] ?? null, 'r_district' => $r['receiver_district'] ?? null, 'r_address' => (string) ($r['receiver_address'] ?? ''), 'r_name' => $r['receiver_name'] ?? null, 'r_mobile' => $r['receiver_mobile'] ?? null, 'r_phone' => null, 'buyer_memo' => null, 'weight' => null, ]; $pkg = $this->withViewAddr($io, $pkg); $this->itemsFromView = true; $total += $this->emitPackage(self::PURCHASE_OUT_OFFSET + (int) $io, $pkg, $viewItems[$io], $skus, [], $so, $tracking, $now, true); $this->markDone($io, (string) $tracking); $emitted++; } } $this->flushPending(); if ($progress) { $progress(sprintf( '采购退货出库完成:窗口 %d 单,补吐 %d 包裹 / %d 行;跳过 已处理%d 已取消%d 视图无商品%d', count($rows), $emitted, $total, $skip['done'], $skip['cancel'], $skip['noitem'] )); } return $total; } /** io_id => 视图表头字段(平台/交易单号/省市区/店铺/快递),出库单表头缺失时兜底用 */ private function loadViewHeads(array $ioIds): array { $out = []; foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 300) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); $sql = 'SELECT io_id, MAX(shop_site) AS site, MAX(so_id) AS so, MAX(receiver_state) AS st, MAX(receiver_city) AS ci, ' . 'MAX(receiver_district) AS di, MAX(shop_name) AS sn, MAX(expressName) AS ex FROM ' . self::VIEW . " WHERE io_id IN ({$ph}) GROUP BY io_id"; foreach ($this->selectWithReconnect($sql, $chunk) as $r) { $out[(string) $r['io_id']] = $r; } } return $out; } /** 出库单表头 → 包裹级字段 */ private function outboundSo(array $r, array $h): string { $so = trim((string) ($r['so_id'] ?? '')); return $so !== '' ? $so : trim((string) ($h['so'] ?? '')); } private function pkgFromOutbound(array $r, ?string $tracking, ?string $shipTime, array $h, string $so): array { $pick = fn (string $a, string $b) => (($r[$a] ?? null) !== null && trim((string) $r[$a]) !== '') ? $r[$a] : ($h[$b] ?? null); return [ 'tracking' => $tracking, 'so_id' => $so !== '' ? $so : null, 'express_name1' => $pick('logistics_company', 'ex'), 'ship_time' => $shipTime, 'pay_time' => $this->ts($r['pay_date'] ?? null), 'shop_name' => $pick('shop_name', 'sn'), 'platform' => $h['site'] ?? null, 'dis_co_name' => null, 'warehouse_name' => null, 'r_state' => $pick('receiver_state', 'st'), 'r_city' => $pick('receiver_city', 'ci'), 'r_district' => $pick('receiver_district', 'di'), 'r_address' => '', 'r_name' => null, 'r_mobile' => null, 'r_phone' => $r['receiver_phone'] ?? null, 'buyer_memo' => $r['buyer_message'] ?? null, 'weight' => $r['weight'] ?? null, ]; } /** * 输出一个包裹的所有明细行(每件一行;无商品则一行空商品)。 * @return int 组装行数 */ private function emitPackage(int $pkgId, array $pkg, array $items, array $skus, array $freight, string $so, ?string $tracking, $now, bool $auto = false): int { $items = array_values(array_filter($items, fn ($it) => ! $this->isPricePatch($it['name'] ?? null))); $items = $this->dropZeroQtyPlaceholders($items); // 信誉单/礼品单等虚拟包裹:只保留虚拟条码(X00002 等),不把订单里的实物商品挂到虚拟单号上。 // 10 月工作平台数据中,含虚拟条码的包裹 730 个,全部只有虚拟条码,无一混装实物。 if (self::isReputationPackage($pkg)) { // 信誉订单仓 / 中通礼品单 / 白云韵达-天猫亿家达:工作平台只记虚拟条码 X00002(10 月 689 行全部如此),不挂订单里的实物商品 $items = [[ 'id' => null, 'sku' => self::REPUTATION_VIRTUAL_BARCODE, 'iid' => null, 'shopid' => null, 'name' => '信誉订单虚拟条码', 'qty' => 1, 'amount' => null, 'warehouse' => null, ]]; } else { $virtualItems = array_values(array_filter($items, fn ($it) => self::isVirtualBarcode($it['sku'] ?? null))); if ($virtualItems !== []) { $items = $virtualItems; } } $n = count($items); $tk = ltrim(trim((string) ($tracking ?? '')), '@'); if ($tk !== '') { $this->emittedTrack[$tk] = true; } if (trim($so) !== '') { $this->emittedSo[trim($so)] = true; } $this->seenSourceIds[$pkgId] = true; $this->packagesProcessed++; if ($n === 0) { $this->packagesWithoutItems++; } // 运费明细的快递单号已去@归一:聚水潭换单/改单后单号常带@前缀,原样拼键会对不上本包裹, // 进而误用同交易单号其它包裹的运费和快递名称。 $f = $freight[$so . '|' . $tk] ?? ($freight[$so . '|'] ?? []); $hasFreight = isset($f['cost']); $packageRows = []; if ($n === 0) { $packageRows[] = $this->packageRow($pkgId, $pkgId, $this->buildRow($pkg, [], [], $f, true, '1/1', $now, $auto)); $this->writePackage($pkgId, $packageRows, $hasFreight, false, []); return 1; } $count = 0; $syntheticSkuIds = []; foreach ($items as $idx => $it) { $first = ($idx === 0); $sk = (! empty($it['sku']) && isset($skus[(string) $it['sku']])) ? $skus[(string) $it['sku']] : []; $skuSid = (int) ($it['id'] ?? ($pkgId * 100 + $idx)); if (($it['id'] ?? null) === null) { $syntheticSkuIds[$skuSid] = true; } $packageRows[] = $this->packageRow($pkgId, $skuSid, $this->buildRow($pkg, $it, $sk, $f, $first, '1/' . $n, $now, $auto)); $count++; } $this->writePackage($pkgId, $packageRows, $hasFreight, true, $syntheticSkuIds); return $count; } /** * 导出明细体积(对齐工作平台 OmsDataDay):默认 数量×长×宽×高; * 代发仓(配置)且非标准承运商 → 长×宽×高;特定快递 → 0;无尺寸 → 空。 */ private function detailVolume(mixed $L, mixed $W, mixed $H, float $qty, string $warehouse, string $express): ?float { $cfg = (array) config('jushuitan.detail_volume', []); $express = trim($express); if ($express !== '' && in_array($express, (array) ($cfg['zero_volume_expresses'] ?? []), true)) { return 0.0; } $l = $this->nz($L); $w = $this->nz($W); $h = $this->nz($H); if (! $l || ! $w || ! $h) { return null; } $unit = $l * $w * $h; $noQty = in_array(trim($warehouse), (array) ($cfg['no_qty_warehouses'] ?? []), true) && ! in_array($express, (array) ($cfg['qty_carriers_in_no_qty_warehouses'] ?? []), true); return round($noQty ? $unit : $unit * max($qty, 1), 4); } /** 组装一件明细行。包裹级字段(实称重量/运费)仅首件携带。 */ /** 文本对齐工作平台:工作平台入库时把 & 替换成 ^(如 ">" 导出为 "^gt;",样本 22/22)。 */ private function wpText(mixed $v): ?string { if ($v === null || $v === '') { return $v === '' ? '' : null; } return str_replace('&', '^', (string) $v); } /** 仓库名:聚水潭占位名"{未指定发货仓}"在工作平台仓库表里没有对应,导出为空。 */ private function whName(mixed $w): ?string { $w = trim((string) ($w ?? '')); return ($w === '' || $w === '{未指定发货仓}') ? null : $w; } private function buildRow(array $pkg, array $it, array $sk, array $f, bool $first, string $countStr, $now, bool $auto): array { $qty = (float) ($it['qty'] ?? 0); $skuW = (float) (($it['weight'] ?? null) ?: ($sk['weight'] ?? 0)); $onlineW = $skuW > 0 ? round($skuW * ($qty > 0 ? $qty : 1), 4) : null; $L = ($it['l'] ?? null) ?: ($sk['l'] ?? null); $W = ($it['w'] ?? null) ?: ($sk['w'] ?? null); $H = ($it['h'] ?? null) ?: ($sk['h'] ?? null); $vol = ($it['vol'] ?? null) ? round((float) $it['vol'], 4) : (($L && $W && $H) ? round((float) $L * (float) $W * (float) $H, 4) : null); $pkgWeight = (float) ($pkg['weight'] ?? 0) ?: null; $freightW = (float) ($f['w'] ?? 0); $settleW = $this->skipFreight ? null : ($freightW > 0 ? $freightW : ($pkgWeight ?: $onlineW)); return $this->cleanPayload([ 'express_no' => $pkg['tracking'], 'trade_no' => $pkg['so_id'], 'item_id' => $it['shopid'] ?? null, 'barcode' => $it['sku'] ?? null, 'product_name' => $this->wpText(($it['name'] ?? null) ?: ($sk['name'] ?? null)), 'qty' => $it['qty'] ?? null, 'pkg_count' => $countStr, 'product_code' => ($it['iid'] ?? null) ?: ($sk['iid'] ?? null), 'express_name1' => $pkg['express_name1'], 'express_name2' => ($f['name'] ?? null) ?: $pkg['express_name1'], 'ship_time' => $pkg['ship_time'], 'pay_time' => $pkg['pay_time'], 'shop_name' => $pkg['shop_name'], 'platform' => $pkg['platform'], 'supplier_name' => ($it['supplier'] ?? null) ?: (($sk['supplier'] ?? null) ?: $pkg['dis_co_name']), 'warehouse_name' => $this->whName($pkg['warehouse_name'] ?: ($it['warehouse'] ?? null)), 'receiver_state' => $pkg['r_state'], 'receiver_city' => $pkg['r_city'], 'receiver_district' => $pkg['r_district'], 'receiver_address' => $pkg['r_address'], 'receiver_name' => $pkg['r_name'], 'receiver_mobile' => $pkg['r_mobile'], 'receiver_phone' => $pkg['r_phone'], 'online_weight' => $onlineW, 'actual_weight' => $first ? $pkgWeight : null, 'length_cm' => $L, 'width_cm' => $W, 'height_cm' => $H, 'volume_cm3' => $this->detailVolume($L, $W, $H, $qty, (string) ($pkg['warehouse_name'] ?: ($it['warehouse'] ?? '')), (string) ($pkg['express_name1'] ?? '')), 'm_name' => $this->wpText($it['mname'] ?? null), 'm_volume_cm3' => isset($it['mvol']) ? round((float) $it['mvol'], 4) : $vol, 'm_length_cm' => $it['ml'] ?? null, 'm_width_cm' => $it['mw'] ?? null, 'm_height_cm' => $it['mh'] ?? null, 'weight_kg' => $first ? $settleW : null, 'settle_weight_kg' => $first ? $settleW : null, 'amount' => $it['amount'] ?? null, 'freight' => $first ? ($f['cost'] ?? null) : null, 'settle_freight' => $first ? ($f['cost'] ?? null) : null, 'buyer_memo' => $pkg['buyer_memo'], 'modified' => $pkg['ship_time'], 'synced_at' => $now, ]); } /** * 快递公司/仓库以实际出库单为准(工作平台口径):发货明细可能停在作废前的旧分配 * (如先分给工厂代发仓、后改外采由总仓出库;或换单后仍是旧快递公司)。 * 出库单快递公司与发货明细一致时不改,并借此学习 wms_co_id => 仓库名。 */ private function withOutboundCarrier(string $io, array $r, array $pkg, int $pkgId): array { $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 !== '' && $lc !== $eLc) { $pkg['express_name1'] = $lc; } if (trim((string) ($pkg['express_name1'] ?? '')) === self::NO_LOGISTICS) { $pkg['warehouse_name'] = self::NO_LOGISTICS_WAREHOUSE; return $pkg; } 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; } /** * 仓库以发货数据视图的 wareHoursName 为准(工作平台口径);快递名称以出库单/发货明细当前值为准,视图仅兜底。 */ private function withViewCarrier(string $io, array $pkg, int $pkgId): array { if ($io === '') { return $pkg; } // 快递名称以出库单/发货明细的当前值为准:聚水潭换单、改快递公司后出库单与发货明细会更新,工作平台跟着变, // 发货数据视图的 expressName 却停在旧值(如 崔桥顺丰干配→常州德邦)。视图只在出库单/发货明细没有快递名称时兜底。 if (trim((string) ($pkg['express_name1'] ?? '')) === '' && ! empty($this->ioExpress[$io])) { $pkg['express_name1'] = $this->ioExpress[$io]; } if (! empty($this->ioWare[$io])) { $pkg['warehouse_name'] = $this->ioWare[$io]; unset($this->pendingWarehouse[$pkgId]); } return $pkg; } private function wmsName(string $wms): ?string { if (empty($this->wmsNames[$wms])) { return null; } $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); } /** 扫描时还没学到仓库名的包裹:扫描结束后按学到的 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): 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); }) ->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 { $addr = (string) ($r['receiver_address'] ?? ''); $parts = $addr !== '' ? array_values(array_filter(explode('-', $addr))) : []; return [ 'tracking' => $tracking, 'so_id' => $r['so_id'] ?? null, 'express_name1' => $r['logistic_company_name'] ?? null, 'ship_time' => $shipTime, 'pay_time' => $this->ts($r['pay_time'] ?? null), 'shop_name' => $r['shop_name'] ?? null, 'platform' => $r['platform_name'] ?? null, 'dis_co_name' => $r['dis_co_name'] ?? null, 'warehouse_name' => $r['warehouse_name'] ?? null, 'r_state' => $parts[0] ?? null, 'r_city' => $parts[1] ?? null, 'r_district' => $parts[2] ?? null, 'r_address' => $addr, 'r_name' => null, 'r_mobile' => null, 'r_phone' => null, 'buyer_memo' => $r['buyer_message'] ?? null, 'weight' => $r['weight'] ?? null, ]; } /** jst_Orders 行(自动发货仓) → 包裹级字段 */ private function pkgFromOrder(array $r, ?string $tracking, ?string $shipTime): array { return [ 'tracking' => $tracking, 'so_id' => $r['so_id'] ?? null, 'express_name1' => $r['logistics_company'] ?? null, 'ship_time' => $shipTime, 'pay_time' => $this->ts($r['pay_date'] ?? null), 'shop_name' => $r['shop_name'] ?? null, 'platform' => $r['order_from'] ?? null, 'dis_co_name' => null, 'warehouse_name' => null, 'r_state' => $r['receiver_state'] ?? null, 'r_city' => $r['receiver_city'] ?? null, 'r_district' => $r['receiver_district'] ?? null, 'r_address' => $r['receiver_address'] ?? '', 'r_name' => $r['receiver_name'] ?? null, 'r_mobile' => $r['receiver_mobile'] ?? null, 'r_phone' => $r['receiver_phone'] ?? null, 'buyer_memo' => $r['buyer_message'] ?? null, 'weight' => $r['weight'] ?? null, ]; } private function tracking(string $logisticId, string $joLid, array $freight, string $so): ?string { if ($logisticId !== '') { return $logisticId; } if ($joLid !== '') { return $joLid; } $f = $freight[$so . '|'] ?? []; $no = (string) ($f['no'] ?? ''); return $no !== '' ? $no : null; } /** @return array{0:array,1:array} [byOid, bySo] */ private function loadAllItems(array $oids, array $soIds, bool $loadBySoId = true): array { $byOid = []; $bySo = []; $sel = 'o_id, raw_so_id, sku_id, i_id, shop_i_id, name, qty, amount, send_warehouse, Id'; foreach (array_chunk($oids, 800) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); foreach ($this->selectWithReconnect("SELECT {$sel} FROM rpt.jst_OrderItem WHERE o_id IN ({$ph}) ORDER BY Id", $chunk) as $r) { $k = (string) ($r['o_id'] ?? ''); if ($k !== '') { $byOid[$k][] = $this->itemArr($r); } } } // raw_so_id lacks a usable index in the production replica. Windowed sync already // preloads DWS rows by sourceno, so querying this fallback can turn every batch into // a full-table scan and exceed the upstream timeout. Keep it only for the small, // non-windowed diagnostic mode where DWS is not preloaded. if ($loadBySoId) { foreach (array_chunk($soIds, 800) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); foreach ($this->selectWithReconnect("SELECT {$sel} FROM rpt.jst_OrderItem WHERE raw_so_id IN ({$ph}) ORDER BY Id", $chunk) as $r) { $k = (string) ($r['raw_so_id'] ?? ''); if ($k !== '') { $bySo[$k][] = $this->itemArr($r); } } } } return [$byOid, $bySo]; } /** 开跑前按天预加载窗口内 dws 销售全量到内存,避免逐批扫表。 */ private function preloadDws(\Illuminate\Support\Carbon $from, \Illuminate\Support\Carbon $to): void { $this->dwsCache = []; $seen = []; $day = $from->copy()->subDay()->startOfDay(); $end = $to->copy(); while ($day < $end) { $d = $day->format('Y-m-d'); $day = $day->addDay(); try { $rows = $this->selectWithReconnect("SELECT sourceno, item_id, skuoid, goods_count, sales_amount FROM rpt.dws_sn_sales_full_info_di WHERE stat_date = ?", [$d]); } catch (Throwable) { $this->dwsFailedDays[] = $d; continue; } foreach ($rows as $r) { $so = (string) ($r['sourceno'] ?? ''); $bc = (string) ($r['skuoid'] ?? ''); if ($so === '' || $bc === '') { continue; } $k = $so.'|'.$bc; if (isset($seen[$k])) { continue; } $seen[$k] = true; $this->dwsCache[$so][] = [ 'id' => null, 'sku' => $bc, 'iid' => null, 'shopid' => $r['item_id'] ?? null, 'name' => null, 'qty' => $r['goods_count'] ?? null, 'amount' => $r['sales_amount'] ?? null, 'warehouse' => null, ]; } unset($rows); } } /** jst_OrderItem 缺失时的兜底:dws 销售全量按 sourceno 取商品(条码/商品ID/数量/金额)。 */ private function loadDwsItems(array $soIds): array { $out = []; $seen = []; foreach (array_chunk($soIds, 500) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); $sql = "SELECT sourceno, item_id, skuoid, goods_count, sales_amount FROM rpt.dws_sn_sales_full_info_di WHERE sourceno IN ({$ph})"; foreach ($this->selectWithReconnect($sql, $chunk) as $r) { $so = (string) ($r['sourceno'] ?? ''); $barcode = (string) ($r['skuoid'] ?? ''); if ($so === '' || $barcode === '') { continue; } $k = $so.'|'.$barcode; if (isset($seen[$k])) { continue; } $seen[$k] = true; $out[$so][] = [ 'id' => null, 'sku' => $barcode, 'iid' => null, 'shopid' => $r['item_id'] ?? null, 'name' => null, 'qty' => $r['goods_count'] ?? null, 'amount' => $r['sales_amount'] ?? null, 'warehouse' => null, ]; } } return $out; } private function itemArr(array $r): array { return [ 'id' => $r['Id'] ?? null, 'sku' => $r['sku_id'] ?? null, 'iid' => $r['i_id'] ?? null, 'shopid' => $r['shop_i_id'] ?? null, 'name' => $r['name'] ?? null, 'qty' => $r['qty'] ?? null, 'amount' => $r['amount'] ?? null, 'warehouse' => $r['send_warehouse'] ?? null, ]; } /** * 包裹实发商品: * 1. 出库单实发商品(发货数据视图); * 2. 多包裹订单:聚水潭运费明细里本快递单号的商品; * 3. 内部订单(o_id)商品; * 4. 运费明细里本快递单号的商品(单包裹订单); * 5. 交易单号整单商品。 * * 运费明细(fys_jstFreightCostDetail)按 交易单号+快递单号 记录每个包裹的 sku。工厂代发仓等找不到出库单的包裹, * 内部订单/交易单号商品是整单口径,多包裹时会把同订单其它包裹的商品挂到本单号上 * (例:交易单号 3317080548271028274 的韵达包裹被挂成圆通包裹的 DNZ0254102N1217V01; * 交易单号 3316455123356017092 的韵达包裹被挂成 34095J + 雅柏仓的 CZ000935N1201V01)。 */ private function packageItems(string $io, array $viewItems, array $byOid, array $bySo, array $dws, string $oid, string $so, string $tracking): array { $this->itemsFromView = false; if ($io !== '' && ! empty($viewItems[$io])) { $this->itemsFromView = true; return $viewItems[$io]; } $tk = ltrim(trim($tracking), '@'); $packageSkus = ($so !== '' && $tk !== '') ? ($this->fysPackageSkus[$so . '|' . $tk] ?? []) : []; $multiPackage = ($this->fysSoTrackCount[$so] ?? 0) > 1; if ($oid !== '' && ! empty($byOid[$oid]) && ! ($multiPackage && $packageSkus !== [])) { return $byOid[$oid]; } if ($packageSkus === []) { return $this->itemsFor($byOid, $bySo, $dws, $oid, $so); } // 商品名称、商品ID、数量、金额尽量沿用订单商品;订单里找不到的 sku 按运费明细行数记数量 $pool = []; foreach ([$byOid[$oid] ?? [], $bySo[$so] ?? [], $dws[$so] ?? []] as $list) { foreach ($list as $it) { $key = mb_strtolower(trim((string) ($it['sku'] ?? ''))); if ($key !== '' && ! isset($pool[$key])) { $pool[$key] = $it; } } } $items = []; foreach ($packageSkus as $sku => $rows) { $items[] = $pool[mb_strtolower((string) $sku)] ?? [ 'id' => null, 'sku' => (string) $sku, 'iid' => null, 'shopid' => null, 'name' => null, 'qty' => $rows, 'amount' => null, 'warehouse' => null, ]; } return $items; } private const REPUTATION_VIRTUAL_BARCODE = 'X00002'; private const REPUTATION_WAREHOUSES = ['简尚-信誉订单仓']; private const REPUTATION_CARRIERS = ['中通礼品单', '白云韵达-天猫亿家达']; /** 信誉订单/礼品单虚拟包裹:按仓库或快递判断(系统数据里没有 X00002,X00002 是工作平台导出时补的)。 */ private static function isReputationPackage(array $pkg): bool { return in_array(trim((string) ($pkg['warehouse_name'] ?? '')), self::REPUTATION_WAREHOUSES, true) || in_array(trim((string) ($pkg['express_name1'] ?? '')), self::REPUTATION_CARRIERS, true); } /** 信誉单/礼品单虚拟条码,如 X00002、X00006。 */ private static function isVirtualBarcode(mixed $sku): bool { return preg_match('/^X\\d{5}$/i', trim((string) $sku)) === 1; } /** * 只读诊断:一个快递单号在各上游表里的记录,用于排查发货数据商品为空/错配。 * 不写任何表。 * * @return array */ public function diagnosePackage(string $tracking): array { $t = ltrim(trim($tracking), '@'); $alts = [$t, '@' . $t]; $in = static fn (array $values): string => implode(',', array_fill(0, count($values), '?')); $col = static function (array $rows, string $key): array { $out = []; foreach ($rows as $row) { $value = trim((string) ($row[$key] ?? '')); if ($value !== '') { $out[$value] = true; } } return array_keys($out); }; $out = ['tracking' => $t]; $out['outbound'] = $this->selectWithReconnect( 'SELECT io_id, o_id, so_id, l_id, io_date, status, logistics_company, wms_co_id FROM ' . self::OUTBOUND . ' WHERE l_id IN (?, ?)', $alts ); $ioIds = $col($out['outbound'], 'io_id'); $out['view'] = $ioIds === [] ? [] : $this->selectWithReconnect( 'SELECT io_id, so_id, sku_ids, qty, type, wareHoursName, expressName FROM ' . self::VIEW . " WHERE io_id IN ({$in($ioIds)})", $ioIds ); $out['express_details'] = $this->selectWithReconnect( 'SELECT id, so_id, inner_order_id, logistic_id, logistic_company_name, warehouse_name, send_time FROM rpt.jst_order_express_details WHERE logistic_id IN (?, ?)', $alts ); $out['orders'] = $this->selectWithReconnect( 'SELECT o_id, so_id, l_id, status, logistics_company, send_date, labels FROM rpt.jst_Orders WHERE l_id IN (?, ?)', $alts ); $oids = array_values(array_unique(array_merge( $col($out['outbound'], 'o_id'), $col($out['express_details'], 'inner_order_id'), $col($out['orders'], 'o_id') ))); $out['order_items'] = $oids === [] ? [] : $this->selectWithReconnect( "SELECT o_id, sku_id, qty, name FROM rpt.jst_OrderItem WHERE o_id IN ({$in($oids)})", $oids ); $soIds = array_values(array_unique(array_merge( $col($out['outbound'], 'so_id'), $col($out['express_details'], 'so_id'), $col($out['orders'], 'so_id'), $col($out['view'], 'so_id') ))); $out['freight_detail'] = $soIds === [] ? [] : $this->selectWithReconnect( "SELECT so_id, expressNo, io_id, sku_id, freightCost, weight, expressName FROM rpt.fys_jstFreightCostDetail WHERE so_id IN ({$in($soIds)})", $soIds ); return $out; } /** 该包裹的商品:优先 o_id 精确,取不到回落 so_id。 */ private function itemsFor(array $byOid, array $bySo, array $dws, string $oid, string $so): array { if ($oid !== '' && ! empty($byOid[$oid])) { return $byOid[$oid]; } if ($so !== '' && ! empty($bySo[$so])) { return $bySo[$so]; } if ($so !== '' && ! empty($dws[$so])) { return $dws[$so]; // jst_OrderItem 取不到时,用 dws 销售全量兜底 } return []; } /** 窗口同步按 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, logistics_company, wms_co_id FROM ' . self::OUTBOUND . ' WHERE io_date >= ? AND io_date < ?'; $this->outboundMap = $this->mapOutboundRows($this->selectWithReconnect($sql, [$fromS, $toS])); } /** 出库单行 -> 映射(单号/订单/单号+订单 三种定位,作废重出按有效优先、io_date 最新择优)。 */ private function mapOutboundRows(array $rows): array { $map = $this->emptyIoMap(); foreach ($rows as $r) { $this->addIoRow($map, $r, true, true); } unset($map['rank']); return $map; } private function ioMapFor(bool $windowed, array $logisticIds, array $oIds): array { return $windowed ? $this->outboundMap : $this->loadIoMap($logisticIds, $oIds); } /** * 出库单映射:byL 单号->io、byO 订单->io、byLO 单号|订单->io、byOAll 订单->[io=>单号](仅有效单)、ioL io->单号。 * 同一单号/订单可能有多张出库单(作废重出):优先有效(非 Delete/Cancelled),再取最新 io_date。 */ private function emptyIoMap(): array { return ['byL' => [], 'byO' => [], 'byLO' => [], 'byOAll' => [], 'ioL' => [], 'rank' => ['L' => [], 'O' => [], 'LO' => []]]; } private function addIoRow(array &$m, array $r, bool $indexByL, bool $indexByO): void { $rio = (string) ($r['io_id'] ?? ''); if ($rio === '') { return; } 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; $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; $m['rank']['L'][$l] = $rk; } // 同一单号多订单共用(拼单/合包):按 单号+订单 精确定位,避免拿到别的订单的商品 $lo = $l . '|' . $o; if ($indexByL && $l !== '' && $o !== '' && (! isset($m['rank']['LO'][$lo]) || strcmp($rk, $m['rank']['LO'][$lo]) > 0)) { $m['byLO'][$lo] = $rio; $m['rank']['LO'][$lo] = $rk; } if ($indexByO && $o !== '') { if ($ok) { $m['byOAll'][$o][$rio] = $l; } if (! isset($m['rank']['O'][$o]) || strcmp($rk, $m['rank']['O'][$o]) > 0) { $m['byO'][$o] = $rio; $m['rank']['O'][$o] = $rk; } } } /** 快递单号/内部单号 -> 出库单 io_id(非窗口诊断模式按需查询;窗口模式用预载映射)。 */ private function loadIoMap(array $logisticIds, array $oIds): array { $map = $this->emptyIoMap(); foreach (array_chunk(array_values(array_unique(array_filter($logisticIds))), 800) as $chunk) { if ($chunk === []) { continue; } $q = []; foreach ($chunk as $c) { $c = ltrim(trim((string) $c), '@'); if ($c !== '') { $q[$c] = true; $q['@' . $c] = true; } } $q = array_keys($q); $ph = implode(',', array_fill(0, count($q), '?')); 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); } } foreach (array_chunk(array_values(array_unique(array_filter($oIds))), 800) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); 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); } } unset($map['rank']); return $map; } /** * 换单配对:快递明细的单号在出库单里找不到时,到同一内部订单下找一张 * "出库单有单号、但该单号不在任何快递明细里"的出库单,一对一配对(工作平台换单后只保留新单号)。 * * @return array 以快递明细 id 为键 */ private function pairChangedTracking(array $rows, array $ioMap): array { $cands = []; $need = []; foreach ($rows as $r) { $lg = ltrim(trim((string) ($r['logistic_id'] ?? '')), '@'); $oid = (string) ($r['inner_order_id'] ?? ''); $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; } $need[] = $r; foreach ($ioMap['byOAll'][$oid] as $io => $l) { if ($l !== '' && ! isset($this->doneIo[(string) $io]) && ! isset($this->doneTrack[$l])) { $cands[$l] = true; } } } if ($need === [] || $cands === []) { return []; } // 候选新单号若本身出现在快递明细里(同订单拆包),不能拿来配对 $inExpress = []; foreach (array_chunk(array_keys($cands), 400) as $chunk) { $q = []; foreach ($chunk as $c) { $q[] = (string) $c; $q[] = '@' . $c; } $ph = implode(',', array_fill(0, count($q), '?')); 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; } } $used = []; $swap = []; foreach ($need as $r) { $oid = (string) $r['inner_order_id']; $ios = $ioMap['byOAll'][$oid]; ksort($ios, SORT_STRING); foreach ($ios as $io => $l) { $io = (string) $io; if ($l === '' || isset($inExpress[$l]) || isset($used[$io]) || isset($this->doneIo[$io]) || isset($this->doneTrack[$l])) { continue; } $used[$io] = true; $swap[(string) $r['id']] = ['io' => $io, 'l_id' => $l, 'old' => (string) $r['logistic_id']]; break; } } return $swap; } private function ioFor(array $ioMap, string $logisticId, string $oid): string { $logisticId = ltrim(trim($logisticId), '@'); if ($logisticId !== '' && $oid !== '' && ! empty($ioMap['byLO'][$logisticId . '|' . $oid])) { return (string) $ioMap['byLO'][$logisticId . '|' . $oid]; } if ($logisticId !== '' && ! empty($ioMap['byL'][$logisticId])) { return (string) $ioMap['byL'][$logisticId]; } if ($oid !== '' && ! empty($ioMap['byO'][$oid])) { return (string) $ioMap['byO'][$oid]; } return ''; } /** * io_id -> 实发商品[itemArr](发货数据视图)。 * 单条码行:按(io_id,条码)去重,type=0 之和优先。 * 多条码行(组合件):见 appendComboParts,对齐工作平台导出明细的拆件口径。 */ private function loadViewItems(array $ioIds): array { $agg = []; $multi = []; $this->ioSite = []; $this->ioAddr = []; $this->ioShop = []; $this->ioExpress = []; $this->ioWare = []; // 发货数据视图是计算视图,IN 列表过长时副本库容易超时:按 300 个出库单一批 foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 300) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); $sql = 'SELECT io_id, sku_ids, qty, type, sku_name, shop_i_id, wareHoursName, expressName, l, w, h, volume, weight, supplier_name, shop_site, shop_name, receiver_state, receiver_city, receiver_district FROM ' . self::VIEW . " WHERE io_id IN ({$ph})"; foreach ($this->selectWithReconnect($sql, $chunk) as $r) { $io = (string) ($r['io_id'] ?? ''); if ($io !== '' && ! isset($this->ioSite[$io]) && trim((string) ($r['shop_site'] ?? '')) !== '') { $this->ioSite[$io] = trim((string) $r['shop_site']); } if ($io !== '' && ! isset($this->ioShop[$io]) && trim((string) ($r['shop_name'] ?? '')) !== '') { $this->ioShop[$io] = trim((string) $r['shop_name']); } if ($io !== '' && ! isset($this->ioExpress[$io]) && trim((string) ($r['expressName'] ?? '')) !== '') { $this->ioExpress[$io] = trim((string) $r['expressName']); } if ($io !== '' && ! isset($this->ioWare[$io]) && trim((string) ($r['wareHoursName'] ?? '')) !== '') { $this->ioWare[$io] = trim((string) $r['wareHoursName']); } if ($io !== '') { foreach (['st' => 'receiver_state', 'ci' => 'receiver_city', 'di' => 'receiver_district'] as $ak => $ac) { $av = trim((string) ($r[$ac] ?? '')); if ($av !== '' && ! isset($this->ioAddr[$io][$ak])) { $this->ioAddr[$io][$ak] = $av; } } } $skuIds = (string) ($r['sku_ids'] ?? ''); if ($io === '' || $skuIds === '') { continue; } // 一行可能逗号拼接多个条码 —— 对齐工作平台导出明细:拆成每条码一件; // 数量与体积按条码数均摊,保证合并求和后与整行值一致(工作平台合并体积"整箱算一次")。 $bcs = array_values(array_filter(array_map('trim', explode(',', $skuIds)), fn ($x) => $x !== '')); if ($bcs === []) { continue; } $n = count($bcs); if ($n > 1) { $multi[$io][] = [ 't' => (string) ($r['type'] ?? ''), 'qty' => (float) ($r['qty'] ?? 0), 'bcs' => $bcs, 'name' => $r['sku_name'] ?? null, 'l' => $r['l'] ?? null, 'w' => $r['w'] ?? null, 'h' => $r['h'] ?? null, 'vol' => $r['volume'] ?? null, 'shopid' => $r['shop_i_id'] ?? null, 'wh' => $r['wareHoursName'] ?? null, 'supplier' => $r['supplier_name'] ?? null, ]; continue; } $isT0 = ((string) ($r['type'] ?? '') === '0'); $perQty = (float) ($r['qty'] ?? 0) / $n; $rowVol = ($r['volume'] !== null && $r['volume'] !== '') ? (float) $r['volume'] : null; $perVol = ($rowVol !== null) ? $rowVol / $n : null; foreach ($bcs as $bc) { $k = $io . '|' . mb_strtolower($bc); if (! isset($agg[$k])) { $agg[$k] = [ 'io' => $io, 'sku' => $bc, 't0' => 0.0, 't0n' => 0, 'any' => 0.0, 'anyn' => 0, 't0vol' => 0.0, 't0voln' => 0, 'anyvol' => 0.0, 'anyvoln' => 0, 'shopid' => null, 'name' => null, 'wh' => null, 'l' => null, 'w' => null, 'h' => null, 'weight' => null, 'supplier' => null, ]; } if ($isT0) { $agg[$k]['t0'] += $perQty; $agg[$k]['t0n']++; if ($perVol !== null) { $agg[$k]['t0vol'] += $perVol; $agg[$k]['t0voln']++; } } $agg[$k]['any'] += $perQty; $agg[$k]['anyn']++; if ($perVol !== null) { $agg[$k]['anyvol'] += $perVol; $agg[$k]['anyvoln']++; } // 名称/长宽高/供应商沿用整行值(共享):任意行回填一次 if (empty($agg[$k]['name']) && ! empty($r['sku_name'])) { $agg[$k]['name'] = $r['sku_name']; } if (empty($agg[$k]['shopid']) && ! empty($r['shop_i_id'])) { $agg[$k]['shopid'] = $r['shop_i_id']; } if (empty($agg[$k]['wh']) && ! empty($r['wareHoursName'])) { $agg[$k]['wh'] = $r['wareHoursName']; } foreach (['l', 'w', 'h', 'weight'] as $ff) { if (empty($agg[$k][$ff]) && ! empty($r[$ff])) { $agg[$k][$ff] = $r[$ff]; } } if (empty($agg[$k]['supplier']) && ! empty($r['supplier_name'])) { $agg[$k]['supplier'] = $r['supplier_name']; } } } } $out = []; foreach ($agg as $a) { $qty = $a['t0n'] > 0 ? $a['t0'] : ($a['anyn'] > 0 ? $a['any'] / $a['anyn'] : 0); $qty = (int) round($qty); if ($qty < 1) { $qty = 1; } // 体积:type=0 各条码分摊之和优先;否则取任一 type 的均值(去 type 重复) $vol = $a['t0voln'] > 0 ? round($a['t0vol'], 4) : ($a['anyvoln'] > 0 ? round($a['anyvol'] / $a['anyvoln'], 4) : null); $out[$a['io']][] = [ 'id' => null, 'sku' => $a['sku'], 'iid' => null, 'shopid' => $a['shopid'], 'name' => $a['name'], 'qty' => $qty, 'amount' => null, 'warehouse' => $a['wh'], 'l' => $a['l'], 'w' => $a['w'], 'h' => $a['h'], 'vol' => $vol, 'weight' => $a['weight'], 'supplier' => $a['supplier'], ]; } if ($multi !== []) { $this->appendComboParts($multi, $out); } return $out; } /** * 组合件拆件(对齐工作平台导出明细):视图一行逗号拼接多个部件条码时,每个部件一行—— * 名称/长宽高取 SKU 资料,体积=长×宽×高; * 数量:订单商品 → 运费逐件重量÷SKU单重 → 整数均分,前两者须部件合计=整行数量才采用; * 同时带整行 名称/体积(按部件数均摊)/长宽高(m_*),供合并口径使用(工作平台合并取整行值)。 * 同一出库单有 type=0 行只用 type=0,否则用最小 type,避免 type=3 等重复行重复计数。 */ private function appendComboParts(array $multi, array &$out): void { $sel = []; $bcAll = []; foreach ($multi as $io => $rows) { $types = array_values(array_unique(array_map(fn ($r) => $r['t'], $rows))); $useT = in_array('0', $types, true) ? '0' : min($types); foreach ($rows as $r) { if ($r['t'] === $useT) { $sel[(string) $io][] = $r; foreach ($r['bcs'] as $b) { $bcAll[$b] = true; } } } } $ioKeys = array_map('strval', array_keys($sel)); $o2io = []; foreach (array_chunk($ioKeys, 800) as $ck) { $ph = implode(',', array_fill(0, count($ck), '?')); foreach ($this->selectWithReconnect('SELECT io_id, o_id FROM ' . self::OUTBOUND . " WHERE io_id IN ({$ph})", $ck) as $r) { $o = (string) ($r['o_id'] ?? ''); if ($o !== '') { $o2io[$o][(string) $r['io_id']] = true; } } } $ordQty = []; foreach (array_chunk(array_map('strval', array_keys($o2io)), 800) as $ck) { $ph = implode(',', array_fill(0, count($ck), '?')); foreach ($this->selectWithReconnect("SELECT o_id, sku_id, qty FROM rpt.jst_OrderItem WHERE o_id IN ({$ph})", $ck) as $r) { $k = mb_strtolower(trim((string) ($r['sku_id'] ?? ''))); foreach ($o2io[(string) $r['o_id']] ?? [] as $io => $_) { $ordQty[(string) $io][$k] = ($ordQty[(string) $io][$k] ?? 0) + (float) ($r['qty'] ?? 0); } } } $fysW = []; foreach (array_chunk($ioKeys, 800) as $ck) { $ph = implode(',', array_fill(0, count($ck), '?')); foreach ($this->selectWithReconnect("SELECT io_id, sku_id, weight FROM rpt.fys_jstFreightCostDetail WHERE io_id IN ({$ph})", $ck) as $r) { $k = mb_strtolower(trim((string) ($r['sku_id'] ?? ''))); $fysW[(string) $r['io_id']][$k] = ($fysW[(string) $r['io_id']][$k] ?? 0) + (float) ($r['weight'] ?? 0); } } $skuL = []; foreach ($this->loadSkus(array_map('strval', array_keys($bcAll))) as $k => $v) { $skuL[mb_strtolower((string) $k)] = $v; } $raw = fn ($v) => ($v === null || trim((string) $v) === '') ? null : (float) $v; foreach ($sel as $io => $rows) { $io = (string) $io; $parts = []; foreach ($rows as $r) { $bcs = $r['bcs']; $n = count($bcs); $q = $this->partQty($bcs, (int) round($r['qty']), $ordQty[$io] ?? [], $fysW[$io] ?? [], $skuL); $rowVol = $raw($r['vol']); foreach ($bcs as $i => $bc) { $lk = mb_strtolower($bc); $s = $skuL[$lk] ?? []; $L = $this->nz($s['l'] ?? null); $W = $this->nz($s['w'] ?? null); $H = $this->nz($s['h'] ?? null); if (! isset($parts[$lk])) { $parts[$lk] = [ 'id' => null, 'sku' => $bc, 'iid' => $s['iid'] ?? null, 'shopid' => ($r['shopid'] ?? null) ?: null, 'name' => ($s['name'] ?? null) ?: $r['name'], 'qty' => 0, 'amount' => null, 'warehouse' => ($r['wh'] ?? null) ?: null, 'l' => $L, 'w' => $W, 'h' => $H, 'vol' => ($L && $W && $H) ? round($L * $W * $H, 4) : null, 'weight' => $s['weight'] ?? null, 'supplier' => ($r['supplier'] ?? null) ?: null, 'mname' => $r['name'], 'mvol' => null, 'ml' => $raw($r['l']), 'mw' => $raw($r['w']), 'mh' => $raw($r['h']), ]; } $parts[$lk]['qty'] += $q[$i]; if ($rowVol !== null) { $parts[$lk]['mvol'] = ($parts[$lk]['mvol'] ?? 0) + $rowVol / $n; } } } foreach ($parts as $lk => $p) { $p['mvol'] = $p['mvol'] !== null ? round($p['mvol'], 4) : null; $hit = false; foreach ($out[$io] ?? [] as $idx => $it) { if (mb_strtolower((string) $it['sku']) === $lk) { $out[$io][$idx]['qty'] += $p['qty']; $hit = true; break; } } if (! $hit) { $out[$io][] = $p; } } } } /** 组合件部件数量:订单商品 → 运费逐件重量÷SKU单重 → 整数均分;前两者须合计=整行数量。 */ private function partQty(array $bcs, int $rowQty, array $ord, array $fys, array $skuL): array { $n = count($bcs); $keys = array_map(fn ($b) => mb_strtolower($b), $bcs); $a = []; foreach ($keys as $k) { if (! isset($ord[$k])) { $a = null; break; } $a[] = (int) round($ord[$k]); } if ($a !== null && array_sum($a) === $rowQty && min($a) > 0) { return $a; } $b = []; foreach ($keys as $k) { $sw = (float) ($skuL[$k]['weight'] ?? 0); if (! isset($fys[$k]) || $sw <= 0) { $b = null; break; } $b[] = (int) round($fys[$k] / $sw); } if ($b !== null && array_sum($b) === $rowQty && min($b) > 0) { return $b; } $total = max($rowQty, 0); $base = intdiv($total, $n); $rem = $total - $base * $n; $c = []; foreach ($keys as $i => $_) { $c[] = $base + ($i < $rem ? 1 : 0); } return $c; } private function nz(mixed $v): ?float { if ($v === null || trim((string) $v) === '') { return null; } $f = (float) $v; return $f == 0.0 ? null : $f; } /** 收货省市区统一取发货数据视图(与工作平台一致),视图无值时保留原值。 */ private function withViewAddr(string $io, array $pkg): array { if ($io !== '' && trim((string) ($pkg['shop_name'] ?? '')) === '' && isset($this->ioShop[$io])) { $pkg['shop_name'] = $this->ioShop[$io]; } $a = ($io !== '') ? ($this->ioAddr[$io] ?? []) : []; foreach (['st' => 'r_state', 'ci' => 'r_city', 'di' => 'r_district'] as $ak => $pk) { if (isset($a[$ak]) && $a[$ak] !== '') { $pkg[$pk] = $a[$ak]; } } return $pkg; } private function collectSkuIds(array ...$maps): array { $ids = []; foreach ($maps as $map) { foreach ($map as $items) { foreach ($items as $it) { if (! empty($it['sku'])) { $ids[(string) $it['sku']] = true; } } } } return array_keys($ids); } private function push(int $sourceId, int $skuSourceId, array $payload): void { $payload['source_id'] = $sourceId; $payload['sku_source_id'] = $skuSourceId; $this->buffer[] = $payload; if (count($this->buffer) >= self::FLUSH) { $this->flush(); } } private function packageRow(int $sourceId, int $skuSourceId, array $payload): array { $payload['source_id'] = $sourceId; $payload['sku_source_id'] = $skuSourceId; return $payload; } /** @param list> $rows */ private function writePackage(int $sourceId, array $rows, bool $hasFreight, bool $hasItems, array $syntheticSkuIds): void { $fromView = $this->itemsFromView; $this->itemsFromView = false; if (! $this->replaceWindow) { foreach ($rows as $row) { $this->push($sourceId, (int) $row['sku_source_id'], $row); } return; } $this->replacementRowCount -= count($this->replacementPackages[$sourceId] ?? []); $this->replacementPackages[$sourceId] = $rows; $this->replacementHasFreight[$sourceId] = $hasFreight; $this->replacementHasItems[$sourceId] = $hasItems; $this->replacementSyntheticSkuIds[$sourceId] = $syntheticSkuIds; $this->replacementFromView[$sourceId] = $fromView; $this->replacementRowCount += count($rows); if ($this->replacementRowCount >= self::FLUSH) { $this->flushReplacements(); } } private function flushPending(): void { if ($this->replaceWindow) { $this->flushReplacements(); } else { $this->flush(); } } private function flushReplacements(): void { if ($this->replacementPackages === []) { return; } $packages = $this->replacementPackages; $hasFreight = $this->replacementHasFreight; $hasItems = $this->replacementHasItems; $syntheticSkuIds = $this->replacementSyntheticSkuIds; $fromView = $this->replacementFromView; $this->db->transaction(function () use ($packages, $hasFreight, $hasItems, $syntheticSkuIds, $fromView): void { $ids = array_keys($packages); $previous = []; $previousItems = []; $oldAmountSkuIds = []; $oldHasItems = []; $oldRows = $this->db->table('jst_oms_shipping')->whereIn('source_id', $ids) ->select(array_merge( ['source_id', 'sku_source_id', 'express_no', 'express_name2', 'freight', 'settle_freight', 'weight_kg', 'settle_weight_kg', 'item_id', 'barcode', 'product_name', 'product_code', 'qty', 'amount'], self::PRESERVED_PACKAGE_FIELDS, self::PRESERVED_CONTACT_FIELDS, self::PRESERVED_ITEM_FIELDS, )) ->get(); foreach ($oldRows as $oldRow) { $id = (int) $oldRow->source_id; $row = (array) $oldRow; $previousItems[$id][(int) $oldRow->sku_source_id] = $row; if ($row['amount'] !== null) { $oldAmountSkuIds[$id][(int) $oldRow->sku_source_id] = true; } foreach (['item_id', 'barcode', 'product_name', 'product_code', 'qty', 'amount', 'finished_barcode', 'finished_name', 'finished_supplier'] as $field) { if ($row[$field] !== null && $row[$field] !== '') { $oldHasItems[$id] = true; break; } } if (! isset($previous[$id])) { $previous[$id] = $row; continue; } foreach (array_merge( ['freight', 'settle_freight', 'weight_kg', 'settle_weight_kg', 'express_no', 'express_name2'], self::PRESERVED_PACKAGE_FIELDS, self::PRESERVED_CONTACT_FIELDS, ) as $field) { if (($previous[$id][$field] ?? null) === null || $previous[$id][$field] === '') { $previous[$id][$field] = $row[$field]; } } } $rows = []; $replaceIds = []; $refresh = []; foreach ($packages as $id => $packageRows) { if (! ($hasItems[$id] ?? false) && ($oldHasItems[$id] ?? false)) { $this->packagesRetainedForMissingItems++; $refresh[$id] = $packageRows[0] ?? []; continue; } $unmatchedOldAmounts = $oldAmountSkuIds[$id] ?? []; $unsafeAmount = false; // 旧金额按条码索引:商品行 ID 变了但条码相同时沿用旧金额 $oldAmountByBarcode = []; $oldBarcodes = []; foreach ($previousItems[$id] ?? [] as $oldSkuId => $oldRowItem) { $oldKey = mb_strtolower(trim((string) ($oldRowItem['barcode'] ?? ''))); if ($oldKey === '') { continue; } $oldBarcodes[$oldKey] = true; if ($oldRowItem['amount'] !== null && ! isset($oldAmountByBarcode[$oldKey])) { $oldAmountByBarcode[$oldKey] = [$oldSkuId, $oldRowItem['amount']]; } } $newBarcodes = []; foreach ($packageRows as $newRow) { $newKey = mb_strtolower(trim((string) ($newRow['barcode'] ?? ''))); if ($newKey !== '') { $newBarcodes[$newKey] = true; } } foreach ($packageRows as &$packageRow) { $skuId = (int) $packageRow['sku_source_id']; $oldItem = $previousItems[$id][$skuId] ?? null; if ($packageRow['amount'] !== null) { unset($unmatchedOldAmounts[$skuId]); continue; } if ($oldItem === null || $oldItem['amount'] === null) { $barcodeKey = mb_strtolower(trim((string) ($packageRow['barcode'] ?? ''))); if ($barcodeKey !== '' && isset($oldAmountByBarcode[$barcodeKey])) { [$matchedOldSkuId, $matchedAmount] = $oldAmountByBarcode[$barcodeKey]; $packageRow['amount'] = $matchedAmount; unset($unmatchedOldAmounts[$matchedOldSkuId], $oldAmountByBarcode[$barcodeKey]); $this->amountsMatchedByBarcode++; } continue; } if (isset($syntheticSkuIds[$id][$skuId])) { $oldBarcode = trim((string) $oldItem['barcode']); $newBarcode = trim((string) ($packageRow['barcode'] ?? '')); if ($oldBarcode === '' || $newBarcode === '' || $oldBarcode !== $newBarcode) { $unsafeAmount = true; break; } } $packageRow['amount'] = $oldItem['amount']; unset($unmatchedOldAmounts[$skuId]); } unset($packageRow); if (! $unsafeAmount && $unmatchedOldAmounts !== []) { foreach ($packageRows as $packageRow) { if ($packageRow['amount'] === null) { $unsafeAmount = true; break; } } } if ($unsafeAmount && $oldBarcodes !== [] && $newBarcodes !== [] && array_intersect_key($oldBarcodes, $newBarcodes) === []) { // 新旧商品完全不同:旧记录是错配商品(如把同订单其它包裹的商品挂到本单号),旧金额不属于本包裹,按新商品覆盖 $unsafeAmount = false; $this->packagesItemsCorrected++; } elseif ($unsafeAmount && ($fromView[$id] ?? false) && $oldBarcodes !== [] && $newBarcodes !== [] && $oldBarcodes != $newBarcodes) { // 新商品取自出库单实发明细且条码集合与旧记录不同(旧记录多为订单商品兜底:整件/原商品/配件母件等): // 以出库单为准覆盖;条码相同的行已按条码沿用旧金额,其余旧金额属于错配商品,不再保留。 $unsafeAmount = false; $this->packagesItemsCorrected++; } if ($unsafeAmount) { $this->packagesRetainedForUnmatchedAmount++; $refresh[$id] = $packageRows[0] ?? []; continue; } $replaceIds[] = $id; foreach ($packageRows as &$packageRow) { foreach (self::PRESERVED_PACKAGE_FIELDS as $field) { $packageRow[$field] ??= null; } foreach (self::PRESERVED_ITEM_FIELDS as $field) { $packageRow[$field] = $previousItems[$id][(int) $packageRow['sku_source_id']][$field] ?? null; } } unset($packageRow); if (isset($previous[$id])) { foreach (self::PRESERVED_PACKAGE_FIELDS as $field) { $packageRows[0][$field] = $previous[$id][$field] ?? null; } foreach (self::PRESERVED_CONTACT_FIELDS as $field) { if (($packageRows[0][$field] ?? null) === null || $packageRows[0][$field] === '') { $packageRows[0][$field] = $previous[$id][$field] ?? null; } } if ($this->skipFreight) { $packageRows[0] = $this->withPreservedFreight($packageRows[0], $previous[$id]); } elseif (! ($hasFreight[$id] ?? false)) { $packageRows[0] = $this->withPreservedCharges($packageRows[0], $previous[$id], false); } } array_push($rows, ...$packageRows); } $this->refreshRetainedPackages($refresh); if ($replaceIds === []) { return; } $this->db->table('jst_oms_shipping')->whereIn('source_id', $replaceIds)->delete(); foreach (array_chunk($rows, self::FLUSH) as $chunk) { $this->db->table('jst_oms_shipping')->insert($chunk); } }); $this->replacementPackages = []; $this->replacementHasFreight = []; $this->replacementHasItems = []; $this->replacementSyntheticSkuIds = []; $this->replacementFromView = []; $this->replacementRowCount = 0; } private function flush(): void { if ($this->buffer === []) { return; } $this->db->table('jst_oms_shipping')->insertOrIgnore($this->buffer); $this->buffer = []; } private function isPricePatch(?string $name): bool { if ($name === null || $name === '') { return false; } return str_contains($name, '补收差价') || str_contains($name, '差价专用'); } /** * 线上发货标签排除口径(对齐工作平台): * 工作平台只排除"刷单/批发/纯售后"这类无实物的线上发货虚拟单; * 云仓/京仓(CLPS/ECLP)真实发货工作平台是收进的,不能排除。 * 实测(9.1-9.16):该口径误排 2、误收 0。 */ private function excludedByLabel(?string $labels): bool { if ($labels === null || $labels === '') { return false; } if (! str_contains($labels, '线上发货')) { return false; } // 云仓/京仓真实发货:工作平台收进,保留 if (str_contains($labels, '云仓订单') || str_contains($labels, '京仓订单') || str_contains($labels, 'CLPS') || str_contains($labels, 'ECLP')) { return false; } return true; } /** @return array sku_id => 尺寸/重量/供应商 */ private function loadSkus(array $skuIds): array { $out = []; foreach (array_chunk($skuIds, 800) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); foreach ($this->selectWithReconnect("SELECT sku_id, l, w, h, pack_volume, weight, supplier_name, name, i_id FROM rpt.jst_sku WHERE sku_id IN ({$ph})", $chunk) as $r) { $out[(string) $r['sku_id']] = [ 'l' => $r['l'] ?? null, 'w' => $r['w'] ?? null, 'h' => $r['h'] ?? null, 'vol' => $r['pack_volume'] ?? null, 'weight' => $r['weight'] ?? null, 'supplier' => $r['supplier_name'] ?? null, 'name' => $r['name'] ?? null, 'iid' => $r['i_id'] ?? null, ]; } } return $out; } /** @return array so_id|expressNo => 运费 (含 so_id| 兜底键) */ private function loadFreight(array $soIds): array { $out = []; $first = []; $tracksBySo = []; foreach (array_chunk($soIds, 800) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); $sql = 'SELECT so_id, expressNo, SUM(freightCost) AS cost, SUM(weight) AS w, ' . 'MAX(expressName) AS name, MAX(receiver_state) AS state ' . "FROM rpt.fys_jstFreightCostDetail WHERE so_id IN ({$ph}) GROUP BY so_id, expressNo"; foreach ($this->selectWithReconnect($sql, $chunk) as $r) { $so = (string) $r['so_id']; // 快递单号去@归一,与包裹单号(emittedTrack/doneTrack 等)同一口径 $expno = ltrim(trim((string) ($r['expressNo'] ?? '')), '@'); $val = ['cost' => $r['cost'] ?? null, 'w' => $r['w'] ?? null, 'name' => $r['name'] ?? null, 'state' => $r['state'] ?? null, 'no' => $expno ?: null]; $first[$so] ??= $val; if ($expno === '') { continue; } $tracksBySo[$so][$expno] = true; $key = $so . '|' . $expno; // 同一包裹可能同时有带@和不带@两条(换单前后),保留有运费的那条,不相加 if (! isset($out[$key]) || ! isset($out[$key]['cost'])) { $out[$key] = $val; } } } // 只有一个快递单号的交易单号才允许按交易单号兜底;多包裹订单不能借用其它包裹的运费、单号和快递名称 foreach ($first as $so => $val) { if (count($tracksBySo[$so] ?? []) <= 1) { $out[$so . '|'] = $val; } } return $out; } /** * 聚水潭运费明细里每个包裹(交易单号+快递单号)的 sku,写入 $this->fysPackageSkus; * 返回 [交易单号|快递单号 => [['sku' => ...], ...]] 供 loadSkus 一并加载尺寸重量。 */ private function loadFysPackageSkus(array $soIds): array { $this->fysPackageSkus = []; $this->fysSoTrackCount = []; foreach (array_chunk($soIds, 800) as $chunk) { if ($chunk === []) { continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); $sql = 'SELECT so_id, expressNo, sku_id, COUNT(*) AS n ' . "FROM rpt.fys_jstFreightCostDetail WHERE so_id IN ({$ph}) GROUP BY so_id, expressNo, sku_id"; foreach ($this->selectWithReconnect($sql, $chunk) as $r) { $expno = ltrim(trim((string) ($r['expressNo'] ?? '')), '@'); $sku = trim((string) ($r['sku_id'] ?? '')); if ($expno === '' || $sku === '') { continue; } $key = ((string) $r['so_id']) . '|' . $expno; $this->fysPackageSkus[$key][$sku] = ($this->fysPackageSkus[$key][$sku] ?? 0) + max((int) ($r['n'] ?? 1), 1); } } $items = []; foreach (array_keys($this->fysPackageSkus) as $key) { $so = substr($key, 0, (int) strrpos($key, '|')); $this->fysSoTrackCount[$so] = ($this->fysSoTrackCount[$so] ?? 0) + 1; } foreach ($this->fysPackageSkus as $key => $skus) { foreach (array_keys($skus) as $sku) { $items[$key][] = ['sku' => (string) $sku]; } } return $items; } /** * 窗口内本次未扫到的旧包裹:若其快递单号已由本次其它包裹(新来源键)吐出,说明是被取代的旧记录 * (如凌晨同步时发货明细未到、先按自动发货仓/出库单兜底入库,之后发货明细到齐换成发货明细来源),删除以免重复和残留空白; * 其余仍保留并计数,需按源键核对。 */ private function countUnseenMirrorPackages(string $fromS, string $toS): int { $unseen = 0; $superseded = []; $rows = $this->db->table('jst_oms_shipping') ->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS) ->selectRaw('source_id, MAX(express_no) AS express_no, MAX(trade_no) AS trade_no')->groupBy('source_id')->get(); foreach ($rows as $row) { $id = (int) $row->source_id; if (isset($this->seenSourceIds[$id])) { continue; } $tk = ltrim(trim((string) ($row->express_no ?? '')), '@'); if ($tk !== '' && isset($this->emittedTrack[$tk])) { $superseded[] = $id; continue; } // 凌晨按自动发货仓入库、当时还没有快递单号的旧包裹:订单之后有了发货明细(改走发货明细来源键), // 或同一交易单号本次已由其它包裹吐出 → 视为被取代 // 出库单兜底/采购退货出库:本次已完整扫描窗口内的出库单与采购退货单,未再出现说明上游已作废、换单或不再收录(工作平台也不再显示) if (($id >= self::PURCHASE_OUT_OFFSET && $this->purchaseScanned > 0) || ($id >= self::OUTBOUND_OFFSET && $id < self::PURCHASE_OUT_OFFSET && $this->outboundScanned > 0)) { $superseded[] = $id; continue; } $so = trim((string) ($row->trade_no ?? '')); $isAuto = $id >= self::AUTOSHIP_OFFSET && $id < self::OUTBOUND_OFFSET; if (($isAuto && isset($this->expressOrderIds[(string) ($id - self::AUTOSHIP_OFFSET)])) || ($tk === '' && $so !== '' && isset($this->emittedSo[$so]))) { $superseded[] = $id; continue; } $unseen++; } foreach (array_chunk($superseded, 500) as $chunk) { $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk)->delete(); } $this->supersededRemoved = count($superseded); return $unseen; } /** 因商品/金额无法安全替换而保留的旧包裹:包裹级字段(省市区、快递、仓库、店铺等)仍按本次上游刷新,商品与金额不动。 */ private function refreshRetainedPackages(array $refresh): void { foreach ($refresh as $id => $new) { $set = []; foreach (self::REFRESHED_PACKAGE_FIELDS as $field) { $v = $new[$field] ?? null; if ($v !== null && trim((string) $v) !== '') { $set[$field] = $v; } } if ($set !== []) { $this->db->table('jst_oms_shipping')->where('source_id', (int) $id)->update($set); } } } private function withPreservedFreight(array $payload, array $previous): array { $payload = $this->withPreservedCharges($payload, $previous); if (! empty($previous['express_no'])) { $payload['express_no'] = $previous['express_no']; } $payload['express_name2'] = $previous['express_name2'] ?? $payload['express_name2']; return $payload; } private function withPreservedCharges(array $payload, array $previous, bool $includeNull = true): array { foreach (['freight', 'settle_freight', 'weight_kg', 'settle_weight_kg'] as $field) { if ($includeNull || $previous[$field] !== null) { $payload[$field] = $previous[$field]; } } return $payload; } /** jst_oms_shipping 文本列长度上限:上游偶有超长值(如组合装商品ID),超长截断而不是整批写入失败。 */ private const COLUMN_LIMITS = [ 'express_no' => 64, 'trade_no' => 64, 'item_id' => 64, 'barcode' => 128, 'product_name' => 512, 'receiver_state' => 64, 'receiver_city' => 64, 'receiver_district' => 64, 'receiver_name' => 128, 'receiver_mobile' => 64, 'receiver_phone' => 64, 'shop_name' => 255, 'express_name1' => 128, 'express_name2' => 128, 'warehouse_name' => 128, 'supplier_name' => 128, 'platform' => 64, 'product_code' => 128, 'pkg_count' => 32, 'm_name' => 500, ]; private function cleanPayload(array $data): array { foreach ($data as $k => $v) { if (is_string($v)) { $v = $this->cleanStr($v); $limit = self::COLUMN_LIMITS[$k] ?? null; if ($limit !== null && mb_strlen($v) > $limit) { $v = mb_substr($v, 0, $limit); } $data[$k] = $v; } } return $data; } /** * 订单商品表里平台原始商品行(数量 0、金额 0)并没有实际出库,聚水潭工作平台也不显示; * 包裹已有实发商品时不写入这类行,避免多出一个"条码"(其实是平台商品编号)。 * * @param list> $items * @return list> */ private function dropZeroQtyPlaceholders(array $items): array { $zero = static fn ($v): bool => $v !== null && $v !== '' && is_numeric($v) && (float) $v == 0.0; $placeholder = static fn (array $it): bool => $zero($it['qty'] ?? null) && (($it['amount'] ?? null) === null || ($it['amount'] ?? null) === '' || $zero($it['amount'])); $keep = array_values(array_filter($items, static fn (array $it): bool => ! $placeholder($it))); $hasShipped = array_filter($keep, static fn (array $it): bool => is_numeric($it['qty'] ?? null) && (float) $it['qty'] > 0); return ($hasShipped !== [] && count($keep) < count($items)) ? $keep : $items; } private function cleanStr(string $v): string { $v = str_replace("\0", '', $v); $clean = @iconv('UTF-8', 'UTF-8//IGNORE', $v); if ($clean === false) { $clean = mb_convert_encoding($v, 'UTF-8', 'UTF-8'); } 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]; } /** * 清理历史残留的订单占位行:包裹已有实发商品(数量>0)时,删除数量 0、金额 0/空 的订单商品行 * (被替换的原商品、组合装原套装等,聚水潭销售出库单不显示),商品行数(件数 1/N)随之更正。 * 这类行多半是占位过滤上线前入库、之后因"金额对不上保留旧包裹"一直没被替换下来的。 * 包裹级字段(重量/运费/结算等)若记在被删的首行上,先挪到保留下来的首行。 */ public function purgeZeroQtyPlaceholders(string $fromS, string $toS): int { $pkgFields = array_merge( ['online_weight', 'actual_weight', 'weight_kg', 'settle_weight_kg', 'freight', 'settle_freight'], self::PRESERVED_PACKAGE_FIELDS, ); $sids = $this->db->table('jst_oms_shipping') ->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS) ->where('qty', 0) ->where(fn ($q) => $q->whereNull('amount')->orWhere('amount', 0)) ->distinct()->pluck('source_id')->map(fn ($v) => (int) $v)->all(); $packages = 0; foreach (array_chunk($sids, 500) as $chunk) { $rows = $this->db->table('jst_oms_shipping')->whereIn('source_id', $chunk) ->orderBy('id')->get(array_merge(['id', 'source_id', 'qty', 'amount'], $pkgFields)); $by = []; foreach ($rows as $r) { $by[(int) $r->source_id][] = (array) $r; } foreach ($by as $list) { $isPh = static fn (array $r): bool => is_numeric($r['qty']) && (float) $r['qty'] == 0.0 && ($r['amount'] === null || (is_numeric($r['amount']) && (float) $r['amount'] == 0.0)); $keep = array_values(array_filter($list, fn ($r) => ! $isPh($r))); $drop = array_values(array_filter($list, $isPh)); $shipped = array_filter($keep, static fn ($r) => is_numeric($r['qty']) && (float) $r['qty'] > 0); if ($drop === [] || $shipped === []) { continue; } $this->db->transaction(function () use ($list, $keep, $drop, $isPh, $pkgFields): void { if ($isPh($list[0])) { $set = []; foreach ($pkgFields as $f) { $old = $list[0][$f] ?? null; $cur = $keep[0][$f] ?? null; if (($cur === null || $cur === '') && $old !== null && $old !== '') { $set[$f] = $old; } } if ($set !== []) { $this->db->table('jst_oms_shipping')->where('id', $keep[0]['id'])->update($set); } } $this->db->table('jst_oms_shipping')->whereIn('id', array_column($drop, 'id'))->delete(); $this->db->table('jst_oms_shipping')->whereIn('id', array_column($keep, 'id'))->update(['pkg_count' => '1/' . count($keep)]); }); $packages++; } } return $packages; } private function ts(mixed $v): ?string { if ($v === null || $v === '') { return null; } try { return Carbon::parse((string) $v)->format('Y-m-d H:i:s'); } catch (Throwable) { return null; } } private function markRunning(): void { $this->db->table('jst_sync_state')->updateOrInsert( ['dataset_key' => self::DATASET], ['status' => 'running', 'last_started_at' => now(), 'updated_at' => now(), 'created_at' => now()] ); } private function markSucceeded(): void { $this->db->table('jst_sync_state')->where('dataset_key', self::DATASET)->update([ 'status' => 'succeeded', 'last_succeeded_at' => now(), 'last_error' => null, 'watermark' => now(), 'updated_at' => now(), ]); } private function windowDatasetKey(string $prefix, string $fromS, string $toS): string { return $prefix . Carbon::parse($fromS)->format('YmdHis') . '-' . Carbon::parse($toS)->format('YmdHis'); } private function markFreightIncomplete(string $fromS, string $toS): void { $this->db->table('jst_sync_state')->updateOrInsert( ['dataset_key' => $this->windowDatasetKey(self::FREIGHT_DATASET_PREFIX, $fromS, $toS)], [ 'status' => 'pending', 'last_error' => '本窗口采用数量优先补录;上游运费查询已跳过,新记录的运费和结算重量待补齐,空白不代表 0。', 'updated_at' => now(), 'created_at' => now(), ], ); } private function markItemsIncomplete(string $fromS, string $toS): void { $detail = sprintf( '部分日期窗口的商品或金额待核对:%d 个包裹未匹配商品,%d 个包裹金额无法安全匹配,DWS 预载失败 %d 天。', $this->packagesWithoutItems, $this->packagesRetainedForUnmatchedAmount, count($this->dwsFailedDays), ); $this->db->table('jst_sync_state')->updateOrInsert( ['dataset_key' => $this->windowDatasetKey(self::ITEMS_DATASET_PREFIX, $fromS, $toS)], ['status' => 'pending', 'last_error' => $detail, 'updated_at' => now(), 'created_at' => now()], ); } private function markCovered(string $prefix, string $fromS, string $toS): void { $from = Carbon::parse($fromS)->format('YmdHis'); $to = Carbon::parse($toS)->format('YmdHis'); $states = $this->db->table('jst_sync_state')->where('dataset_key', 'like', $prefix . '%') ->where('status', '!=', 'succeeded')->pluck('dataset_key'); foreach ($states as $key) { $range = substr((string) $key, strlen($prefix)); if (! preg_match('/^\d{14}-\d{14}$/', $range)) { continue; } [$pendingFrom, $pendingTo] = explode('-', $range, 2); if ($from <= $pendingFrom && $pendingTo <= $to) { $this->db->table('jst_sync_state')->where('dataset_key', $key)->update([ 'status' => 'succeeded', 'last_error' => null, 'last_succeeded_at' => now(), 'updated_at' => now(), ]); } } } private function markIncompleteWindow(int $unseen): void { $this->db->table('jst_sync_state')->where('dataset_key', self::DATASET)->update([ 'status' => 'partial', 'last_error' => "本窗口尚有 {$unseen} 个镜像包裹未在上游扫描中出现,已保留原记录,需按源键核对。", 'updated_at' => now(), ]); } private function markFailed(string $err): void { $this->db->table('jst_sync_state')->where('dataset_key', self::DATASET)->update([ 'status' => 'failed', 'last_error' => mb_substr($err, 0, 1000), 'updated_at' => now(), ]); } }