diff --git a/app/Console/Commands/JstDump.php b/app/Console/Commands/JstDump.php index 72b36b91..baf5096f 100644 --- a/app/Console/Commands/JstDump.php +++ b/app/Console/Commands/JstDump.php @@ -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)); diff --git a/config/jushuitan.php b/config/jushuitan.php index afbd0882..5878cee4 100644 --- a/config/jushuitan.php +++ b/config/jushuitan.php @@ -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', // 出库单列表(按时间窗分页) diff --git a/modules/EcommerceFinance/Database/Migrations/2026_10_05_000001_add_merged_cols_to_jst_oms_shipping.php b/modules/EcommerceFinance/Database/Migrations/2026_10_05_000001_add_merged_cols_to_jst_oms_shipping.php new file mode 100644 index 00000000..9d6bebe7 --- /dev/null +++ b/modules/EcommerceFinance/Database/Migrations/2026_10_05_000001_add_merged_cols_to_jst_oms_shipping.php @@ -0,0 +1,54 @@ +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); + }); + } + } +}; diff --git a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php index e6bfc714..4149b9d1 100644 --- a/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php +++ b/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php @@ -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) { diff --git a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php index 1c70b384..668c415f 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -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 本轮已处理的快递单号(去@) */ private array $doneTrack = []; + /** 已实际输出的快递单号(去@),用于判断虚拟/补差单是否借用了别的包裹单号 */ + private array $emittedTrack = []; + + /** 快递单号(去@) => [交易单号 => true]:同一单号被不同交易单号共用(合包/拼单)时,按交易单号区分是否已处理 */ + private array $doneTrackSo = []; + + /** @var array 最近一次 loadViewItems 的 io_id => 平台(视图 shop_site,工作平台口径) */ + private array $ioSite = []; + + /** @var array 最近一次 loadViewItems 的 io_id => 视图收货省市区(工作平台口径) */ + private array $ioAddr = []; + + /** @var array io_id => 出库时间 io_date(工作平台发货时间口径) */ + private array $ioDate = []; + + /** @var array 最近一次 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;",样本 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 以快递明细 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 = [];