发货视图对采购退货单会重复出很多行(每行都写整单数量),拼接条码还会被截断出残码, 按视图累加把数量放大(如 1190330222661 聚水潭 93 件、财务 1083 件)。 现改为商品行以 rpt.jst_purchaseoutItem 为准:视图同条码沿用名称/长宽高,数量取明细; 视图没有的条码按明细补行;视图多出的残码/重复行丢弃;明细为空时仍按视图。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf > 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。 ## 任务占用与边界 - 任务名称: - 实际开发者: - 任务来源/团队分工: - 目标结果: - 预计修改路径: - 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件): - 仅复用、明确不修改的公共契约: - 数据源、表/视图和字段: - 上游 `source_key` / `scope`: - 权限名、菜单键或共享组件: - 明确不修改的范围: ## 开工碰撞检查 - [ ] Codex 已执行 `scripts/codex-task-preflight.ps1` - [ ] 已检查最新 `main` 和所有远程任务分支 - [ ] 已搜索主线既有页面、接口、Repository 和数据查询 - [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件 - [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论 相关分支、重叠项与协调结论: ## 实际改动 - 完成内容: - Codex 实际修改的文件: - 新增或变更的公共契约: - 与原计划的差异: ## 验证证据 - 正常流程: - 空数据/错误输入/无权限场景: - 受影响旧功能: - 自动测试及结果: - 实际页面、接口或只读业务查询结果: ## 风险检查 - [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复 - [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权 - [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物 - [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用” 高风险影响、回滚和授权状态: ## 合并前复查 - [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查 - [ ] PR 不再是 `WIP:`,所有测试结果已经补齐 - [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff - [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果 - [ ] 合并顺序不会覆盖其他未合并任务 - [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支 - [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权 Reviewed-on: #865
3137 lines
142 KiB
PHP
3137 lines
142 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Services\Jushuitan;
|
||
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use InvalidArgumentException;
|
||
use Modules\EcommerceFinance\Services\UpstreamConnection;
|
||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||
use PDOException;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
|
||
/**
|
||
* 直读经营分析副本库(operations_replica)聚水潭发货明细,清洗写入 jst_oms_shipping。
|
||
* 对齐工作平台口径:明细=一个发货单×每件商品一行(导出明细);合并由查询层按包裹归并(导出)。
|
||
*
|
||
* 每行 = 一个发货单(包裹, express_details) × 其一件商品(OrderItem)。
|
||
* - source_id = 发货单 id(包裹)
|
||
* - sku_source_id = 商品明细 id(件)
|
||
* - pkg_count = '1/N'(同包裹件数)
|
||
* - 体积 volume_cm3 = 数量 × 长 × 宽 × 高(工作平台 Svolume 规则)
|
||
* - 包裹级字段(实称重量/运费)只落在首件,其余置空 —— 便于合并 SUM 不重复
|
||
* - 剔除补差虚拟单;并入自动发货仓(jst_Orders 已发货但不在发货明细表)
|
||
*/
|
||
class ShippingReplicaSynchronizer
|
||
{
|
||
private const UPSTREAM_RECONNECT_RETRIES = 5;
|
||
/** 副本库繁忙超时后的重连等待(毫秒):逐次拉长,白天整月重跑不再因单次超时中断 */
|
||
private const UPSTREAM_RECONNECT_BACKOFF_MS = [2000, 5000, 10000, 20000, 30000];
|
||
|
||
private const SOURCE = 'operations_replica';
|
||
private const DATASET = 'jst.sctrade';
|
||
private const FREIGHT_DATASET_PREFIX = 'jst.sctrade.freight.';
|
||
private const ITEMS_DATASET_PREFIX = 'jst.sctrade.items.';
|
||
private const AUTOSHIP_OFFSET = 1000000000;
|
||
private const OUTBOUND_OFFSET = 2000000000;
|
||
private const PURCHASE_OUT_OFFSET = 3000000000;
|
||
private const DEMO_SOURCE_IDS = [9000001, 9000002, 9000003, 9000004, 9000005, 9000006];
|
||
private const FLUSH = 300;
|
||
private const PRESERVED_PACKAGE_FIELDS = [
|
||
'buyer_nick', 'seller_memo', 'package_no', 'download_time', 'freight_rate', 'freight_formula',
|
||
'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3', 'front_no',
|
||
];
|
||
private const PRESERVED_CONTACT_FIELDS = ['receiver_name', 'receiver_mobile', 'receiver_phone'];
|
||
private const REFRESHED_PACKAGE_FIELDS = [
|
||
'trade_no', 'express_name1', 'warehouse_name', 'shop_name', 'platform', 'pay_time', 'ship_time',
|
||
'receiver_state', 'receiver_city', 'receiver_district',
|
||
];
|
||
private const PRESERVED_ITEM_FIELDS = ['finished_barcode', 'finished_name', 'finished_supplier'];
|
||
|
||
private const VIEW = 'rpt.Jst_SendData_View_extension';
|
||
private const OUTBOUND = 'rpt.jst_salesOutboundInfo';
|
||
private const PURCHASE_OUT = 'rpt.jst_purchaseout';
|
||
|
||
private int $maxExpressBatches = 5000;
|
||
private int $maxAutoShipBatches = 600;
|
||
private bool $skipFreight = false;
|
||
private bool $replaceWindow = false;
|
||
private int $shipTimeFixed = 0;
|
||
private int $weightFixed = 0;
|
||
private int $placeholderPurged = 0;
|
||
|
||
/** @var array<int,list<array<string,mixed>>> */
|
||
private array $replacementPackages = [];
|
||
/** @var array<int,bool> */
|
||
private array $replacementHasFreight = [];
|
||
/** @var array<int,bool> */
|
||
private array $replacementHasItems = [];
|
||
/** @var array<int,array<int,true>> */
|
||
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<int,bool> 待替换包裹 => 商品取自出库单实发明细 */
|
||
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<int,true> */
|
||
private array $seenSourceIds = [];
|
||
|
||
/** @var array<string,true> 发货明细已占用的窗口内订单号 */
|
||
private array $expressOrderIds = [];
|
||
|
||
/** @var list<string> */
|
||
private array $dwsFailedDays = [];
|
||
|
||
private readonly \Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery $up;
|
||
private readonly \Illuminate\Database\ConnectionInterface $db;
|
||
|
||
/** @var array<int,array> */
|
||
private array $buffer = [];
|
||
|
||
/** @var array<string,array> 窗口内 dws 销售全量商品缓存 sourceno=>items */
|
||
private array $dwsCache = [];
|
||
|
||
/** @var array<string,array<string,int>> 本批 交易单号|快递单号(去@) => [sku => 行数],来自聚水潭运费明细,按包裹记录实发商品 */
|
||
private array $fysPackageSkus = [];
|
||
|
||
/** @var array<string,int> 本批 交易单号 => 运费明细里的快递单号个数(去@),>1 即多包裹订单 */
|
||
private array $fysSoTrackCount = [];
|
||
|
||
/** @var array<string,bool> 本轮 express/autoship 已处理(含按标签排除)的出库单 io_id */
|
||
private array $doneIo = [];
|
||
|
||
/** @var array<string,bool> 本轮已处理的快递单号(去@) */
|
||
private array $doneTrack = [];
|
||
|
||
/** 已实际输出的快递单号(去@),用于判断虚拟/补差单是否借用了别的包裹单号 */
|
||
private array $emittedTrack = [];
|
||
|
||
/** @var array<string,bool> 本次已吐出包裹的交易单号 */
|
||
private array $emittedSo = [];
|
||
|
||
/** 快递单号(去@) => [交易单号 => true]:同一单号被不同交易单号共用(合包/拼单)时,按交易单号区分是否已处理 */
|
||
private array $doneTrackSo = [];
|
||
|
||
/** @var array<int,true> 本轮按规则排除的包裹 source_id(取消/拆分/合并/线上发货),窗口内旧记录需清掉 */
|
||
private array $excludedSourceIds = [];
|
||
|
||
/** @var array<string,string> 最近一次 loadViewItems 的 io_id => 平台(视图 shop_site,工作平台口径) */
|
||
private array $ioSite = [];
|
||
|
||
/** @var array<string,array{st?:string,ci?:string,di?:string}> 最近一次 loadViewItems 的 io_id => 视图收货省市区(工作平台口径) */
|
||
private array $ioAddr = [];
|
||
|
||
/** @var array<string,string> io_id => 出库时间 io_date(工作平台发货时间口径) */
|
||
private array $ioDate = [];
|
||
|
||
/** @var array<string,string> io_id => 出库单快递公司(工作平台按实际出库单取,发货明细可能停在作废前的旧分配) */
|
||
private array $ioLc = [];
|
||
|
||
/** @var array<string,string> io_id => 出库单发货仓 wms_co_id */
|
||
private array $ioWms = [];
|
||
|
||
/** @var array<string,array<string,int>> wms_co_id => [仓库名 => 次数]:从快递公司一致的发货明细行学习仓库名 */
|
||
private array $wmsNames = [];
|
||
|
||
/** @var array<int,string> 包裹 source_id => 出库单 wms_co_id(扫描结束后按学到的仓库名统一回填) */
|
||
private array $pendingWarehouse = [];
|
||
|
||
/** @var array<string,bool> io_id => 是否有效出库单(非作废/取消) */
|
||
private array $ioOk = [];
|
||
|
||
/** 换单配对查询超时而跳过的页数(白天副本库繁忙时,不让单页超时中断整轮同步) */
|
||
private int $pairingSkipped = 0;
|
||
|
||
/** 工作平台口径:快递名称为"无需物流"(补差/信誉/运费等虚拟发货)的包裹,仓库统一显示自动发货仓 */
|
||
private const NO_LOGISTICS = '无需物流';
|
||
private const NO_LOGISTICS_WAREHOUSE = '简尚-自动发货仓';
|
||
|
||
/** @var array<string,string> 最近一次 loadViewItems 的 io_id => 视图店铺名(发货明细缺店铺时兜底) */
|
||
private array $ioShop = [];
|
||
|
||
/** @var array<string,string> 最近一次 loadViewItems 的 io_id => 视图快递名称 expressName(工作平台显示的快递名称) */
|
||
private array $ioExpress = [];
|
||
|
||
/** @var array<string,string> 最近一次 loadViewItems 的 io_id => 视图发货仓 wareHoursName(工作平台显示的仓库) */
|
||
private array $ioWare = [];
|
||
/** @var array<string,array{l_id:mixed,send_date:mixed,status:?string,online:bool}> 已查询订单元数据缓存 o_id=>字段 */
|
||
private array $orderMetadata = [];
|
||
|
||
/** @var array<string,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<array<string,mixed>> */
|
||
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<array<string,mixed>> $viewItems
|
||
* @param array<string,array{sku:string,qty:float,name:?string}> $poRows 小写条码 => 明细
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
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<string,array>,1:array<string,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<string,mixed>
|
||
*/
|
||
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<string,array{io:string,l_id:string,old:string}> 以快递明细 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<array<string,mixed>> $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<string,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<string,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<array<string,mixed>> $items
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
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(),
|
||
]);
|
||
}
|
||
}
|