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 68e3f4a7..c04f9e57 100644 --- a/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php +++ b/modules/EcommerceFinance/Services/Jushuitan/ShippingReplicaSynchronizer.php @@ -33,6 +33,8 @@ class ShippingReplicaSynchronizer private const ITEMS_DATASET_PREFIX = 'jst.sctrade.items.'; 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', @@ -43,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; @@ -87,6 +90,29 @@ class ShippingReplicaSynchronizer /** @var array{byL:array,byO:array} 窗口内出库单映射 */ private array $outboundMap = ['byL' => [], 'byO' => []]; + /** @var array 本轮 express/autoship 已处理(含按标签排除)的出库单 io_id */ + private array $doneIo = []; + + /** @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) { @@ -132,6 +158,13 @@ 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); @@ -145,6 +178,8 @@ 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); @@ -168,6 +203,10 @@ class ShippingReplicaSynchronizer } $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); @@ -341,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; } @@ -357,18 +399,48 @@ class ShippingReplicaSynchronizer if ($pkgId === 0) { continue; } - if ($this->excludedByLabel($r['order_labels'] ?? null)) { - 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( + $sw !== null ? $sw['io'] : $this->ioFor($ioMap, (string) ($r['logistic_id'] ?? ''), (string) ($r['inner_order_id'] ?? '')), + (string) ($r['logistic_id'] ?? ''), + (string) ($r['jo_lid'] ?? ''), + $sw !== null ? $sw['old'] : '' + ); + $this->markTrackSo((string) ($r['so_id'] ?? ''), (string) ($r['logistic_id'] ?? ''), (string) ($r['jo_lid'] ?? '')); + if ($this->excludedByLabel($r['order_labels'] ?? null) || $this->excludedByLabel($r['jo_labels'] ?? null)) { + 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); } @@ -409,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 === '') { @@ -418,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) { @@ -439,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; } @@ -549,6 +626,11 @@ class ShippingReplicaSynchronizer if ($oid === '') { continue; } + $tracking = $this->tracking((string) ($r['l_id'] ?? ''), '', $freight, (string) ($r['so_id'] ?? '')); + // Shared tracking can represent a real combined shipment across orders. + // Keep the upstream identifier; sharing alone does not prove a virtual order. + $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; // 线上发货:对齐排除 } @@ -557,9 +639,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); } @@ -579,6 +662,324 @@ 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 + { + if ($io !== '') { + $this->doneIo[$io] = true; + } + foreach ($tracks as $t) { + $t = ltrim(trim($t), '@'); + if ($t !== '') { + $this->doneTrack[$t] = true; + } + } + } + + /** + * 兜底:出库单(按 io_date 窗口)里 express/autoship 都没处理过的包裹,按发货数据视图补吐。 + * 典型:工厂代发仓/自有仓出库单无订单行、或同订单其它包裹在发货明细而本包裹不在。 + * 防重复按 io_id + 快递单号(去@),不按订单号。 + */ + private function syncOutbound(string $fromS, string $toS, $now, ?callable $progress = null): int + { + $mm = $this->selectWithReconnect('SELECT MIN(Id) AS lo, MAX(Id) AS hi FROM ' . self::OUTBOUND . ' WHERE io_date >= ? AND io_date < ?', [$fromS, $toS]); + $lo = (int) ($mm[0]['lo'] ?? 0); + $hi = (int) ($mm[0]['hi'] ?? 0); + if ($lo <= 0 || $hi < $lo) { + if ($progress) { + $progress('出库单兜底:窗口内无出库单'); + } + + return 0; + } + $step = max(5000, (int) ceil(($hi - $lo + 1) / 400)); + $cols = 'io_id, o_id, so_id, l_id, io_date, pay_date, shop_name, labels, status, logistics_company, weight, ' + . 'buyer_message, receiver_state, receiver_city, receiver_district, receiver_phone'; + + $total = 0; + $scanned = 0; + $emitted = 0; + $skip = ['done' => 0, 'notrack' => 0, 'status' => 0, 'label' => 0, 'noitem' => 0]; + $seen = []; + $page = 0; + for ($a = $lo; $a <= $hi; $a += $step) { + $page++; + $rows = $this->selectWithReconnect( + "SELECT {$cols} FROM " . self::OUTBOUND . ' WHERE Id >= ? AND Id < ? AND io_date >= ? AND io_date < ?', + [$a, $a + $step, $fromS, $toS] + ); + $scanned += count($rows); + $pick = []; + foreach ($rows as $r) { + $io = (string) ($r['io_id'] ?? ''); + if ($io === '' || isset($seen[$io])) { + continue; + } + $seen[$io] = true; + $lid = trim((string) ($r['l_id'] ?? '')); + if ($lid === '') { + $skip['notrack']++; + continue; + } + 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']++; + continue; + } + if ($this->excludedByLabel($r['labels'] ?? null)) { + $skip['label']++; + continue; + } + $pick[$io] = $r; + } + if ($pick !== []) { + $ioIds = array_map('strval', array_keys($pick)); + $viewItems = $this->loadViewItems($ioIds); + $heads = $this->loadViewHeads($ioIds); + $soIds = []; + foreach ($pick as $pio => $r) { + $so = $this->outboundSo($r, $heads[(string) $pio] ?? []); + if ($so !== '') { + $soIds[$so] = true; + } + } + $skus = $this->loadSkus($this->collectSkuIds($viewItems)); + $freight = $this->skipFreight ? [] : $this->loadFreight(array_map('strval', array_keys($soIds))); + foreach ($pick as $io => $r) { + $io = (string) $io; + if (empty($viewItems[$io])) { + $skip['noitem']++; + continue; + } + $h = $heads[$io] ?? []; + $so = $this->outboundSo($r, $h); + $tracking = trim((string) $r['l_id']); + if (! empty($r['_trk_done'])) { + $t0 = ltrim($tracking, '@'); + if ($so === '' || empty($this->doneTrackSo[$t0]) || isset($this->doneTrackSo[$t0][$so])) { + $skip['done']++; + continue; + } + } + $pkg = $this->pkgFromOutbound($r, $tracking, $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); + } + if ($progress && $page % 40 === 0) { + $progress(sprintf('出库单兜底:已扫描 %d 行,补吐 %d 包裹', $scanned, $emitted)); + } + } + $this->flushPending(); + if ($progress) { + $progress(sprintf( + '出库单兜底完成:扫描 %d 行,补吐 %d 包裹 / %d 行;跳过 已处理%d 无单号%d 取消删除%d 标签%d 无商品%d', + $scanned, $emitted, $total, $skip['done'], $skip['notrack'], $skip['status'], $skip['label'], $skip['noitem'] + )); + } + + return $total; + } + + /** + * 采购退货出库(退回供应商):工作平台的发货数据视图收录了其中走物流的那部分,按视图补吐。 + * 无交易单号/店铺/平台;仓库取视图(发货仓),收货信息取采购退货单。 + */ + private function syncPurchaseOut(string $fromS, string $toS, $now, ?callable $progress = null): int + { + $rows = $this->selectWithReconnect( + 'SELECT io_id, io_date, status, l_id, so_id, logistics_company, receiver_state, receiver_city, receiver_district, ' + . 'receiver_address, receiver_name, receiver_mobile FROM ' . self::PURCHASE_OUT . ' WHERE io_date >= ? AND io_date < ?', + [$fromS, $toS] + ); + $pick = []; + $skip = ['done' => 0, 'cancel' => 0, 'noitem' => 0]; + foreach ($rows as $r) { + $io = (string) ($r['io_id'] ?? ''); + if ($io === '' || isset($pick[$io])) { + continue; + } + $lid = trim((string) ($r['l_id'] ?? '')); + if (isset($this->doneIo[$io]) || ($lid !== '' && isset($this->doneTrack[ltrim($lid, '@')]))) { + $skip['done']++; + continue; + } + if (stripos((string) ($r['status'] ?? ''), 'cancel') !== false) { + $skip['cancel']++; + continue; + } + $pick[$io] = $r; + } + $total = 0; + $emitted = 0; + if ($pick !== []) { + $ioIds = array_map('strval', array_keys($pick)); + $viewItems = $this->loadViewItems($ioIds); + // 采购退货:每个条码数量取采购退货明细(jst_purchaseoutItem),体积=单件长宽高×数量(对齐工作平台) + $poQty = []; + foreach (array_chunk($ioIds, 800) as $ck) { + $ph = implode(',', array_fill(0, count($ck), '?')); + foreach ($this->selectWithReconnect("SELECT io_id, sku_id, qty FROM rpt.jst_purchaseoutItem WHERE io_id IN ({$ph})", $ck) as $pr) { + $pk = mb_strtolower(trim((string) ($pr['sku_id'] ?? ''))); + $poQty[(string) $pr['io_id']][$pk] = ($poQty[(string) $pr['io_id']][$pk] ?? 0) + (float) ($pr['qty'] ?? 0); + } + } + foreach ($viewItems as $vio => $vitems) { + foreach ($vitems as $vi => $it) { + $pq = $poQty[(string) $vio][mb_strtolower((string) $it['sku'])] ?? null; + if ($pq === null || $pq <= 0) { + continue; + } + $pq = (int) round($pq); + $viewItems[$vio][$vi]['qty'] = $pq; + } + } + $skus = $this->loadSkus($this->collectSkuIds($viewItems)); + foreach ($pick as $io => $r) { + $io = (string) $io; + if (empty($viewItems[$io])) { + $skip['noitem']++; + continue; + } + $lid = trim((string) ($r['l_id'] ?? '')); + $tracking = $lid !== '' ? $lid : null; + $so = trim((string) ($r['so_id'] ?? '')); + $pkg = [ + 'tracking' => $tracking, + 'so_id' => $so !== '' ? $so : null, + 'express_name1' => $r['logistics_company'] ?? null, + 'ship_time' => $this->ts($r['io_date'] ?? null), + 'pay_time' => null, + 'shop_name' => null, + 'platform' => $this->ioSite[$io] ?? null, + 'dis_co_name' => null, + 'warehouse_name' => null, + 'r_state' => $r['receiver_state'] ?? null, + 'r_city' => $r['receiver_city'] ?? null, + 'r_district' => $r['receiver_district'] ?? null, + 'r_address' => (string) ($r['receiver_address'] ?? ''), + 'r_name' => $r['receiver_name'] ?? null, + 'r_mobile' => $r['receiver_mobile'] ?? null, + 'r_phone' => null, + 'buyer_memo' => null, + 'weight' => null, + ]; + $pkg = $this->withViewAddr($io, $pkg); + $total += $this->emitPackage(self::PURCHASE_OUT_OFFSET + (int) $io, $pkg, $viewItems[$io], $skus, [], $so, $tracking, $now, true); + $this->markDone($io, (string) $tracking); + $emitted++; + } + } + $this->flushPending(); + if ($progress) { + $progress(sprintf( + '采购退货出库完成:窗口 %d 单,补吐 %d 包裹 / %d 行;跳过 已处理%d 已取消%d 视图无商品%d', + count($rows), $emitted, $total, $skip['done'], $skip['cancel'], $skip['noitem'] + )); + } + + return $total; + } + + /** io_id => 视图表头字段(平台/交易单号/省市区/店铺/快递),出库单表头缺失时兜底用 */ + private function loadViewHeads(array $ioIds): array + { + $out = []; + foreach (array_chunk(array_values(array_unique(array_filter($ioIds))), 800) as $chunk) { + if ($chunk === []) { + continue; + } + $ph = implode(',', array_fill(0, count($chunk), '?')); + $sql = 'SELECT io_id, MAX(shop_site) AS site, MAX(so_id) AS so, MAX(receiver_state) AS st, MAX(receiver_city) AS ci, ' + . 'MAX(receiver_district) AS di, MAX(shop_name) AS sn, MAX(expressName) AS ex FROM ' . self::VIEW + . " WHERE io_id IN ({$ph}) GROUP BY io_id"; + foreach ($this->selectWithReconnect($sql, $chunk) as $r) { + $out[(string) $r['io_id']] = $r; + } + } + + return $out; + } + + /** 出库单表头 → 包裹级字段 */ + private function outboundSo(array $r, array $h): string + { + $so = trim((string) ($r['so_id'] ?? '')); + + return $so !== '' ? $so : trim((string) ($h['so'] ?? '')); + } + + private function pkgFromOutbound(array $r, ?string $tracking, ?string $shipTime, array $h, string $so): array + { + $pick = fn (string $a, string $b) => (($r[$a] ?? null) !== null && trim((string) $r[$a]) !== '') ? $r[$a] : ($h[$b] ?? null); + + return [ + 'tracking' => $tracking, + 'so_id' => $so !== '' ? $so : null, + 'express_name1' => $pick('logistics_company', 'ex'), + 'ship_time' => $shipTime, + 'pay_time' => $this->ts($r['pay_date'] ?? null), + 'shop_name' => $pick('shop_name', 'sn'), + 'platform' => $h['site'] ?? null, + 'dis_co_name' => null, + 'warehouse_name' => null, + 'r_state' => $pick('receiver_state', 'st'), + 'r_city' => $pick('receiver_city', 'ci'), + 'r_district' => $pick('receiver_district', 'di'), + 'r_address' => '', + 'r_name' => null, + 'r_mobile' => null, + 'r_phone' => $r['receiver_phone'] ?? null, + 'buyer_memo' => $r['buyer_message'] ?? null, + 'weight' => $r['weight'] ?? null, + ]; + } + /** * 输出一个包裹的所有明细行(每件一行;无商品则一行空商品)。 * @return int 组装行数 @@ -587,6 +988,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) { @@ -621,7 +1026,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); @@ -641,7 +1088,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), @@ -652,7 +1099,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'], @@ -665,7 +1112,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, @@ -881,22 +1333,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 @@ -907,38 +1375,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]; } @@ -949,19 +1475,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; @@ -973,6 +1521,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; @@ -1043,9 +1600,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($this->skipFreight ? [] : $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 = []; @@ -1267,14 +2003,27 @@ class ShippingReplicaSynchronizer return str_contains($name, '补收差价') || str_contains($name, '差价专用'); } - /** 线上发货(平台代发/云仓)标签:工作平台不拉取,caiwu 对齐排除。 */ + /** + * 线上发货标签排除口径(对齐工作平台): + * 工作平台只排除"刷单/批发/纯售后"这类无实物的线上发货虚拟单; + * 云仓/京仓(CLPS/ECLP)真实发货工作平台是收进的,不能排除。 + * 实测(9.1-9.16):该口径误排 2、误收 0。 + */ private function excludedByLabel(?string $labels): bool { if ($labels === null || $labels === '') { return false; } + if (! str_contains($labels, '线上发货')) { + return false; + } + // 云仓/京仓真实发货:工作平台收进,保留 + if (str_contains($labels, '云仓订单') || str_contains($labels, '京仓订单') + || str_contains($labels, 'CLPS') || str_contains($labels, 'ECLP')) { + return false; + } - return str_contains($labels, '线上发货'); + return true; } /** @return array sku_id => 尺寸/重量/供应商 */ diff --git a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php index 33d34337..ab2b853f 100644 --- a/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php +++ b/tests/Unit/EcommerceFinance/ShippingReplicaSynchronizerTest.php @@ -1093,6 +1093,87 @@ class ShippingReplicaSynchronizerTest extends TestCase ); } + public function test_outbound_fallback_flushes_replacements_and_preserves_freight_when_skipped(): void + { + $db = DB::connection('business_mirror'); + $db->table('jst_oms_shipping')->insert([ + 'source_id' => 2000000420, 'sku_source_id' => 2000000420, + 'express_no' => 'outbound-420', 'trade_no' => 'trade-420', + 'ship_time' => '2026-09-02 12:00:00', 'freight' => 18.50, 'settle_weight_kg' => 7, + ]); + $upstream = $this->createMock(UpstreamReadOnlyQuery::class); + $upstream->method('select')->willReturnCallback(function (string $sql): array { + if (str_contains($sql, 'MIN(Id) AS lo')) { + return [['lo' => 1, 'hi' => 1]]; + } + if (str_contains($sql, 'WHERE Id >= ?')) { + return [['io_id' => '420', 'so_id' => 'trade-420', 'l_id' => 'outbound-420', 'io_date' => '2026-09-02 12:00:00']]; + } + if (str_contains($sql, 'SELECT io_id, sku_ids')) { + return [['io_id' => '420', 'sku_ids' => 'sku-420', 'qty' => 2, 'type' => '0', 'volume' => 100, 'l' => 10, 'w' => 5, 'h' => 2]]; + } + $this->assertStringNotContainsString('fys_jstFreightCost', $sql); + return []; + }); + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->method('forSource')->willReturn($upstream); + $result = (new ShippingReplicaSynchronizer($factory))->sync( + Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true, + ); + $this->assertSame(1, $result['outbound']); + $row = $db->table('jst_oms_shipping')->where('source_id', 2000000420)->sole(); + $this->assertSame('sku-420', $row->barcode); + $this->assertEquals(2, $row->qty); + $this->assertEquals(18.50, $row->freight); + $this->assertEquals(7, $row->settle_weight_kg); + } + + public function test_outbound_mapping_keeps_shared_tracking_orders_distinct_and_ignores_cancelled(): void + { + $upstream = $this->createMock(UpstreamReadOnlyQuery::class); + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->method('forSource')->willReturn($upstream); + $sync = new ShippingReplicaSynchronizer($factory); + $map = (new \ReflectionMethod($sync, 'mapOutboundRows'))->invoke($sync, [ + ['io_id' => '10', 'o_id' => 'A', 'l_id' => '@shared', 'io_date' => '2026-09-02'], + ['io_id' => '20', 'o_id' => 'B', 'l_id' => 'shared', 'io_date' => '2026-09-03'], + ['io_id' => '30', 'o_id' => 'A', 'l_id' => 'shared', 'io_date' => '2026-09-04', 'status' => 'Cancelled'], + ]); + $method = new \ReflectionMethod($sync, 'ioFor'); + $this->assertSame('10', $method->invoke($sync, $map, 'shared', 'A')); + $this->assertSame('20', $method->invoke($sync, $map, '@shared', 'B')); + $this->assertArrayNotHasKey(30, $map['ioL']); + } + + public function test_tracking_replacement_requires_one_unique_pair(): void + { + $upstream = $this->createMock(UpstreamReadOnlyQuery::class); + $upstream->method('select')->willReturn([]); + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->method('forSource')->willReturn($upstream); + $sync = new ShippingReplicaSynchronizer($factory); + $method = new \ReflectionMethod($sync, 'pairChangedTracking'); + $rows = [['id' => 1, 'inner_order_id' => 'A', 'logistic_id' => 'old']]; + $this->assertSame([], $method->invoke($sync, $rows, ['byL' => [], 'byOAll' => ['A' => ['10' => 'new-a', '20' => 'new-b']]])); + $this->assertSame(['1' => ['io' => '10', 'l_id' => 'new-a', 'old' => 'old']], + $method->invoke($sync, $rows, ['byL' => [], 'byOAll' => ['A' => ['10' => 'new-a']]])); + } + + public function test_cloud_order_label_is_included_and_volume_rules_are_bounded(): void + { + $factory = $this->createMock(UpstreamReadOnlyQueryFactory::class); + $factory->method('forSource')->willReturn($this->createMock(UpstreamReadOnlyQuery::class)); + $sync = new ShippingReplicaSynchronizer($factory); + $label = new \ReflectionMethod($sync, 'excludedByLabel'); + $this->assertTrue($label->invoke($sync, '线上发货')); + $this->assertFalse($label->invoke($sync, '线上发货,云仓订单')); + $volume = new \ReflectionMethod($sync, 'detailVolume'); + $this->assertSame(120.0, $volume->invoke($sync, 10, 3, 2, 2.0, '普通仓', '中通速递')); + $this->assertSame(60.0, $volume->invoke($sync, 10, 3, 2, 2.0, '常州简尚家居有限公司', '自送')); + $this->assertNull($volume->invoke($sync, 0, 3, 2, 2.0, '普通仓', '中通速递')); + $this->assertSame(0.0, $volume->invoke($sync, 10, 3, 2, 2.0, '普通仓', '中通礼品单')); + } + private function setScanBatchLimit(ShippingReplicaSynchronizer $synchronizer, string $property, int $limit): void { $reflection = new \ReflectionProperty($synchronizer, $property);