100.00) { return 100.00; } return $val; } /** * 强校验百分比输入(0 ~ 100) * * @param mixed $raw * @return float * @throws \InvalidArgumentException */ public static function validateRateInput($raw): float { $str = trim((string)$raw); if ($str === '' || !is_numeric($str)) { throw new \InvalidArgumentException('请填写有效的抽佣比例数字(0 ~ 100)'); } $val = (float)$str; if ($val < 0 || $val > 100) { throw new \InvalidArgumentException('抽佣比例必须在 0% ~ 100% 之间'); } return round($val, 2); } /** * 读取全局默认兜底抽佣比例(%) */ public static function getGlobalRate(): float { $defaults = FinanceConfigService::getCommissionDefaults(); return self::normalizeRate($defaults['commission_rate'] ?? self::DEFAULT_RATE); } /** * 计算单条规则的条件精确度得分(相同权重 weigh 时,条件越具体越优先命中) */ protected static function calcSpecificity(array $rule): int { $score = 0; if ((int)($rule['cat_id'] ?? 0) > 0) { $score += 16; } if (($rule['customer_type'] ?? 'all') !== 'all') { $score += 8; } if (($rule['order_mode'] ?? 'all') !== 'all') { $score += 4; } if (($rule['urgent_mode'] ?? 'all') !== 'all') { $score += 2; } if ((float)($rule['min_amount'] ?? 0) > 0 || (float)($rule['max_amount'] ?? 0) > 0) { $score += 1; } return $score; } /** * 获取当前全部启用中的订单抽佣规则(按 weigh DESC -> 精确度 DESC -> id DESC 排序) */ public static function getActiveRules(bool $forceRefresh = false): array { if (self::$activeRulesCache === null || $forceRefresh) { $rows = []; try { $rows = Db::name('commission_rule') ->where('status', 'normal') ->order('weigh desc, id desc') ->select(); if (!is_array($rows)) { $rows = []; } } catch (\Throwable $e) { $rows = []; } foreach ($rows as &$r) { $r['id'] = (int)$r['id']; $r['cat_id'] = (int)($r['cat_id'] ?? 0); $r['weigh'] = (int)($r['weigh'] ?? 0); $r['min_amount'] = round(max(0.00, (float)($r['min_amount'] ?? 0)), 2); $r['max_amount'] = round(max(0.00, (float)($r['max_amount'] ?? 0)), 2); $r['commission_rate'] = self::normalizeRate($r['commission_rate'] ?? 0); $r['fixed_fee'] = round(max(0.00, (float)($r['fixed_fee'] ?? 0)), 2); $r['min_commission'] = round(max(0.00, (float)($r['min_commission'] ?? 0)), 2); $r['max_commission'] = round(max(0.00, (float)($r['max_commission'] ?? 0)), 2); $r['_specificity'] = self::calcSpecificity($r); } unset($r); usort($rows, function (array $a, array $b) { if ($a['weigh'] !== $b['weigh']) { return $b['weigh'] <=> $a['weigh']; } if ($a['_specificity'] !== $b['_specificity']) { return $b['_specificity'] <=> $a['_specificity']; } return $b['id'] <=> $a['id']; }); self::$activeRulesCache = $rows; } return self::$activeRulesCache; } /** * 判断订单静态维度(不含金额区间)是否匹配规则 */ protected static function matchStaticDimensions(array $rule, string $priceType, string $customerType, int $catId, int $urgent): bool { $rMode = (string)($rule['order_mode'] ?? 'all'); if ($rMode !== 'all' && $rMode !== $priceType) { return false; } $rCust = (string)($rule['customer_type'] ?? 'all'); if ($rCust !== 'all' && $rCust !== $customerType) { return false; } $rCat = (int)($rule['cat_id'] ?? 0); if ($rCat > 0 && $rCat !== $catId) { return false; } $rUrgent = (string)($rule['urgent_mode'] ?? 'all'); if ($rUrgent === 'urgent' && $urgent !== 1) { return false; } if ($rUrgent === 'normal' && $urgent !== 0) { return false; } return true; } /** * 判断订单金额是否命中规则的金额区间 */ protected static function matchAmountRange(array $rule, float $amount): bool { $minAmt = (float)($rule['min_amount'] ?? 0); $maxAmt = (float)($rule['max_amount'] ?? 0); $amt = round(max(0.00, $amount), 2); if ($amt < $minAmt) { return false; } if ($maxAmt > 0 && $amt > $maxAmt) { return false; } return true; } /** * 提取规则核心计佣描述结构(用于写入订单快照) */ protected static function extractRuleItem(array $rule): array { $calcMode = in_array($rule['calc_mode'] ?? 'percent', ['percent', 'fixed', 'mixed'], true) ? $rule['calc_mode'] : 'percent'; return [ 'rule_id' => (int)($rule['id'] ?? 0), 'rule_name' => (string)($rule['name'] ?? '全局默认抽佣规则'), 'min_amount' => round((float)($rule['min_amount'] ?? 0), 2), 'max_amount' => round((float)($rule['max_amount'] ?? 0), 2), 'calc_mode' => $calcMode, 'commission_rate' => self::normalizeRate($rule['commission_rate'] ?? 0), 'fixed_fee' => round(max(0.00, (float)($rule['fixed_fee'] ?? 0)), 2), 'min_commission' => round(max(0.00, (float)($rule['min_commission'] ?? 0)), 2), 'max_commission' => round(max(0.00, (float)($rule['max_commission'] ?? 0)), 2), ]; } /** * 创单阶段:根据订单多维上下文匹配生效抽佣规则并生成完整快照 * * @param array $ctx ['price_type' => string, 'customer_type' => 'personal'|'merchant', 'cat_id' => int, 'urgent' => int, 'order_amount' => float] * @param array|null $rules 可选预加载规则列表(批量导单防重复查库) * @param array|null $defaults 可选预加载默认兜底配置 * @return array ['commission_rate' => float, 'commission_snapshot' => string(JSON), 'rule_name' => string] */ public static function matchOrderSnapshot(array $ctx, ?array $rules = null, ?array $defaults = null): array { $rules = $rules !== null ? $rules : self::getActiveRules(); $defaults = $defaults !== null ? $defaults : FinanceConfigService::getCommissionDefaults(); $priceType = trim((string)($ctx['price_type'] ?? 'fixed')) ?: 'fixed'; $customerType = in_array($ctx['customer_type'] ?? 'personal', ['personal', 'merchant'], true) ? $ctx['customer_type'] : 'personal'; $catId = max(0, (int)($ctx['cat_id'] ?? 0)); $urgent = (int)($ctx['urgent'] ?? 0) === 1 ? 1 : 0; $orderAmount = round(max(0.00, (float)($ctx['order_amount'] ?? 0)), 2); $fallbackItem = [ 'rule_id' => 0, 'rule_name' => '全局默认兜底规则', 'min_amount' => 0.00, 'max_amount' => 0.00, 'calc_mode' => $defaults['calc_mode'], 'commission_rate' => self::normalizeRate($defaults['commission_rate']), 'fixed_fee' => round((float)$defaults['fixed_fee'], 2), 'min_commission' => round((float)$defaults['min_commission'], 2), 'max_commission' => round((float)$defaults['max_commission'], 2), ]; $staticMatched = []; $matchedItem = null; foreach ($rules as $rule) { if (!self::matchStaticDimensions($rule, $priceType, $customerType, $catId, $urgent)) { continue; } $item = self::extractRuleItem($rule); $staticMatched[] = $item; if ($matchedItem === null && self::matchAmountRange($rule, $orderAmount)) { $matchedItem = $item; } } // 若报价单创单金额为 0,且存在起始金额 > 0 的报价阶梯但暂无 0 元阶梯,优先取第一条无金额限制规则,否则暂挂兜底并保留全部阶梯候选 if ($matchedItem === null) { $matchedItem = $fallbackItem; } $snapshot = [ 'version' => 1, 'snapshot_time' => time(), 'context' => [ 'price_type' => $priceType, 'customer_type' => $customerType, 'cat_id' => $catId, 'urgent' => $urgent, 'init_amount' => $orderAmount, ], 'include_diff_in_commission' => (int)$defaults['include_diff_in_commission'] === 1 ? 1 : 0, 'active_rule' => $matchedItem, 'fallback_rule' => $fallbackItem, ]; // 报价单创单时金额通常为 0,固化创单时刻符合静态条件的全部金额阶梯,供后续选定报价时在快照内部闭环定档 if ($priceType === 'quote' && !empty($staticMatched)) { $snapshot['candidate_tiers'] = $staticMatched; } $effectiveRate = self::calcDisplayRateForAmount($orderAmount, $matchedItem); return [ 'commission_rate' => $effectiveRate, 'commission_snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE), 'rule_name' => $matchedItem['rule_name'], ]; } /** * 报价单选定师傅报价并确认金额时:仅在订单自身的创单快照 candidate_tiers 内部重新定档(严禁读取后台新规则) * * @param float $finalAmount 选定报价后的订单金额 * @param mixed $existingSnapshotJson 订单表已有的 commission_snapshot * @param mixed $existingRate 订单表已有的 commission_rate * @return array ['commission_rate' => float, 'commission_snapshot' => string|null] */ public static function refreshQuoteOrderSnapshotOnAmountConfirmed(float $finalAmount, $existingSnapshotJson, $existingRate): array { $fallbackRate = ($existingRate !== null && trim((string)$existingRate) !== '' && is_numeric($existingRate)) ? self::normalizeRate($existingRate) : self::DEFAULT_RATE; if (!is_string($existingSnapshotJson) || trim($existingSnapshotJson) === '') { return [ 'commission_rate' => $fallbackRate, 'commission_snapshot' => null, ]; } $snap = json_decode($existingSnapshotJson, true); if (!is_array($snap)) { return [ 'commission_rate' => $fallbackRate, 'commission_snapshot' => $existingSnapshotJson, ]; } $chosen = null; if (!empty($snap['candidate_tiers']) && is_array($snap['candidate_tiers'])) { foreach ($snap['candidate_tiers'] as $tier) { if (is_array($tier) && self::matchAmountRange($tier, $finalAmount)) { $chosen = $tier; break; } } } if ($chosen === null) { if (!empty($snap['fallback_rule']) && is_array($snap['fallback_rule'])) { $chosen = $snap['fallback_rule']; } elseif (!empty($snap['active_rule']) && is_array($snap['active_rule'])) { $chosen = $snap['active_rule']; } } if (is_array($chosen)) { $snap['active_rule'] = $chosen; $snap['confirmed_quote_amount'] = round(max(0.00, $finalAmount), 2); $snap['confirmed_time'] = time(); $newRate = self::calcDisplayRateForAmount($finalAmount, $chosen); return [ 'commission_rate' => $newRate, 'commission_snapshot' => json_encode($snap, JSON_UNESCAPED_UNICODE), ]; } return [ 'commission_rate' => $fallbackRate, 'commission_snapshot' => $existingSnapshotJson, ]; } /** * 计算给定金额与规则项下的展示/快照百分比 */ protected static function calcDisplayRateForAmount(float $amount, array $ruleItem): float { $mode = $ruleItem['calc_mode'] ?? 'percent'; $rate = self::normalizeRate($ruleItem['commission_rate'] ?? 0); if ($mode === 'percent' && (float)($ruleItem['min_commission'] ?? 0) <= 0 && (float)($ruleItem['max_commission'] ?? 0) <= 0) { return $rate; } if ($amount <= 0) { return $rate; } $res = self::computeCommissionByRuleItem($amount, $amount, $ruleItem); return $res['commission_rate']; } /** * 核心纯函数:按规则项核算抽佣与实结金额 * * @param float $totalPayAmount 订单总实付金额(主单实付/应付总额,师傅结算总额上限) * @param float $calcBaseAmount 参与抽佣计算的基数金额(若增项免抽佣则已剔除增项差价) * @param array $ruleItem 规则快照结构 * @return array */ protected static function computeCommissionByRuleItem(float $totalPayAmount, float $calcBaseAmount, array $ruleItem): array { $total = max(0.00, round($totalPayAmount, 2)); $base = max(0.00, min($total, round($calcBaseAmount, 2))); $mode = in_array($ruleItem['calc_mode'] ?? 'percent', ['percent', 'fixed', 'mixed'], true) ? $ruleItem['calc_mode'] : 'percent'; $rate = self::normalizeRate($ruleItem['commission_rate'] ?? 0); $fixed = round(max(0.00, (float)($ruleItem['fixed_fee'] ?? 0)), 2); $minC = round(max(0.00, (float)($ruleItem['min_commission'] ?? 0)), 2); $maxC = round(max(0.00, (float)($ruleItem['max_commission'] ?? 0)), 2); if ($total <= 0 || $base <= 0) { return [ 'commission_rate' => $mode === 'percent' ? $rate : 0.00, 'commission' => 0.00, 'settle_amount' => $total, 'calc_mode' => $mode, 'rule_name' => (string)($ruleItem['rule_name'] ?? '历史比例快照'), 'calc_base_amount' => $base, ]; } if ($mode === 'fixed') { $rawComm = $fixed; } elseif ($mode === 'mixed') { $rawComm = round($base * $rate / 100 + $fixed, 2); } else { $rawComm = round($base * $rate / 100, 2); } $comm = $rawComm; if ($minC > 0 && $comm < $minC) { $comm = $minC; } if ($maxC > 0 && $comm > $maxC) { $comm = $maxC; } // 抽佣额绝不可超过计佣基数或总实付额 $comm = round(min($base, max(0.00, $comm)), 2); $settle = round(max(0.00, $total - $comm), 2); // 若为纯比例且未触发保底/封顶与增项剔除,保留原配置比例;否则折算实际综合抽佣率供结算单归档对账 if ($mode === 'percent' && $comm === $rawComm && abs($total - $base) < 0.001) { $effectiveRate = $rate; } else { $effectiveRate = $total > 0 ? self::normalizeRate(round($comm / $total * 100, 2)) : $rate; } return [ 'commission_rate' => $effectiveRate, 'commission' => $comm, 'settle_amount' => $settle, 'calc_mode' => $mode, 'rule_name' => (string)($ruleItem['rule_name'] ?? '历史比例快照'), 'calc_base_amount' => $base, ]; } /** * 基于完整订单行记录(fa_order)锁定的快照计算平台抽佣与师傅实结金额 * * @param array $order fa_order 行数据(含 pay_amount/total_amount, diff_amount, diff_status, commission_rate, commission_snapshot) * @param float|null $overrideAmount 可选指定结算总金额(如订单大厅预估展示时传入基准价) * @return array ['commission_rate' => float, 'commission' => float, 'settle_amount' => float, 'calc_mode' => string, 'rule_name' => string, 'calc_base_amount' => float] */ public static function calcByOrder(array $order, ?float $overrideAmount = null): array { if ($overrideAmount !== null) { $totalAmount = max(0.00, round($overrideAmount, 2)); } else { $payAmt = (float)($order['pay_amount'] ?? 0); $totAmt = (float)($order['total_amount'] ?? 0); $totalAmount = max(0.00, round($payAmt > 0 ? $payAmt : $totAmt, 2)); } $snapshotRate = $order['commission_rate'] ?? null; $snapshotJson = $order['commission_snapshot'] ?? null; if (is_string($snapshotJson) && trim($snapshotJson) !== '') { $snap = json_decode($snapshotJson, true); if (is_array($snap) && !empty($snap['active_rule']) && is_array($snap['active_rule'])) { $calcBase = $totalAmount; $includeDiff = isset($snap['include_diff_in_commission']) ? (int)$snap['include_diff_in_commission'] : 1; if ($overrideAmount === null && $includeDiff === 0 && (int)($order['diff_status'] ?? 0) === 2) { $diffAmt = round(max(0.00, (float)($order['diff_amount'] ?? 0)), 2); if ($diffAmt > 0 && $calcBase >= $diffAmt) { $calcBase = round($calcBase - $diffAmt, 2); } } return self::computeCommissionByRuleItem($totalAmount, $calcBase, $snap['active_rule']); } } return self::calcByOrderSnapshot($totalAmount, $snapshotRate); } /** * 兼容历史仅含 commission_rate 字段或传入快照 JSON 的核算调用 * * @param float $orderAmount 订单实付结算基数 * @param mixed $snapshotRate fa_order.commission_rate 快照值 * @param string|null $snapshotJson 可选 fa_order.commission_snapshot JSON * @return array ['commission_rate' => float, 'commission' => float, 'settle_amount' => float, 'calc_mode' => string, 'rule_name' => string, 'calc_base_amount' => float] */ public static function calcByOrderSnapshot(float $orderAmount, $snapshotRate, ?string $snapshotJson = null): array { if ($snapshotJson !== null && trim($snapshotJson) !== '') { return self::calcByOrder([ 'pay_amount' => $orderAmount, 'total_amount' => $orderAmount, 'commission_rate' => $snapshotRate, 'commission_snapshot' => $snapshotJson, ]); } $rate = ($snapshotRate !== null && trim((string)$snapshotRate) !== '' && is_numeric($snapshotRate)) ? self::normalizeRate($snapshotRate) : self::DEFAULT_RATE; return self::computeCommissionByRuleItem($orderAmount, $orderAmount, [ 'rule_name' => '比例快照(' . number_format($rate, 2, '.', '') . '%)', 'calc_mode' => 'percent', 'commission_rate' => $rate, 'fixed_fee' => 0.00, 'min_commission' => 0.00, 'max_commission' => 0.00, ]); } /** * 将 fa_config 同步至 application/extra/site.php,并保留文件中非数据库托管的扩展密钥 * * @param string $removedKey 刚被删除的配置键名 * @return void */ public static function syncSiteConfigFile(string $removedKey = ''): void { $siteFile = CONF_PATH . 'extra' . DS . 'site.php'; $existing = []; if (is_file($siteFile)) { $loaded = include $siteFile; if (is_array($loaded)) { $existing = $loaded; } } if ($removedKey !== '' && array_key_exists($removedKey, $existing)) { unset($existing[$removedKey]); } $dbConfig = []; $rows = Db::name('config')->select(); foreach ($rows as $value) { $val = $value['value']; if (in_array($value['type'], ['selects', 'checkbox', 'images', 'files'], true)) { $val = $val === '' ? [] : explode(',', (string)$val); } elseif ($value['type'] === 'array') { $val = (array)json_decode((string)$val, true); } $dbConfig[$value['name']] = $val; } $merged = array_merge($existing, $dbConfig); file_put_contents( $siteFile, '