- 凌晨同步时发货明细常未到齐,包裹先按自动发货仓/出库单兜底入库(无省份、无商品);之后发货明细到齐换成发货明细来源键, 旧兜底包裹因"上游未再出现"被保留,造成重复和空白省份(10 月重跑 2,226 个)。现在:旧包裹的快递单号已由本次其它包裹吐出时 视为被取代并删除;无对应的仍保留待核对。 - 商品金额无法安全匹配(或新商品明细缺失)而保留的旧包裹(10 月 28,201 个),此前整包不更新;现在商品与金额不动, 省市区、快递名称、仓库、店铺、平台、交易单号、付款时间按本次上游刷新。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
2549 lines
112 KiB
PHP
2549 lines
112 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',
|
||
'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;
|
||
|
||
/** @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 $staleMirrorPackages = 0;
|
||
private int $supersededRemoved = 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,bool> 本轮 express/autoship 已处理(含按标签排除)的出库单 io_id */
|
||
private array $doneIo = [];
|
||
|
||
/** @var array<string,bool> 本轮已处理的快递单号(去@) */
|
||
private array $doneTrack = [];
|
||
|
||
/** 已实际输出的快递单号(去@),用于判断虚拟/补差单是否借用了别的包裹单号 */
|
||
private array $emittedTrack = [];
|
||
|
||
/** 快递单号(去@) => [交易单号 => 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->staleMirrorPackages = 0;
|
||
$this->supersededRemoved = 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->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 . ' 条');
|
||
}
|
||
}
|
||
} 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['dws_failed_days'] = $this->dwsFailedDays;
|
||
$result['stale_mirror_packages'] = $this->staleMirrorPackages;
|
||
$result['superseded_packages_removed'] = $this->supersededRemoved;
|
||
$result['tracking_pairing_skipped_pages'] = $this->pairingSkipped;
|
||
}
|
||
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->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));
|
||
$skus = $this->loadSkus($this->collectSkuIds($byOid, $bySo, $dws, $viewItems));
|
||
$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 = ($io !== '' && ! empty($viewItems[$io]))
|
||
? $viewItems[$io]
|
||
: $this->itemsFor($byOid, $bySo, $dws, $oid, $so);
|
||
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));
|
||
$skus = $this->loadSkus($this->collectSkuIds($byOid, $bySo, $dws, $viewItems));
|
||
$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 = ($io !== '' && ! empty($viewItems[$io]))
|
||
? $viewItems[$io]
|
||
: $this->itemsFor($byOid, $bySo, $dws, $oid, $so);
|
||
$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);
|
||
$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++;
|
||
}
|
||
$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;
|
||
}
|
||
|
||
/**
|
||
* 采购退货出库(退回供应商):工作平台的发货数据视图收录了其中走物流的那部分,按视图补吐。
|
||
* 无交易单号/店铺/平台;仓库取视图(发货仓),收货信息取采购退货单。
|
||
*/
|
||
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]
|
||
);
|
||
$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),体积=单件长宽高×数量(对齐工作平台)
|
||
$poQty = [];
|
||
foreach (array_chunk($ioIds, 800) as $ck) {
|
||
$ph = implode(',', array_fill(0, count($ck), '?'));
|
||
foreach ($this->selectWithReconnect("SELECT io_id, sku_id, qty FROM rpt.jst_purchaseoutItem WHERE io_id IN ({$ph})", $ck) as $pr) {
|
||
$pk = mb_strtolower(trim((string) ($pr['sku_id'] ?? '')));
|
||
$poQty[(string) $pr['io_id']][$pk] = ($poQty[(string) $pr['io_id']][$pk] ?? 0) + (float) ($pr['qty'] ?? 0);
|
||
}
|
||
}
|
||
foreach ($viewItems as $vio => $vitems) {
|
||
foreach ($vitems as $vi => $it) {
|
||
$pq = $poQty[(string) $vio][mb_strtolower((string) $it['sku'])] ?? null;
|
||
if ($pq === null || $pq <= 0) {
|
||
continue;
|
||
}
|
||
$pq = (int) round($pq);
|
||
$viewItems[$vio][$vi]['qty'] = $pq;
|
||
}
|
||
}
|
||
$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);
|
||
$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)));
|
||
$n = count($items);
|
||
$tk = ltrim(trim((string) ($tracking ?? '')), '@');
|
||
if ($tk !== '') {
|
||
$this->emittedTrack[$tk] = true;
|
||
}
|
||
$this->seenSourceIds[$pkgId] = true;
|
||
$this->packagesProcessed++;
|
||
if ($n === 0) {
|
||
$this->packagesWithoutItems++;
|
||
}
|
||
$f = $freight[$so . '|' . ($tracking ?? '')] ?? ($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;
|
||
}
|
||
|
||
/**
|
||
* 工作平台的快递名称/仓库即发货数据视图的 expressName / wareHoursName(抽样 14/14 一致):
|
||
* 视图有值时以视图为准;视图无该出库单时保留出库单/发货明细推断的值。
|
||
*/
|
||
private function withViewCarrier(string $io, array $pkg, int $pkgId): array
|
||
{
|
||
if ($io === '') {
|
||
return $pkg;
|
||
}
|
||
if (! 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,
|
||
];
|
||
}
|
||
|
||
/** 该包裹的商品:优先 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
|
||
{
|
||
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->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;
|
||
$this->db->transaction(function () use ($packages, $hasFreight, $hasItems, $syntheticSkuIds): 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;
|
||
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) {
|
||
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) {
|
||
$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->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 = [];
|
||
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) {
|
||
$expno = (string) ($r['expressNo'] ?? '');
|
||
$val = ['cost' => $r['cost'] ?? null, 'w' => $r['w'] ?? null, 'name' => $r['name'] ?? null, 'state' => $r['state'] ?? null, 'no' => $expno ?: null];
|
||
$out[((string) $r['so_id']) . '|' . $expno] = $val;
|
||
$out[((string) $r['so_id']) . '|'] ??= $val;
|
||
}
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* 窗口内本次未扫到的旧包裹:若其快递单号已由本次其它包裹(新来源键)吐出,说明是被取代的旧记录
|
||
* (如凌晨同步时发货明细未到、先按自动发货仓/出库单兜底入库,之后发货明细到齐换成发货明细来源),删除以免重复和残留空白;
|
||
* 其余仍保留并计数,需按源键核对。
|
||
*/
|
||
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')->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;
|
||
}
|
||
$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;
|
||
}
|
||
|
||
private function cleanPayload(array $data): array
|
||
{
|
||
foreach ($data as $k => $v) {
|
||
if (is_string($v)) {
|
||
$data[$k] = $this->cleanStr($v);
|
||
}
|
||
}
|
||
|
||
return $data;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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(),
|
||
]);
|
||
}
|
||
}
|