option('from') !== null && $this->option('from') !== '' ? Carbon::parse((string) $this->option('from'))->startOfDay() : now()->subDays(max((int) $this->option('days'), 1))->startOfDay(); $to = $this->option('to') !== null && $this->option('to') !== '' ? Carbon::parse((string) $this->option('to'))->startOfDay() : now()->addDay()->startOfDay(); $onlyRules = array_filter(array_map('intval', (array) $this->option('rule'))); $chunkSize = min(max((int) $this->option('chunk'), 50), 500); $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); $schema = DB::connection($connection)->getSchemaBuilder(); if (! $schema->hasTable('courier_surcharge_settlement_rows')) { $this->error('结算表 courier_surcharge_settlement_rows 不存在,请先执行迁移。'); return self::FAILURE; } $rules = $this->overlengthRules($onlyRules); if ($rules === []) { $this->warn('没有启用且可自动计算的超长费规则。'); return self::SUCCESS; } $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun'); $totalWritten = 0; foreach ($rules as $rule) { $threshold = $rule['threshold']; $amount = $rule['amount']; $this->info(sprintf( '规则 #%d %s:单边超 %scm 计 %s 元/单,取数 %s ~ %s', $rule['id'], $rule['courier'], $threshold, $amount, $from->toDateString(), $to->toDateString(), )); $orders = $this->pullOrders($upstream, $rule['courier'], $from, $to, $chunkSize); if ($orders === []) { $this->line(' 没有运单号非空的出库单,跳过。'); continue; } $written = $this->writeSettlementRows($connection, $rule, $orders, $threshold, $amount, $from, $to); $totalWritten += $written; $this->line(' 出库单 ' . count($orders) . ' 单,命中超长并写入 ' . $written . ' 行。'); } $this->info('完成,共写入 ' . $totalWritten . ' 行结算数据。'); return self::SUCCESS; } /** * @param list $onlyRules * @return list */ private function overlengthRules(array $onlyRules): array { $rules = []; foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) { $types = json_decode((string) $rule->types, true) ?: []; if (! in_array('超长费', $types, true)) { continue; } if (! str_contains((string) $rule->mode, '按尺寸') && ! str_contains((string) $rule->mode, '最长边')) { continue; } if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) { continue; } $condition = (string) $rule->condition; $standard = (string) $rule->standard; if (preg_match('/单边超\s*(\d+(?:\.\d+)?)\s*(?:cm|厘米|CM|㎝)/u', $condition, $matched) !== 1) { $this->warn('规则 #' . $rule->id . ' 的生效条件解析不出单边阈值,已跳过:' . $condition); continue; } $threshold = (float) $matched[1]; if (preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/?\s*单/u', $standard, $priceMatched) !== 1) { $this->warn('规则 #' . $rule->id . ' 的计价标准解析不出每单金额,已跳过:' . $standard); continue; } $rules[] = [ 'id' => (int) $rule->id, 'courier' => trim((string) $rule->courier), 'threshold' => $threshold, 'amount' => (float) $priceMatched[1], ]; } return $rules; } /** * 拉取出库单(只取已分配运单号的单)。 * * @return list> */ private function pullOrders( UpstreamReadOnlyQuery $upstream, string $courier, Carbon $from, Carbon $to, int $chunkSize, ): array { $orders = []; $lastId = 0; while (true) { $rows = $upstream->select( 'SELECT TOP ' . $chunkSize . ' Id, io_id, o_id, l_id, so_id, shop_name, io_date, weight, f_weight ' . 'FROM dbo.jst_salesOutboundInfo ' . 'WHERE logistics_company = ? AND io_date >= ? AND io_date < ? ' . "AND LEN(ISNULL(l_id, '')) > 0 AND Id > ? " . 'ORDER BY Id', [$courier, $from->toDateTimeString(), $to->toDateTimeString(), $lastId], ); if ($rows === []) { break; } foreach ($rows as $row) { $row = (array) $row; $orders[] = $row; $lastId = max($lastId, (int) $row['Id']); } if (count($rows) < $chunkSize) { break; } } return $orders; } /** * @param list> $orders */ private function writeSettlementRows( string $connection, array $rule, array $orders, float $threshold, float $amount, Carbon $from, Carbon $to, ): int { $ioIds = array_values(array_filter(array_map( static fn (array $order): int => (int) ($order['io_id'] ?? 0), $orders, ), static fn (int $value): bool => $value > 0)); $items = $this->pullItems($ioIds); $dimensions = $this->dimensionMap($items); $platformProductIds = $this->platformProductIds($items, $orders); $onlineOrderNumbers = $this->onlineOrderNumbers($orders); $now = now(); $written = 0; $rows = []; foreach ($orders as $order) { $ioId = (int) ($order['io_id'] ?? 0); if ($ioId <= 0) { continue; } $best = null; foreach ($items[$ioId] ?? [] as $item) { $skuId = trim((string) ($item['sku_id'] ?? '')); $dim = $dimensions[$skuId] ?? null; if ($dim === null) { continue; } $maxSide = max($dim['l'], $dim['w'], $dim['h']); if ($best === null || $maxSide > $best['max_side']) { $best = [ 'max_side' => $maxSide, 'sku_id' => $skuId, 'i_id' => trim((string) ($item['i_id'] ?? '')), 'length' => $dim['l'], 'width' => $dim['w'], 'height' => $dim['h'], 'platform_product_id' => $platformProductIds[$ioId][$skuId] ?? ($platformProductIds[$ioId][''] ?? null), ]; } } if ($best === null || $best['max_side'] <= $threshold) { continue; } $rows[] = [ 'rule_id' => $rule['id'], 'surcharge_type' => '超长费', 'courier_name' => $rule['courier'], 'waybill' => trim((string) ($order['l_id'] ?? '')), 'outbound_io_id' => $ioId, 'platform_product_id' => $best['platform_product_id'], 'product_code' => $best['sku_id'] !== '' ? $best['sku_id'] : ($best['i_id'] !== '' ? $best['i_id'] : null), 'shop_name' => mb_substr(trim((string) ($order['shop_name'] ?? '')), 0, 191) ?: null, 'online_order_no' => $onlineOrderNumbers[(int) ($order['o_id'] ?? 0)] ?? null, 'length' => $best['length'], 'width' => $best['width'], 'height' => $best['height'], 'volume' => round($best['length'] * $best['width'] * $best['height'], 2), 'inspection_type' => null, 'inspection_reason' => null, 'return_waybill_no' => null, 'settlement_weight' => null, 'settlement_amount' => $amount, 'settlement_status' => '已计算', 'evidence' => mb_substr(sprintf( '条形码 %s 尺寸 %s×%s×%s,最长边 %scm > %scm', $best['sku_id'] !== '' ? $best['sku_id'] : '(无)', $this->trimNumber($best['length']), $this->trimNumber($best['width']), $this->trimNumber($best['height']), $this->trimNumber($best['max_side']), $this->trimNumber($threshold), ), 0, 500), 'shipped_at' => $order['io_date'] ?? null, 'pulled_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]; $written++; } $target = DB::connection($connection)->table('courier_surcharge_settlement_rows'); // 同一规则、同一取数窗口内重跑时先清掉旧行,避免残留历史判断。 $target->where('rule_id', $rule['id']) ->where('shipped_at', '>=', $from->toDateTimeString()) ->where('shipped_at', '<', $to->toDateTimeString()) ->delete(); foreach (array_chunk($rows, 500) as $chunk) { $target->insert($chunk); } return $written; } /** * @param list $ioIds * @return array>> */ private function pullItems(array $ioIds): array { if ($ioIds === []) { return []; } $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun'); $items = []; foreach (array_chunk($ioIds, 500) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); $rows = $upstream->select( 'SELECT io_id, sku_id, i_id, name, qty FROM dbo.jst_salesOutboundItem WHERE io_id IN (' . $placeholders . ')', $chunk, ); foreach ($rows as $row) { $row = (array) $row; $items[(int) $row['io_id']][] = $row; } } return $items; } /** * 线上订单号:优先用出库单自带 so_id,为空时按 o_id 到「未发货订单」表兜底。 * * 部分渠道(如阿里巴巴-常州玖阔)的出库单 so_id 为空,但发货中订单表 * jst_nosend_orders 里仍能按 o_id 找到线上单号。 * * @param list> $orders * @return array */ private function onlineOrderNumbers(array $orders): array { $map = []; $missing = []; foreach ($orders as $order) { $oId = (int) ($order['o_id'] ?? 0); $soId = trim((string) ($order['so_id'] ?? '')); if ($soId !== '') { $map[$oId] = mb_substr($soId, 0, 191); } elseif ($oId > 0) { $missing[$oId] = true; } } if ($missing === []) { return $map; } $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun'); foreach (array_chunk(array_keys($missing), 500) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); try { $rows = $upstream->select( "SELECT o_id, so_id FROM dbo.jst_nosend_orders WHERE o_id IN (" . $placeholders . ')' . " AND LEN(ISNULL(so_id, '')) > 0", $chunk, ); } catch (Throwable) { continue; } foreach ($rows as $row) { $row = (array) $row; $map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191); } } // 仍未补到的(多为已发货、已离开未发货订单表的单),到订单视图兜底; // 该视图覆盖部分不在 jst_Orders 主表里的店铺(如阿里巴巴-常州玖阔)。 $remaining = array_values(array_diff(array_keys($missing), array_keys($map))); foreach (array_chunk($remaining, 500) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); try { $rows = $upstream->select( "SELECT o_id, so_id FROM dbo.jst_ordersView WHERE o_id IN (" . $placeholders . ')' . " AND LEN(ISNULL(so_id, '')) > 0", $chunk, ); } catch (Throwable) { continue; } foreach ($rows as $row) { $row = (array) $row; $map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191); } } // 再兜底:发货数据视图在订单离开 jst_Orders/ordersView/nosend 后仍保留 o_id → so_id, // 例如 2026-07-31 新佰广1688 已归档的出库单。 $remaining = array_values(array_diff(array_keys($missing), array_keys($map))); foreach (array_chunk($remaining, 200) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); try { $rows = $upstream->select( 'SELECT DISTINCT o_id, so_id FROM dbo.Jst_SendData_View WHERE o_id IN (' . $placeholders . ')' . ' AND LEN(so_id) > 0', $chunk, ); } catch (Throwable) { continue; } foreach ($rows as $row) { $row = (array) $row; $map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191); } } // 最后兜底:全渠道订单列表按月归档,部分天猫订单只在该表保留 o_id → so_id。 $remaining = array_values(array_diff(array_keys($missing), array_keys($map))); if ($remaining !== []) { $monthById = []; foreach ($orders as $order) { $oId = (int) ($order['o_id'] ?? 0); $ioDate = trim((string) ($order['io_date'] ?? '')); if ($oId <= 0 || $ioDate === '') { continue; } try { $monthById[$oId] = Carbon::parse($ioDate)->format('Ym'); } catch (Throwable) { // io_date 无法解析时只查当前表。 } } $tables = ['jst_qmcust_orderlist' => array_values($remaining)]; foreach ($remaining as $oId) { $month = $monthById[$oId] ?? null; if ($month !== null) { $tables['jst_qmcust_orderlist_' . $month][] = $oId; } } foreach ($tables as $table => $ids) { foreach (array_chunk($ids, 200) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); try { $rows = $upstream->select( 'SELECT o_id, so_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')' . ' AND LEN(so_id) > 0', $chunk, ); } catch (Throwable) { continue; } foreach ($rows as $row) { $row = (array) $row; $map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191); } } } } return $map; } /** * 平台商品ID:查件工单里的 platformItemId 与订单/发货明细的 shop_i_id 同源。 * 优先按 io_id + sku_id 从发货明细视图取;缺行时按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。 * * @param array>> $items * @param list> $orders * @return array> */ private function platformProductIds(array $items, array $orders): array { $map = []; $ioIds = array_keys($items); if ($ioIds === []) { return $map; } $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun'); foreach (array_chunk($ioIds, 200) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); try { $rows = $upstream->select( 'SELECT io_id, sku_id, shop_i_id FROM dbo.Jst_SendDataDetail_View WHERE io_id IN (' . $placeholders . ')' . ' AND LEN(shop_i_id) > 0', $chunk, ); } catch (Throwable) { continue; } foreach ($rows as $row) { $row = (array) $row; $ioId = (int) ($row['io_id'] ?? 0); $skuId = trim((string) ($row['sku_id'] ?? '')); $shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191); if ($ioId <= 0 || $shopIId === '') { continue; } if ($skuId !== '') { $map[$ioId][$skuId] = $shopIId; } $map[$ioId][''] ??= $shopIId; } } // 剩余按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。 $oIdByIoId = []; $monthByIoId = []; foreach ($orders as $order) { $ioId = (int) ($order['io_id'] ?? 0); $oId = (int) ($order['o_id'] ?? 0); $ioDate = trim((string) ($order['io_date'] ?? '')); if ($ioId <= 0 || $oId <= 0) { continue; } $oIdByIoId[$ioId] = $oId; if ($ioDate === '') { continue; } try { $monthByIoId[$ioId] = Carbon::parse($ioDate)->format('Ym'); } catch (Throwable) { // io_date 无法解析时只查当前表。 } } $missing = []; foreach ($ioIds as $ioId) { if (($map[$ioId] ?? []) !== []) { continue; } $oId = $oIdByIoId[$ioId] ?? 0; if ($oId > 0) { $missing[$ioId] = $oId; } } if ($missing === []) { return $map; } $tables = ['jst_qmcust_orderlist_item' => $missing]; foreach ($missing as $ioId => $oId) { $month = $monthByIoId[$ioId] ?? null; if ($month !== null) { $tables['jst_qmcust_orderlist_item_' . $month][$ioId] = $oId; } } foreach ($tables as $table => $idsByIo) { $oIds = array_values(array_unique(array_values($idsByIo))); foreach (array_chunk($oIds, 200) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); try { $rows = $upstream->select( 'SELECT o_id, sku_id, shop_i_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')' . ' AND LEN(shop_i_id) > 0', $chunk, ); } catch (Throwable) { continue; } $byOrder = []; foreach ($rows as $row) { $row = (array) $row; $skuId = trim((string) ($row['sku_id'] ?? '')); $shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191); if ($shopIId === '') { continue; } $byOrder[(int) ($row['o_id'] ?? 0)][] = ['sku_id' => $skuId, 'shop_i_id' => $shopIId]; } foreach ($idsByIo as $ioId => $oId) { foreach ($byOrder[$oId] ?? [] as $entry) { if ($entry['sku_id'] !== '') { $map[$ioId][$entry['sku_id']] = $entry['shop_i_id']; } $map[$ioId][''] ??= $entry['shop_i_id']; } } } } return $map; } /** * @param array>> $items * @return array */ private function dimensionMap(array $items): array { $skuIds = []; foreach ($items as $rows) { foreach ($rows as $row) { $skuId = trim((string) ($row['sku_id'] ?? '')); if ($skuId !== '') { $skuIds[$skuId] = true; } } } $skuIds = array_keys($skuIds); $dimensions = []; // 商品及库存管理(商品资料)已在共享镜像里,直接读镜像,不再压上游。 foreach (array_chunk($skuIds, 500) as $chunk) { try { $rows = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')) ->table('ops_jst_sku') ->whereIn('sku_id', $chunk) ->get(['sku_id', 'l', 'w', 'h']); } catch (Throwable) { continue; } foreach ($rows as $row) { $l = (float) $row->l; $w = (float) $row->w; $h = (float) $row->h; if ($l > 0 && $w > 0 && $h > 0) { $dimensions[trim((string) $row->sku_id)] = ['l' => $l, 'w' => $w, 'h' => $h]; } } } return $dimensions; } private function trimNumber(float $value): string { return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); } }