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 af89f9ad..91915fb3 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 0c1403c9..6b06f01c 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -34,6 +34,7 @@ class ShippingReplicaSynchronizer private const AUTOSHIP_OFFSET = 1000000000; private const DEMO_SOURCE_IDS = [9000001, 9000002, 9000003, 9000004, 9000005, 9000006]; private const OUTBOUND_OFFSET = 2000000000; + private const PURCHASE_OUT_OFFSET = 3000000000; private const FLUSH = 300; private const PRESERVED_PACKAGE_FIELDS = [ 'buyer_nick', 'seller_memo', 'package_no', 'download_time', 'freight_rate', 'freight_formula', @@ -44,6 +45,7 @@ class ShippingReplicaSynchronizer 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; @@ -94,6 +96,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); @@ -139,8 +159,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); $this->preloadOutboundMap($fromS, $toS); @@ -155,6 +179,7 @@ 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->staleMirrorPackages = $this->countUnseenMirrorPackages($fromS, $toS); if (! $skipFreight && $this->staleMirrorPackages === 0) { $removed = $this->dedupAtPrefix($fromS, $toS); @@ -177,7 +202,11 @@ class ShippingReplicaSynchronizer $this->markCovered(self::ITEMS_DATASET_PREFIX, $fromS, $toS); } - $result = ['processed' => $total, 'autoship' => $auto, 'outbound' => $outb]; + $result = ['processed' => $total, 'autoship' => $auto]; + if ($windowed) { + $result['outbound'] = $outb; + $result['purchase_out'] = $pout; + } if ($windowed) { $result['packages'] = $this->packagesProcessed; $result['unique_packages'] = count($this->seenSourceIds); @@ -351,9 +380,12 @@ class ShippingReplicaSynchronizer } // 主口径:发货数据视图按出库单(io_id)取实发商品 $ioMap = $this->ioMapFor($windowed, 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; } @@ -367,23 +399,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); } @@ -424,7 +481,7 @@ class ShippingReplicaSynchronizer continue; } $ph = implode(',', array_fill(0, count($chunk), '?')); - $sql = "SELECT o_id, l_id, send_date FROM rpt.jst_Orders om WHERE om.o_id IN ({$ph})"; + $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 === '') { @@ -433,6 +490,8 @@ class ShippingReplicaSynchronizer $this->orderMetadata[$oid] = [ 'l_id' => $row['l_id'] ?? null, 'send_date' => $row['send_date'] ?? null, + 'status' => $row['status'] ?? null, + 'labels' => $row['labels'] ?? null, ]; } foreach ($chunk as $oid) { @@ -454,6 +513,9 @@ class ShippingReplicaSynchronizer $row['jo_send'] = $metadata['send_date']; } + $row['jo_status'] ??= $metadata['status'] ?? null; + $row['jo_labels'] ??= $metadata['labels'] ?? null; + return $row; } @@ -564,7 +626,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; // 线上发货:对齐排除 } @@ -573,9 +641,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); } @@ -595,6 +664,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 { @@ -616,7 +719,7 @@ class ShippingReplicaSynchronizer */ private function syncOutbound(string $fromS, string $toS, $now, ?callable $progress = null): int { - $mm = $this->up->select('SELECT MIN(Id) AS lo, MAX(Id) AS hi FROM ' . self::OUTBOUND . ' WHERE io_date >= ? AND io_date < ?', [$fromS, $toS]); + $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) { @@ -638,7 +741,7 @@ class ShippingReplicaSynchronizer $page = 0; for ($a = $lo; $a <= $hi; $a += $step) { $page++; - $rows = $this->up->select( + $rows = $this->selectWithReconnect( "SELECT {$cols} FROM " . self::OUTBOUND . ' WHERE Id >= ? AND Id < ? AND io_date >= ? AND io_date < ?', [$a, $a + $step, $fromS, $toS] ); @@ -655,10 +758,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']++; @@ -682,7 +787,7 @@ class ShippingReplicaSynchronizer } } $skus = $this->loadSkus($this->collectSkuIds($viewItems)); - $freight = $this->loadFreight(array_map('strval', array_keys($soIds))); + $freight = $this->skipFreight ? [] : $this->loadFreight(array_map('strval', array_keys($soIds))); foreach ($pick as $io => $r) { $io = (string) $io; if (empty($viewItems[$io])) { @@ -692,9 +797,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); @@ -703,7 +817,7 @@ class ShippingReplicaSynchronizer $progress(sprintf('出库单兜底:已扫描 %d 行,补吐 %d 包裹', $scanned, $emitted)); } } - $this->flush(); + $this->flushPending(); if ($progress) { $progress(sprintf( '出库单兜底完成:扫描 %d 行,补吐 %d 包裹 / %d 行;跳过 已处理%d 无单号%d 取消删除%d 标签%d 无商品%d', @@ -714,6 +828,106 @@ class ShippingReplicaSynchronizer 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 { @@ -726,7 +940,7 @@ class ShippingReplicaSynchronizer $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->up->select($sql, $chunk) as $r) { + foreach ($this->selectWithReconnect($sql, $chunk) as $r) { $out[(string) $r['io_id']] = $r; } } @@ -776,6 +990,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; + } $this->seenSourceIds[$pkgId] = true; $this->packagesProcessed++; if ($n === 0) { @@ -810,7 +1028,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); @@ -830,7 +1090,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), @@ -841,7 +1101,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'], @@ -854,7 +1114,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, @@ -1070,22 +1335,38 @@ class ShippingReplicaSynchronizer /** 窗口同步按 io_date 一次预载,避免对无可用索引的 l_id/o_id 反复执行 IN 查询。 */ private function preloadOutboundMap(string $fromS, string $toS): void { - $this->outboundMap = ['byL' => [], 'byO' => []]; - $sql = 'SELECT l_id, o_id, io_id FROM ' . self::OUTBOUND . ' WHERE io_date >= ? AND io_date < ?'; - foreach ($this->selectWithReconnect($sql, [$fromS, $toS]) as $r) { + $sql = 'SELECT l_id, o_id, io_id, io_date, status FROM ' . self::OUTBOUND . ' WHERE io_date >= ? AND io_date < ?'; + $this->outboundMap = $this->mapOutboundRows($this->selectWithReconnect($sql, [$fromS, $toS])); + } + + private function mapOutboundRows(array $rows): array + { + $map = ['byL' => [], 'byO' => [], 'byOAll' => [], 'ioL' => [], 'byLO' => []]; + $ranks = []; + foreach ($rows as $r) { $io = (string) ($r['io_id'] ?? ''); - if ($io === '') { + $l = ltrim(trim((string) ($r['l_id'] ?? '')), '@'); + $o = (string) ($r['o_id'] ?? ''); + $status = strtolower(trim((string) ($r['status'] ?? ''))); + if ($io === '' || in_array($status, ['delete', 'deleted', 'cancelled', 'cancel', 'canceled'], true)) { continue; } - $l = (string) ($r['l_id'] ?? ''); - if ($l !== '' && ! isset($this->outboundMap['byL'][$l])) { - $this->outboundMap['byL'][$l] = $io; + $rank = (string) ($r['io_date'] ?? '') . '|' . str_pad($io, 20, '0', STR_PAD_LEFT); + $map['ioL'][$io] = $l; + if (!empty($r['io_date'])) { + $this->ioDate[$io] = (string) $r['io_date']; } - $o = (string) ($r['o_id'] ?? ''); - if ($o !== '' && ! isset($this->outboundMap['byO'][$o])) { - $this->outboundMap['byO'][$o] = $io; + if ($o !== '') { + $map['byOAll'][$o][$io] = $l; + } + foreach (['byL' => $l, 'byO' => $o, 'byLO' => ($l !== '' && $o !== '' ? $l . '|' . $o : '')] as $group => $key) { + if ($key !== '' && (!isset($ranks[$group][$key]) || strcmp($rank, $ranks[$group][$key]) > 0)) { + $map[$group][$key] = $io; + $ranks[$group][$key] = $rank; + } } } + return $map; } private function ioMapFor(bool $windowed, array $logisticIds, array $oIds): array @@ -1096,38 +1377,96 @@ class ShippingReplicaSynchronizer /** 快递单号/内部单号 -> 出库单 io_id(优先快递单号,精确到该次出库)。 */ private function loadIoMap(array $logisticIds, array $oIds): array { - $byL = []; - $byO = []; - foreach (array_chunk(array_values(array_unique(array_filter($logisticIds))), 800) as $chunk) { - if ($chunk === []) { + $rows = []; + $logs = []; + foreach ($logisticIds as $value) { + $value = ltrim(trim((string) $value), '@'); + if ($value !== '') { + $logs[$value] = true; + $logs['@' . $value] = true; + } + } + foreach (['l_id' => array_keys($logs), 'o_id' => array_values(array_unique(array_filter($oIds)))] as $column => $values) { + foreach (array_chunk($values, 800) as $chunk) { + $ph = implode(',', array_fill(0, count($chunk), '?')); + array_push($rows, ...$this->selectWithReconnect('SELECT l_id, o_id, io_id, io_date, status FROM ' . self::OUTBOUND . " WHERE {$column} IN ({$ph})", $chunk)); + } + } + return $this->mapOutboundRows($rows); + } + + /** + * 换单配对:快递明细的单号在出库单里找不到时,到同一内部订单下找一张 + * "出库单有单号、但该单号不在任何快递明细里"的出库单,一对一配对(工作平台换单后只保留新单号)。 + * + * @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; } - $ph = implode(',', array_fill(0, count($chunk), '?')); - foreach ($this->selectWithReconnect('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'] ?? ''); + $need[] = $r; + foreach ($ioMap['byOAll'][$oid] as $io => $l) { + if ($l !== '' && ! isset($this->doneIo[(string) $io]) && ! isset($this->doneTrack[$l])) { + $cands[$l] = true; } } } - foreach (array_chunk(array_values(array_unique(array_filter($oIds))), 800) as $chunk) { - if ($chunk === []) { + 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->selectWithReconnect("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 = []; + $needCounts = array_count_values(array_column($need, 'inner_order_id')); + foreach ($need as $r) { + $oid = (string) $r['inner_order_id']; + $ios = array_filter($ioMap['byOAll'][$oid], fn ($l, $io) => + $l !== '' && ! isset($inExpress[$l]) && ! isset($this->doneIo[(string) $io]) + && ! isset($this->doneTrack[$l]), ARRAY_FILTER_USE_BOTH); + // Only a unique old/new pair supports a replacement; never assign by sort order. + if (($needCounts[$oid] ?? 0) !== 1 || count($ios) !== 1) { continue; } - $ph = implode(',', array_fill(0, count($chunk), '?')); - foreach ($this->selectWithReconnect('SELECT o_id, io_id FROM ' . self::OUTBOUND . " WHERE o_id IN ({$ph})", $chunk) as $r) { - $o = (string) ($r['o_id'] ?? ''); - if ($o !== '' && ! isset($byO[$o])) { - $byO[$o] = (string) ($r['io_id'] ?? ''); + 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 ['byL' => $byL, 'byO' => $byO]; + 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]; } @@ -1138,19 +1477,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->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 !== '') { + 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; @@ -1162,6 +1523,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; @@ -1232,9 +1602,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->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 = [];