fix(发货数据): 两种导出模式对齐工作平台取值逻辑

按工作平台 OmsDataDay 源码 + 9.1-9.16 逐单对比反推,合并/明细基础信息一致率均达 99.9% 以上。

取数范围
- 换单配对:快递明细旧单号在出库单查不到时,配同订单下未被占用的出库单新单号
- 作废重出:同单号/同订单多张出库单时优先有效单、再取最新 io_date
- 订单已取消/已拆分/已合并的快递明细不出行;订单标签含"线上发货"同样排除
- 合包/拼单:按 单号+订单号 定位出库单;同单号不同交易单号的出库单照常补出
- 发货明细无单号:出库单单号 -> 订单单号 -> 空,不再借运费表里其它包裹单号
- 自动发货仓虚拟/补差单:单号已被别的包裹输出则留空
- @ 去重只在同一交易单号、本次同步时间段内比对,剩余 @ 前缀统一去掉

字段取值
- 发货时间取出库单 io_date;平台取视图 shop_site(无则映射);省市区取视图
- 合并商品名称/供应商取包裹内最后一个商品(原为 MIN);名称 & 转 ^
- 明细体积:数量×长×宽×高,代发仓非标准承运商不乘数量,指定快递为 0(config detail_volume)
- 组合件合并值单独存 m_* 列(新迁移),合并导出优先使用
- 仓库 {未指定发货仓} 导出为空;明细 0 尺寸/体积导出为空

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf
This commit is contained in:
limeng 2026-10-07 09:14:31 +08:00
parent 0e0ea619c6
commit f06380bcd0
5 changed files with 751 additions and 49 deletions

View File

@ -27,7 +27,7 @@ class JstDump extends Command
['actual_weight', '实称重量'], ['online_weight', '线上重量'], ['freight', '运费'],
['ship_time', '发货时间'], ['pay_time', '付款时间'], ['product_code', '货号'],
['receiver_state', '省'], ['receiver_city', '市'], ['receiver_district', '区'],
['package_no', '配货单号'], ['amount', '金额'],
['package_no', '配货单号'], ['amount', '金额'], ['express_name1', '快递名称'],
];
$fh = fopen('php://output', 'w');
fwrite($fh, chr(0xEF).chr(0xBB).chr(0xBF));

View File

@ -38,6 +38,21 @@ return [
'retry_sleep_ms' => (int) env('JST_RETRY_SLEEP_MS', 1000),
],
// 发货数据「导出明细」体积口径(对齐工作平台 OmsDataDay:明细体积默认 = 数量 × 长×宽×高)。
// 依据:工作平台 9.1-9.16 导出明细,非代发仓 8421/8421 行乘数量;代发仓走简尚线路快递的不乘数量。
// 合并口径体积不受此影响(单独存 m_volume_cm3)。
'detail_volume' => [
// 这些发货仓的明细体积不乘数量(工厂代发虚拟仓)
'no_qty_warehouses' => ['常州简尚家居有限公司'],
// 上述仓库中,用这些标准承运商发货时仍乘数量
'qty_carriers_in_no_qty_warehouses' => [
'中通快运', '韵达快递', '申通E物流', '安能物流', '邮政电商标快', '邮政快递包裹POSTB',
'壹米滴答', '百世物流', '德邦快递', '中通速递', '圆通速递',
],
// 这些快递的明细体积一律为 0
'zero_volume_expresses' => ['白云韵达-天猫亿家达', '中通礼品单'],
],
// 接口方法名(对应工作平台 ErpTradeData 中的调用)。
'methods' => [
'sctrade_list' => 'erp.sctrade.query', // 出库单列表(按时间窗分页)

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 组合件(视图一行多条码)拆件后:明细行存部件自己的名称/尺寸/体积,
* 合并导出改用以下"合并用"列(整行值),对齐工作平台两种口径。普通行这些列为空。
*/
return new class extends Migration
{
private array $cols = ['m_name', 'm_volume_cm3', 'm_length_cm', 'm_width_cm', 'm_height_cm'];
private function conn(): \Illuminate\Database\Schema\Builder
{
return Schema::connection((string) config('jushuitan.connection', 'jushuitan_local'));
}
public function up(): void
{
$schema = $this->conn();
if (! $schema->hasTable('jst_oms_shipping')) {
return;
}
$schema->table('jst_oms_shipping', function (Blueprint $table) use ($schema): void {
if (! $schema->hasColumn('jst_oms_shipping', 'm_name')) {
$table->string('m_name', 500)->nullable()->comment('合并用商品名称(组合件整行)');
}
if (! $schema->hasColumn('jst_oms_shipping', 'm_volume_cm3')) {
$table->decimal('m_volume_cm3', 18, 4)->nullable()->comment('合并用体积(组合件整行均摊)');
}
foreach (['m_length_cm' => '长', 'm_width_cm' => '宽', 'm_height_cm' => '高'] as $c => $label) {
if (! $schema->hasColumn('jst_oms_shipping', $c)) {
$table->decimal($c, 12, 2)->nullable()->comment('合并用' . $label . '(组合件整行)');
}
}
});
}
public function down(): void
{
$schema = $this->conn();
if (! $schema->hasTable('jst_oms_shipping')) {
return;
}
$drop = array_values(array_filter($this->cols, fn ($c) => $schema->hasColumn('jst_oms_shipping', $c)));
if ($drop) {
$schema->table('jst_oms_shipping', function (Blueprint $table) use ($drop): void {
$table->dropColumn($drop);
});
}
}
};

View File

@ -17,6 +17,11 @@ class ShippingOrderRepository
public function __construct()
{
$this->db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
try {
// 合并取"最后一行"值用 GROUP_CONCAT,放宽默认 1024 长度避免截断
$this->db->statement('SET SESSION group_concat_max_len = 4194304');
} catch (\Throwable) {
}
}
/**
@ -36,7 +41,7 @@ class ShippingOrderRepository
->orderByDesc('source_id')
->orderBy('sku_source_id')
->forPage($page, $perPage)
->get()->map(fn ($r) => (array) $r)->all();
->get()->map(fn ($r) => $this->detailRow((array) $r))->all();
return ['data' => $rows, 'total' => $total];
}
@ -55,6 +60,28 @@ class ShippingOrderRepository
return ['data' => $rows, 'total' => $total];
}
/** 明细口径:无尺寸(0)的长宽高/体积显示为空白,对齐工作平台导出明细(合并保持 0)。 */
private function detailRow(array $r): array
{
foreach (['length_cm', 'width_cm', 'height_cm', 'volume_cm3'] as $c) {
if (array_key_exists($c, $r) && $r[$c] !== null && $r[$c] !== '' && (float) $r[$c] == 0.0) {
$r[$c] = null;
}
}
return $r;
}
/**
* 包裹内"最后一行"(按明细 id 顺序)的值。工作平台合并的商品名称/供应商取的是
* 条码串最后一个商品那行(样本 6213/6216、575/575),不是 MIN。
*/
private function lastOf(string $expr): string
{
// SEPARATOR 只能是字符串常量;用名称里不会出现的 '#~#' 作分隔
return "NULLIF(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE({$expr}, '') ORDER BY `id` SEPARATOR '#~#'), '#~#', -1), '')";
}
/** 合并聚合 SELECT:条码/商品ID 逗号拼接、数量/重量/体积/金额/运费求和、长宽高取最大、其余取值。 */
private function mergedSelectRaw(): string
{
@ -71,14 +98,22 @@ class ShippingOrderRepository
'weight_kg', 'settle_weight_kg', 'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3',
'finished_barcode', 'finished_name', 'finished_supplier', 'settle_freight', 'front_no'];
// 组合件拆件后明细存部件值,合并取整行值(m_*),普通行 m_* 为空则回落原列
$mcol = ['product_name' => 'm_name', 'volume_cm3' => 'm_volume_cm3',
'length_cm' => 'm_length_cm', 'width_cm' => 'm_width_cm', 'height_cm' => 'm_height_cm'];
$parts = ['MAX(id) as id', 'source_id'];
foreach ($all as $c) {
if (in_array($c, $sum, true)) {
if ($c === 'product_name') {
$parts[] = $this->lastOf("COALESCE(`m_name`, `product_name`)") . " as `{$c}`";
} elseif (isset($mcol[$c])) {
$fn = $c === 'volume_cm3' ? 'SUM' : 'MAX';
$parts[] = "{$fn}(COALESCE(`{$mcol[$c]}`, `{$c}`)) as `{$c}`";
} elseif (in_array($c, $sum, true)) {
$parts[] = "SUM(`{$c}`) as `{$c}`";
} elseif (in_array($c, $concat, true)) {
$parts[] = "GROUP_CONCAT(DISTINCT `{$c}` SEPARATOR ',') as `{$c}`";
} elseif (in_array($c, $min, true)) {
$parts[] = "MIN(`{$c}`) as `{$c}`";
$parts[] = $this->lastOf("`{$c}`") . " as `{$c}`";
} else {
$parts[] = "MAX(`{$c}`) as `{$c}`";
}
@ -107,7 +142,7 @@ class ShippingOrderRepository
return;
}
foreach ($rows as $row) {
yield (array) $row;
yield $this->detailRow((array) $row);
}
$last = (int) $rows[array_key_last($rows)]->id;
if (count($rows) < $batchSize) {

View File

@ -25,10 +25,12 @@ class ShippingReplicaSynchronizer
private const DATASET = 'jst.sctrade';
private const AUTOSHIP_OFFSET = 1000000000;
private const OUTBOUND_OFFSET = 2000000000;
private const PURCHASE_OUT_OFFSET = 3000000000;
private const FLUSH = 300;
private const VIEW = 'rpt.Jst_SendData_View_extension';
private const OUTBOUND = 'rpt.jst_salesOutboundInfo';
private const PURCHASE_OUT = 'rpt.jst_purchaseout';
private readonly \Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery $up;
private readonly \Illuminate\Database\ConnectionInterface $db;
@ -45,6 +47,24 @@ class ShippingReplicaSynchronizer
/** @var array<string,bool> 本轮已处理的快递单号(去@) */
private array $doneTrack = [];
/** 已实际输出的快递单号(去@),用于判断虚拟/补差单是否借用了别的包裹单号 */
private array $emittedTrack = [];
/** 快递单号(去@) => [交易单号 => true]:同一单号被不同交易单号共用(合包/拼单)时,按交易单号区分是否已处理 */
private array $doneTrackSo = [];
/** @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> 最近一次 loadViewItems 的 io_id => 视图店铺名(发货明细缺店铺时兜底) */
private array $ioShop = [];
public function __construct(UpstreamReadOnlyQueryFactory $factory)
{
$this->up = $factory->forSource(self::SOURCE);
@ -76,8 +96,12 @@ class ShippingReplicaSynchronizer
$total = 0;
$auto = 0;
$outb = 0;
$pout = 0;
$this->doneIo = [];
$this->doneTrack = [];
$this->emittedTrack = [];
$this->doneTrackSo = [];
$this->ioDate = [];
if ($windowed) {
$this->preloadDws($from, $to);
if ($progress) {
@ -86,9 +110,10 @@ class ShippingReplicaSynchronizer
$total = $this->syncExpress(true, $fromS, $toS, $limit, $now, $progress);
$auto = $this->syncAutoShip($fromS, $toS, $now, $progress);
$outb = $this->syncOutbound($fromS, $toS, $now, $progress);
$pout = $this->syncPurchaseOut($fromS, $toS, $now, $progress);
$this->dwsCache = [];
gc_collect_cycles();
$removed = $this->dedupAtPrefix();
$removed = $this->dedupAtPrefix($fromS, $toS);
if ($progress && $removed > 0) {
$progress('去除@重复单号:' . $removed . ' 条');
}
@ -97,7 +122,7 @@ class ShippingReplicaSynchronizer
}
$this->markSucceeded();
return ['processed' => $total, 'autoship' => $auto, 'outbound' => $outb];
return ['processed' => $total, 'autoship' => $auto, 'outbound' => $outb, 'purchase_out' => $pout];
} catch (Throwable $e) {
$this->markFailed($e->getMessage());
throw $e;
@ -105,29 +130,46 @@ class ShippingReplicaSynchronizer
}
/** 去除以 @ 开头、去@后已存在的重复单号(换单/重拉产生;工作平台只保留干净版)。 */
public function dedupAtPrefix(): int
public function dedupAtPrefix(?string $fromS = null, ?string $toS = null): int
{
$all = $this->db->table('jst_oms_shipping')
->whereNotNull('express_no')->where('express_no', '!=', '')
->distinct()->pluck('express_no')->all();
$set = [];
foreach ($all as $e) {
$set[(string) $e] = true;
// 只删"同一交易单号下 @X 与 X 并存"的 @X 行;不同交易单号/窗口外的旧行不作为依据,
// 避免窗口外残留的旧 X 行把窗口内真实包裹 @X 误删(如作废重出的出库单)。
// 只在本次同步窗口内互相比对:窗口外的历史残留行不参与(否则会误删窗口内真实包裹)
$win = function ($q) use ($fromS, $toS) {
if ($fromS !== null && $toS !== null) {
$q->where('ship_time', '>=', $fromS)->where('ship_time', '<', $toS);
}
return $q;
};
$at = $win($this->db->table('jst_oms_shipping')->where('express_no', 'like', '@%'))
->select('express_no', 'trade_no')->distinct()->get();
if ($at->isEmpty()) {
return 0;
}
$toDel = [];
foreach (array_keys($set) as $e) {
$e = (string) $e;
if ($e !== '' && $e[0] === '@' && isset($set[substr($e, 1)])) {
$toDel[] = $e;
$plain = [];
foreach ($at as $r) {
$plain[substr((string) $r->express_no, 1)] = true;
}
$have = [];
foreach (array_chunk(array_keys($plain), 500) as $chunk) {
foreach ($win($this->db->table('jst_oms_shipping')->whereIn('express_no', $chunk))
->select('express_no', 'trade_no')->distinct()->get() as $r) {
$have[(string) $r->express_no . '|' . (string) $r->trade_no] = true;
}
}
$removed = 0;
foreach (array_chunk($toDel, 500) as $chunk) {
if ($chunk === []) {
continue;
foreach ($at as $r) {
$x = substr((string) $r->express_no, 1);
if (isset($have[$x . '|' . (string) $r->trade_no])) {
$removed += (int) $win($this->db->table('jst_oms_shipping')
->where('express_no', (string) $r->express_no)
->where('trade_no', $r->trade_no))->delete();
}
$removed += (int) $this->db->table('jst_oms_shipping')->whereIn('express_no', $chunk)->delete();
}
// 剩余 @X:工作平台导出单号不带 @,统一去掉前缀
$win($this->db->table('jst_oms_shipping')->where('express_no', 'like', '@%'))
->update(['express_no' => $this->db->raw('SUBSTRING(express_no, 2)')]);
return $removed;
}
@ -139,7 +181,8 @@ class ShippingReplicaSynchronizer
$maxBatches = 5000;
$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, jo.l_id AS jo_lid, jo.send_date AS jo_send';
. 'e.weight, e.f_weight, e.buyer_message, e.order_labels, jo.l_id AS jo_lid, jo.send_date AS jo_send, '
. 'jo.status AS jo_status, jo.labels AS jo_labels';
// 窗口模式:只按主键 e.id 翻页(有索引,毫秒级),send_time 窗口过滤放到 PHP,
// 避免每页都对无索引的 send_time 做全表扫描(那是"卡死"的真凶)。
@ -220,9 +263,12 @@ class ShippingReplicaSynchronizer
}
// 主口径:发货数据视图按出库单(io_id)取实发商品
$ioMap = $this->loadIoMap(array_keys($logs), array_keys($oids));
$swap = $this->pairChangedTracking($rows, $ioMap);
$ioIds = [];
foreach ($rows as $r) {
$io = $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), (string) ($r['inner_order_id'] ?? ''));
$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;
}
@ -236,23 +282,48 @@ class ShippingReplicaSynchronizer
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'] ?? ''));
continue;
}
$sw = $swap[(string) $pkgId] ?? null;
if ($sw !== null) {
// 换单:快递明细仍是旧单号,出库单已换成新单号 → 用新单号与对应出库单(工作平台口径)
$r['logistic_id'] = $sw['l_id'];
}
$this->markDone(
$this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), (string) ($r['inner_order_id'] ?? '')),
$sw !== null ? $sw['io'] : $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), (string) ($r['inner_order_id'] ?? '')),
(string) ($r['logistic_id'] ?? ''),
(string) ($r['jo_lid'] ?? '')
(string) ($r['jo_lid'] ?? ''),
$sw !== null ? $sw['old'] : ''
);
if ($this->excludedByLabel($r['order_labels'] ?? null)) {
continue; // 线上发货(平台代发/云仓):工作平台不拉,对齐排除
$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)) {
continue; // 线上发货(平台代发/云仓):工作平台不拉,对齐排除(发货明细标签可能滞后,订单标签一并判断)
}
$so = (string) ($r['so_id'] ?? '');
$oid = (string) ($r['inner_order_id'] ?? '');
$io = $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), $oid);
$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);
$tracking = $this->tracking((string) ($r['logistic_id'] ?? ''), (string) ($r['jo_lid'] ?? ''), $freight, $so);
$shipTime = $this->ts($r['send_time'] ?? null) ?? $this->ts($r['jo_send'] ?? null);
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['platform'] = $this->platformOf($io, $pkg['platform']);
$pkg = $this->withViewAddr($io, $pkg);
$total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now);
}
@ -352,7 +423,13 @@ class ShippingReplicaSynchronizer
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)) {
continue; // 线上发货:对齐排除
}
@ -361,9 +438,10 @@ class ShippingReplicaSynchronizer
$items = ($io !== '' && ! empty($viewItems[$io]))
? $viewItems[$io]
: $this->itemsFor($byOid, $bySo, $dws, $oid, $so);
$tracking = $this->tracking((string) ($r['l_id'] ?? ''), '', $freight, $so);
$shipTime = $this->ts($r['send_date'] ?? null);
$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;
$total += $this->emitPackage($pkgId, $pkg, $items, $skus, $freight, $so, $tracking, $now, true);
}
@ -383,6 +461,40 @@ class ShippingReplicaSynchronizer
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 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
{
@ -443,10 +555,12 @@ class ShippingReplicaSynchronizer
$skip['notrack']++;
continue;
}
if (isset($this->doneIo[$io]) || isset($this->doneTrack[ltrim($lid, '@')])) {
if (isset($this->doneIo[$io])) {
$skip['done']++;
continue;
}
// 单号已处理过:若是别的交易单号共用此单号(合包/拼单),仍要补吐;交易单号要等视图表头加载后判断
$r['_trk_done'] = isset($this->doneTrack[ltrim($lid, '@')]);
$st = (string) ($r['status'] ?? '');
if (stripos($st, 'cancel') !== false || stripos($st, 'delete') !== false) {
$skip['status']++;
@ -480,9 +594,18 @@ class ShippingReplicaSynchronizer
$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, $this->ts($r['io_date'] ?? null), $h, $so);
$pkg = $this->withViewAddr($io, $pkg);
$total += $this->emitPackage(self::OUTBOUND_OFFSET + (int) $io, $pkg, $viewItems[$io], $skus, $freight, $so, $tracking, $now, true);
$this->markDone($io, $tracking);
$this->markTrackSo($so, $tracking);
$emitted++;
}
unset($viewItems, $heads, $skus, $freight);
@ -502,6 +625,106 @@ class ShippingReplicaSynchronizer
return $total;
}
/**
* 采购退货出库(退回供应商):工作平台的发货数据视图收录了其中走物流的那部分,按视图补吐。
* 无交易单号/店铺/平台;仓库取视图(发货仓),收货信息取采购退货单。
*/
private function syncPurchaseOut(string $fromS, string $toS, $now, ?callable $progress = null): int
{
$rows = $this->up->select(
'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->up->select("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->flush();
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
{
@ -564,6 +787,10 @@ class ShippingReplicaSynchronizer
{
$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;
}
$f = $freight[$so . '|' . ($tracking ?? '')] ?? ($freight[$so . '|'] ?? []);
if ($n === 0) {
@ -584,7 +811,49 @@ class ShippingReplicaSynchronizer
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;" 导出为 "^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);
@ -604,7 +873,7 @@ class ShippingReplicaSynchronizer
'trade_no' => $pkg['so_id'],
'item_id' => $it['shopid'] ?? null,
'barcode' => $it['sku'] ?? null,
'product_name' => ($it['name'] ?? null) ?: ($sk['name'] ?? 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),
@ -615,7 +884,7 @@ class ShippingReplicaSynchronizer
'shop_name' => $pkg['shop_name'],
'platform' => $pkg['platform'],
'supplier_name' => ($it['supplier'] ?? null) ?: (($sk['supplier'] ?? null) ?: $pkg['dis_co_name']),
'warehouse_name' => $pkg['warehouse_name'] ?: ($it['warehouse'] ?? null),
'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'],
@ -628,7 +897,12 @@ class ShippingReplicaSynchronizer
'length_cm' => $L,
'width_cm' => $W,
'height_cm' => $H,
'volume_cm3' => $vol,
'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,
@ -837,17 +1111,54 @@ class ShippingReplicaSynchronizer
/** 快递单号/内部单号 -> 出库单 io_id(优先快递单号,精确到该次出库)。 */
private function loadIoMap(array $logisticIds, array $oIds): array
{
// 同一单号/订单可能有多张出库单(作废重出):优先有效(非 Delete/Cancelled),再取最新 io_date
$byL = [];
$byO = [];
$byOAll = [];
$ioL = [];
$rankL = [];
$rankO = [];
$byLO = [];
$rankLO = [];
$rank = static function (array $r): string {
$st = strtolower(trim((string) ($r['status'] ?? '')));
$ok = in_array($st, ['delete', 'deleted', 'cancelled', 'cancel', 'canceled'], true) ? '0' : '1';
return $ok . '|' . (string) ($r['io_date'] ?? '') . '|' . str_pad((string) ($r['io_id'] ?? ''), 12, '0', STR_PAD_LEFT);
};
foreach (array_chunk(array_values(array_unique(array_filter($logisticIds))), 800) as $chunk) {
if ($chunk === []) {
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->up->select('SELECT l_id, io_id FROM ' . self::OUTBOUND . " WHERE l_id IN ({$ph})", $chunk) as $r) {
$l = (string) ($r['l_id'] ?? '');
if ($l !== '' && ! isset($byL[$l])) {
$byL[$l] = (string) ($r['io_id'] ?? '');
$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->up->select('SELECT l_id, io_id, io_date, status, o_id FROM ' . self::OUTBOUND . " WHERE l_id IN ({$ph})", $q) as $r) {
$rio = (string) ($r['io_id'] ?? '');
if ($rio !== '' && ! empty($r['io_date'])) {
$this->ioDate[$rio] = (string) $r['io_date'];
}
$l = ltrim(trim((string) ($r['l_id'] ?? '')), '@');
if ($rio !== '') {
$ioL[$rio] = $l;
}
$rk = $rank($r);
if ($l !== '' && $rio !== '' && (! isset($rankL[$l]) || strcmp($rk, $rankL[$l]) > 0)) {
$byL[$l] = $rio;
$rankL[$l] = $rk;
}
// 同一单号多订单共用(拼单/合包):按 单号+订单 精确定位,避免拿到别的订单的商品
$lo = $l . '|' . (string) ($r['o_id'] ?? '');
if ($l !== '' && $rio !== '' && (! isset($rankLO[$lo]) || strcmp($rk, $rankLO[$lo]) > 0)) {
$byLO[$lo] = $rio;
$rankLO[$lo] = $rk;
}
}
}
@ -856,19 +1167,96 @@ class ShippingReplicaSynchronizer
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->up->select('SELECT o_id, io_id FROM ' . self::OUTBOUND . " WHERE o_id IN ({$ph})", $chunk) as $r) {
foreach ($this->up->select('SELECT o_id, io_id, io_date, l_id, status FROM ' . self::OUTBOUND . " WHERE o_id IN ({$ph})", $chunk) as $r) {
$rio = (string) ($r['io_id'] ?? '');
if ($rio !== '' && ! empty($r['io_date'])) {
$this->ioDate[$rio] = (string) $r['io_date'];
}
$o = (string) ($r['o_id'] ?? '');
if ($o !== '' && ! isset($byO[$o])) {
$byO[$o] = (string) ($r['io_id'] ?? '');
$l = ltrim(trim((string) ($r['l_id'] ?? '')), '@');
if ($rio !== '') {
$ioL[$rio] = $l;
}
$rk = $rank($r);
if ($o !== '' && $rio !== '' && $rk[0] === '1') {
$byOAll[$o][$rio] = $l;
}
if ($o !== '' && $rio !== '' && (! isset($rankO[$o]) || strcmp($rk, $rankO[$o]) > 0)) {
$byO[$o] = $rio;
$rankO[$o] = $rk;
}
}
}
return ['byL' => $byL, 'byO' => $byO];
return ['byL' => $byL, 'byO' => $byO, 'byOAll' => $byOAll, 'ioL' => $ioL, 'byLO' => $byLO];
}
/**
* 换单配对:快递明细的单号在出库单里找不到时,到同一内部订单下找一张
* "出库单有单号、但该单号不在任何快递明细里"的出库单,一对一配对(工作平台换单后只保留新单号)。
*
* @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'] ?? '');
if ($lg === '' || $oid === '' || isset($ioMap['byL'][$lg]) || 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), '?'));
foreach ($this->up->select("SELECT DISTINCT logistic_id FROM rpt.jst_order_express_details WHERE logistic_id IN ({$ph})", $q) 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];
}
@ -879,19 +1267,41 @@ class ShippingReplicaSynchronizer
return '';
}
/** io_id -> 实发商品[itemArr](发货数据视图,按(io_id,条码)去重,type=0 之和优先)。 */
/**
* io_id -> 实发商品[itemArr](发货数据视图)。
* 单条码行:按(io_id,条码)去重,type=0 之和优先。
* 多条码行(组合件):见 appendComboParts,对齐工作平台导出明细的拆件口径。
*/
private function loadViewItems(array $ioIds): array
{
$agg = [];
$multi = [];
$this->ioSite = [];
$this->ioAddr = [];
$this->ioShop = [];
foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 800) 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, l, w, h, volume, weight, supplier_name FROM '
$sql = 'SELECT io_id, sku_ids, qty, type, sku_name, shop_i_id, wareHoursName, 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->up->select($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 !== '') {
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;
@ -903,6 +1313,15 @@ class ShippingReplicaSynchronizer
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;
@ -973,9 +1392,188 @@ class ShippingReplicaSynchronizer
];
}
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->up->select('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->up->select("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->up->select("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 = [];