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'))); $onlyTypes = array_values(array_filter( array_map(static fn ($type): string => trim((string) $type), (array) $this->option('type')), static fn (string $type): bool => in_array($type, ['退回费', '拦截费'], true), )); $limit = max((int) $this->option('limit'), 0); $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; } if (! $schema->hasTable('js_jst_work_order_info')) { $this->error('查件工单镜像 js_jst_work_order_info 不存在,请先完成镜像同步。'); return self::FAILURE; } $rules = $this->rules($onlyRules, $onlyTypes); if ($rules === []) { $this->warn('没有启用且可自动计算的退回费/拦截费规则。'); return self::SUCCESS; } $orders = $this->pullDeductOrders($connection, $from, $to, $limit); $this->info(sprintf('取数 %s ~ %s,扣单工单 %d 单。', $from->toDateString(), $to->toDateString(), count($orders))); if ($orders === []) { return self::SUCCESS; } $ordersByCourier = []; foreach ($orders as $order) { $ordersByCourier[$order['courier']][] = $order; } $totalWritten = 0; foreach ($rules as $rule) { $matched = $ordersByCourier[$rule['courier']] ?? []; if ($matched === []) { $this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . ':没有匹配的扣单工单,跳过。'); continue; } $written = $this->writeSettlementRows($connection, $rule, $matched); $totalWritten += $written; $this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . '(' . $rule['type'] . '/' . $rule['mode'] . '):扣单 ' . count($matched) . ' 单,写入 ' . $written . ' 行。'); } $this->info('完成,共写入 ' . $totalWritten . ' 行附加费结算数据。'); return self::SUCCESS; } /** * @param list $onlyRules * @param list $onlyTypes * @return list */ private function rules(array $onlyRules, array $onlyTypes): array { $rules = []; foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) { $types = json_decode((string) $rule->types, true) ?: []; $candidateTypes = array_values(array_intersect(['退回费', '拦截费'], $types)); if ($candidateTypes === []) { continue; } if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) { continue; } $condition = trim((string) $rule->condition); if (! str_contains($condition, '扣单')) { $this->warn('规则 #' . $rule->id . ' 的生效条件不含「扣单」,已跳过:' . $condition); continue; } $mode = trim((string) $rule->mode); $standard = trim((string) $rule->standard); foreach ($candidateTypes as $type) { if ($onlyTypes !== [] && ! in_array($type, $onlyTypes, true)) { continue; } if ($type === '退回费') { if ($mode === '固定单价') { $value = $this->parseFixedAmount($standard); } elseif ($mode === '按重量') { $value = $this->parseWeightRate($standard); } else { $this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的退回费模式「' . $mode . '」暂不支持自动计算,已跳过。'); continue; } } else { if ($mode === '固定单价') { $value = $this->parseFixedAmount($standard); } else { $this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的拦截费模式「' . $mode . '」暂不支持自动计算,已跳过。'); continue; } } if ($value === null) { $this->warn('规则 #' . $rule->id . ' 的' . $type . '计价标准解析失败,已跳过:' . $standard); continue; } $rules[] = [ 'id' => (int) $rule->id, 'type' => $type, 'courier' => trim((string) $rule->courier), 'mode' => $mode, 'value' => $value, 'effective_at' => $rule->effective_at !== null && $rule->effective_at !== '' ? (string) $rule->effective_at : null, ]; } } return $rules; } private function parseFixedAmount(string $standard): ?float { return preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $matched) === 1 ? (float) $matched[1] : null; } private function parseWeightRate(string $standard): ?float { return preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/\s*(?:kg|KG|公斤|千克)/u', $standard, $matched) === 1 ? (float) $matched[1] : null; } /** * 从查件工单镜像按创建时间拉取扣单工单。 * * @return list> */ private function pullDeductOrders(string $connection, Carbon $from, Carbon $to, int $limit): array { $orders = []; $lastId = 0; $batchSize = 500; while (true) { $rows = DB::connection($connection)->table('js_jst_work_order_info') ->where('title', '查件') ->where('businessFieldList', 'like', '%"value":"扣单/%') ->where('created', '>=', $from->toDateTimeString()) ->where('created', '<', $to->toDateTimeString()) ->where('Id', '>', $lastId) ->orderBy('Id') ->limit($batchSize) ->get(['Id', 'woNo', 'created', 'businessFieldList']); if ($rows->isEmpty()) { break; } foreach ($rows as $row) { $lastId = max($lastId, (int) $row->Id); $order = $this->parseWorkOrder((string) $row->businessFieldList, (string) $row->woNo, (string) $row->created); if ($order !== null) { $orders[] = $order; } if ($limit > 0 && count($orders) >= $limit) { break 2; } } if ($rows->count() < $batchSize) { break; } } return $orders; } /** * @return array|null */ private function parseWorkOrder(string $json, string $woNo, string $created): ?array { $fields = json_decode($json, true); if (! is_array($fields)) { return null; } $flat = []; foreach ($fields as $field) { if (is_array($field) && isset($field['fieldId'])) { $flat[(string) $field['fieldId']] = $field; } } $waybill = trim((string) ($flat['lIdField']['value'] ?? '')); $oId = (int) ($flat['o_id']['value'] ?? 0); $courier = trim((string) ($flat['logisticsField']['value'] ?? '')); $cascade = trim((string) ($flat[self::CHECK_TYPE_FIELD]['value'] ?? '')); if ($waybill === '' || $courier === '' || ! str_starts_with($cascade, '扣单')) { return null; } [$inspectionType, $inspectionReason] = array_pad(explode('/', $cascade, 2), 2, ''); $items = []; foreach (($flat['itemInfo']['childFieldList'] ?? []) as $group) { if (! is_array($group)) { continue; } $item = []; foreach ($group as $field) { if (is_array($field) && isset($field['fieldId'])) { $item[(string) $field['fieldId']] = $field['value'] ?? null; } } $skuId = strtoupper(trim((string) ($item['skuId'] ?? ''))); if ($skuId === '') { continue; } $items[] = [ 'sku_id' => $skuId, 'qty' => (float) ($item['qty'] ?? 0), 'platform_product_id' => mb_substr(trim((string) ($item['platformItemId'] ?? '')), 0, 191), 'i_id' => trim((string) ($item['iid'] ?? '')), 'name' => mb_substr(trim((string) ($item['name'] ?? '')), 0, 191), ]; } $createdAt = null; try { $createdAt = Carbon::parse($created); } catch (Throwable) { // created 无法解析时仍保留工单,仅规则生效时间过滤会放宽。 } return [ 'wo_no' => $woNo, 'waybill' => $waybill, 'o_id' => $oId, 'courier' => $courier, 'online_order_no' => mb_substr(trim((string) ($flat['orderField']['value'] ?? '')), 0, 191), 'shop_name' => mb_substr(trim((string) ($flat['shopField']['valueLabel'] ?? ($flat['shopField']['value'] ?? ''))), 0, 191), 'inspection_type' => mb_substr(trim((string) $inspectionType), 0, 60), 'inspection_reason' => mb_substr(trim((string) $inspectionReason), 0, 120), 'send_time' => trim((string) ($flat['orderSendTime']['value'] ?? '')), 'created_at' => $createdAt, 'items' => $items, ]; } /** * @param array{id:int,courier:string,mode:string,value:float,effective_at:?string} $rule * @param list> $orders */ private function writeSettlementRows(string $connection, array $rule, array $orders): int { $effectiveAt = null; if (($rule['effective_at'] ?? null) !== null) { try { $effectiveAt = Carbon::parse((string) $rule['effective_at'])->startOfDay(); } catch (Throwable) { // 规则生效时间无法解析时不做下界过滤。 } } if ($effectiveAt !== null) { $orders = array_values(array_filter( $orders, static fn (array $order): bool => ! $order['created_at'] instanceof Carbon || $order['created_at']->greaterThanOrEqualTo($effectiveAt), )); } if ($orders === []) { return 0; } $oIds = []; $waybills = []; $skuIds = []; foreach ($orders as $order) { if ((int) ($order['o_id'] ?? 0) > 0) { $oIds[(int) $order['o_id']] = true; } $waybills[(string) $order['waybill']] = true; foreach ($order['items'] as $item) { $skuIds[(string) $item['sku_id']] = true; } } $type = (string) $rule['type']; $returns = $type === '退回费' ? $this->pullReturns($orders) : []; $weights = $this->pullWeights($connection, array_keys($skuIds)); $now = now(); $rows = []; $hasReturnByWaybill = []; foreach ($orders as $order) { $matched = []; $receiveDates = []; $returnWaybills = []; $hasReturn = false; $settlementWeight = 0.0; $orderWeight = 0.0; foreach ($order['items'] as $item) { $weight = $weights[(string) $item['sku_id']] ?? null; if ($weight !== null) { $orderWeight += (float) $item['qty'] * $weight; } } if ($type === '拦截费') { // 拦截费口径:查件扣单即按固定单价结算,不要求退货进仓。 $amount = (float) $rule['value']; $status = '已计算'; } else { foreach ($returns[(int) ($order['o_id'] ?? 0)] ?? [] as $return) { $rQty = (float) ($return['r_qty'] ?? 0); $receiveDate = trim((string) ($return['receive_date'] ?? '')); if ($rQty <= 0 || $receiveDate === '') { continue; } $hasReturn = true; $matched[] = $return; $receiveDates[] = $receiveDate; $returnWaybill = trim((string) ($return['l_id'] ?? '')); if ($returnWaybill !== '') { $returnWaybills[$returnWaybill] = true; } } $returnedWeight = 0.0; foreach ($matched as $return) { $weight = $weights[strtoupper(trim((string) ($return['sku_id'] ?? '')))] ?? null; if ($weight !== null) { $returnedWeight += (float) $return['r_qty'] * $weight; } } $settlementWeight = $hasReturn ? ($returnedWeight > 0 ? $returnedWeight : $orderWeight) : $orderWeight; if ($rule['mode'] === '固定单价') { $amount = $hasReturn ? (float) $rule['value'] : 0.0; $status = '已计算'; } elseif (! $hasReturn) { $amount = 0.0; $status = '已计算'; } elseif ($settlementWeight > 0) { $amount = round($settlementWeight * (float) $rule['value'], 2); $status = '已计算'; } else { $amount = 0.0; $status = '待核价'; } } $primary = $this->primaryItem($order['items'], $matched); $waybill = (string) $order['waybill']; // 同一运单可能有多张扣单工单;只保留一行,优先保留有退回记录的那行。 if (($hasReturnByWaybill[$waybill] ?? null) === true && ! $hasReturn) { continue; } $rows[$waybill] = [ 'rule_id' => $rule['id'], 'surcharge_type' => $type, 'courier_name' => $rule['courier'], 'waybill' => $waybill, 'outbound_io_id' => null, 'platform_product_id' => $primary['platform_product_id'] !== '' ? $primary['platform_product_id'] : null, 'product_code' => $primary['sku_id'] !== '' ? $primary['sku_id'] : ($primary['i_id'] !== '' ? $primary['i_id'] : null), 'shop_name' => $order['shop_name'] !== '' ? $order['shop_name'] : null, 'online_order_no' => $order['online_order_no'] !== '' ? $order['online_order_no'] : null, 'length' => null, 'width' => null, 'height' => null, 'volume' => null, 'inspection_type' => $order['inspection_type'], 'inspection_reason' => $order['inspection_reason'], 'return_waybill_no' => $returnWaybills === [] ? null : mb_substr(implode(',', array_keys($returnWaybills)), 0, 191), 'settlement_weight' => $settlementWeight > 0 ? round($settlementWeight, 2) : null, 'settlement_amount' => $amount, 'settlement_status' => $status, 'evidence' => mb_substr($this->evidence($rule, $order, $hasReturn, $matched, $receiveDates, $settlementWeight, $amount), 0, 500), 'shipped_at' => $this->shippedAt($order), 'pulled_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]; $hasReturnByWaybill[$waybill] = $hasReturn; } foreach (array_chunk(array_keys($waybills), 500) as $chunk) { DB::connection($connection)->table('courier_surcharge_settlement_rows') ->where('rule_id', $rule['id']) ->where('surcharge_type', $type) ->whereIn('waybill', $chunk) ->delete(); } foreach (array_chunk(array_values($rows), 500) as $chunk) { DB::connection($connection)->table('courier_surcharge_settlement_rows')->insert($chunk); } return count($rows); } /** * 售后退货退款:进仓时间取 receive_date,实退数量取 r_qty。 * * @param list> $orders * @return array>> */ private function pullReturns(array $orders): array { $map = []; $oIds = []; $oIdByWaybill = []; foreach ($orders as $order) { $oId = (int) ($order['o_id'] ?? 0); $waybill = trim((string) ($order['waybill'] ?? '')); if ($oId > 0) { $oIds[$oId] = true; } if ($oId > 0 && $waybill !== '') { $oIdByWaybill[$waybill] = $oId; } } $upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('operations_replica'); foreach (array_chunk(array_keys($oIds), 200) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); foreach ($this->selectReturns($upstream, 'a.o_id IN (' . $placeholders . ')', $chunk) as $row) { $oId = (int) ($row['o_id'] ?? 0); if ($oId > 0) { $map[$oId][] = $row; } } } // 部分售后单的 o_id 与查件工单不一致,按原始运单号再兜底一次。 $remainingWaybills = []; foreach ($oIdByWaybill as $waybill => $oId) { if (($map[$oId] ?? []) === []) { $remainingWaybills[] = $waybill; } } foreach (array_chunk($remainingWaybills, 200) as $chunk) { $placeholders = implode(',', array_fill(0, count($chunk), '?')); foreach ($this->selectReturns($upstream, 'a.l_id IN (' . $placeholders . ')', $chunk) as $row) { $oId = $oIdByWaybill[trim((string) ($row['l_id'] ?? ''))] ?? 0; if ($oId > 0) { $map[$oId][] = $row; } } } return $map; } /** * @param list $bindings * @return list> */ private function selectReturns(UpstreamReadOnlyQuery $upstream, string $where, array $bindings): array { try { $rows = $upstream->select( 'SELECT a.o_id, a.as_id, a.l_id, a.status, CONVERT(varchar(19), a.confirm_date, 120) AS confirm_date, ' . 'ri.sku_id, ri.qty, ri.r_qty, ' . 'CONVERT(varchar(19), ri.receive_date, 120) AS receive_date ' . 'FROM rpt.jst_returnAndRefundInfo a ' . 'INNER JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = a.as_id ' . 'WHERE ' . $where, $bindings, ); } catch (Throwable) { return []; } return array_map(static fn ($row): array => (array) $row, $rows); } /** * @param list $skuIds * @return array */ private function pullWeights(string $connection, array $skuIds): array { $map = []; foreach (array_chunk($skuIds, 500) as $chunk) { $rows = DB::connection($connection)->table('ops_jst_sku') ->whereIn('sku_id', $chunk) ->get(['sku_id', 'weight']); foreach ($rows as $row) { $skuId = strtoupper(trim((string) $row->sku_id)); $weight = $row->weight; if ($skuId === '' || $weight === null || $weight === '' || isset($map[$skuId])) { continue; } $map[$skuId] = (float) $weight; } } return $map; } /** * @param list> $items * @param list> $returns * @return array{sku_id:string,i_id:string,platform_product_id:string,name:string} */ private function primaryItem(array $items, array $returns): array { $returnSkus = []; foreach ($returns as $return) { $skuId = strtoupper(trim((string) ($return['sku_id'] ?? ''))); if ($skuId !== '') { $returnSkus[$skuId] = true; } } foreach ($items as $item) { if (isset($returnSkus[$item['sku_id']])) { return $item; } } foreach ($items as $item) { if ($item['platform_product_id'] !== '') { return $item; } } return $items[0] ?? ['sku_id' => '', 'i_id' => '', 'platform_product_id' => '', 'name' => '']; } /** * @param array{id:int,type:string,courier:string,mode:string,value:float,effective_at:?string} $rule * @param array $order * @param list> $matched * @param list $receiveDates */ private function evidence( array $rule, array $order, bool $hasReturn, array $matched, array $receiveDates, float $settlementWeight, float $amount, ): string { $parts = ['查件扣单/' . $order['inspection_reason']]; if (($rule['type'] ?? '退回费') === '拦截费') { $parts[] = '拦截费规则 ' . $this->trimNumber((float) $rule['value']) . '元/单'; $parts[] = '结算金额 ' . number_format($amount, 2, '.', ''); return implode(';', $parts); } if ($hasReturn) { $returnQty = 0.0; $confirmDates = []; foreach ($matched as $return) { $returnQty += (float) ($return['r_qty'] ?? 0); $confirmDate = trim((string) ($return['confirm_date'] ?? '')); if ($confirmDate !== '') { $confirmDates[] = $confirmDate; } } if ($confirmDates !== []) { $parts[] = '退款确认 ' . max($confirmDates); } $parts[] = '售后退货退款已进仓 ' . ($receiveDates === [] ? '' : max($receiveDates)); $parts[] = '实退 ' . $this->trimNumber($returnQty); } else { $parts[] = '售后退货退款无进仓/实退记录'; } $parts[] = '商品资料重量合计 ' . $this->trimNumber($settlementWeight) . 'kg'; $parts[] = $rule['mode'] === '固定单价' ? '规则 ' . $this->trimNumber((float) $rule['value']) . '元/单' : '规则 ' . $this->trimNumber((float) $rule['value']) . '元/kg'; $parts[] = '结算金额 ' . number_format($amount, 2, '.', ''); return implode(';', $parts); } /** * @param array $order */ private function shippedAt(array $order): ?string { $sendTime = trim((string) ($order['send_time'] ?? '')); if ($sendTime !== '') { try { return Carbon::parse($sendTime)->toDateTimeString(); } catch (Throwable) { // 发货时间无法解析时回退到查件工单创建时间。 } } return $order['created_at'] instanceof Carbon ? $order['created_at']->toDateTimeString() : null; } private function trimNumber(float $value): string { $text = rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); return $text === '' ? '0' : $text; } }