dingdanquanliucheng/backend/app/services/pricing_engine.py

326 lines
12 KiB
Python
Raw Normal View History

2026-05-26 15:24:41 +08:00
"""配置驱动的报价计算引擎。
所有产品差异由 product_pricing_rule 表的 JSON 配置决定
引擎本身不包含任何产品特定的 if/else 分支
"""
import json
import re
from decimal import Decimal, ROUND_HALF_UP
class PricingEngine:
"""读取 PricingRule 配置,执行公式求值、附加费计算、详情构建。
所有产品差异由 product_pricing_rule 表的 JSON 配置驱动
引擎本身不包含任何产品特定的 if/else 分支
"""
2026-05-26 15:24:41 +08:00
# ------------------------------------------------------------------
# 公开方法
# ------------------------------------------------------------------
def calculate(self, rule, user_inputs: dict, product_attrs: dict | None = None) -> dict:
"""执行完整报价计算。
流程单位换算 -> 构建公式上下文 -> 公式求值 -> 附加费计算 -> 构建公式说明
2026-05-26 15:24:41 +08:00
Args:
rule: ProductPricingRule ORM 对象 formula_expr 等字段
user_inputs: 前端传入 {"length": 2, "width": 1.2, "length_unit": "m", ...}
product_attrs: 产品属性 {"thickness": 0.08} 用于阈值判断
Returns:
{
"base_cost", "surcharge_items", "total_surcharge", "cost_price",
"formula_detail", "formula_detail_steps"
}
被调用路由: pricing.py - POST /pricing/calculate
2026-05-26 15:24:41 +08:00
"""
product_attrs = product_attrs or {}
# 1. 单位换算 → 统一为米
normalized = self._normalize_inputs(rule, user_inputs)
area_sqm = normalized.get("length_m", 0) * normalized.get("width_m", 0)
# 2. 构建公式上下文并求值
context = {
"input": normalized,
"rule": {"base_unit_price": float(rule.base_unit_price)},
"const": self._safe_load_json(rule.formula_constants) or {},
"product": product_attrs,
}
base_cost = self._evaluate_formula(rule.formula_expr or "0", context)
# 3. 计算附加费
surcharges = self._calculate_surcharges(
rule.surcharge_json, user_inputs, normalized, area_sqm, product_attrs,
)
total_surcharge = round(sum(s["amount"] for s in surcharges), 2)
cost_price = round(base_cost + total_surcharge, 2)
# 4. 构建公式说明
formula_detail = self._build_formula_detail(rule, normalized, base_cost, surcharges)
return {
"base_cost": round(base_cost, 2),
"surcharge_items": surcharges,
"total_surcharge": total_surcharge,
"cost_price": cost_price,
"formula_detail": formula_detail,
}
def get_available_surcharge_options(self, rule) -> list[dict]:
"""返回定价规则中可选的附加费配置列表。
Args:
rule: ProductPricingRule ORM 对象
Returns:
附加费选项列表每项包含 keynamemethodprice
被调用路由: pricing.py - GET /pricing/surcharge-options/{rule_id}
"""
2026-05-26 15:24:41 +08:00
surcharge = self._safe_load_json(rule.surcharge_json) or {}
return [
{"key": key, "name": cfg.get("name", key), "method": cfg.get("method", ""),
"price": cfg.get("price", 0), "needs_input": cfg.get("needs_input", False),
"input_key": cfg.get("input_key", key)}
for key, cfg in surcharge.items()
]
# ------------------------------------------------------------------
# 单位换算
# ------------------------------------------------------------------
_UNIT_TO_M = {"m": 1.0, "cm": 0.01, "mm": 0.001}
def _normalize_inputs(self, rule, user_inputs: dict) -> dict:
"""将前端输入转换为统一的米制数值。
根据 pricing_inputs 声明解析字段自动读取 _unit 后缀进行单位换算
Args:
rule: 定价规则 ORM 对象
user_inputs: 前端传入的原始输入字典
Returns:
标准化后的输入字典包含原始值和 _m 后缀的米制值
"""
2026-05-26 15:24:41 +08:00
normalized = {}
# 解析 pricing_inputs 声明
inputs_decl = self._safe_load_json(rule.pricing_inputs) or []
for field in inputs_decl:
key = field.get("key", "")
val = user_inputs.get(key)
if val is None or val == "":
normalized[key] = 0
continue
val = float(val)
if field.get("type") == "boolean":
normalized[key] = val
continue
# 带单位的数值字段:读取对应 _unit 后缀
unit_key = f"{key}_unit"
unit = user_inputs.get(unit_key, field.get("default_unit", "m"))
multiplier = self._UNIT_TO_M.get(unit, 1.0)
normalized[f"{key}_m"] = round(val * multiplier, 6)
normalized[key] = val
return normalized
# ------------------------------------------------------------------
# 公式求值
# ------------------------------------------------------------------
def _evaluate_formula(self, expr: str, context: dict) -> float:
"""替换变量 -> 解析条件表达式 -> 安全求值。
支持 $input.xxx$rule.xxx$const.xxx$product.xxx 变量引用
以及 ${condition} ? a : b 三元条件表达式
Args:
expr: 公式表达式字符串
context: 变量上下文字典
Returns:
计算结果浮点数异常时返回 0.0
"""
2026-05-26 15:24:41 +08:00
resolved = expr
# 替换 $input.xxx / $rule.xxx / $const.xxx / $product.xxx
for prefix, values in context.items():
if not isinstance(values, dict):
continue
for k, v in values.items():
resolved = resolved.replace(f"${prefix}.{k}", self._to_str(v))
# 解析 ${condition} ? a : b
resolved = self._resolve_conditionals(resolved)
# 安全求值
try:
return round(float(eval(resolved, {"__builtins__": {}}, {"round": round})), 4)
except Exception:
return 0.0
def _resolve_conditionals(self, expr: str) -> str:
"""处理 ${condition} ? valueA : valueB 三元表达式。
循环替换直到表达式中不再包含条件表达式支持嵌套
Args:
expr: 含条件表达式的公式字符串
Returns:
条件表达式已解析的公式字符串
"""
2026-05-26 15:24:41 +08:00
pattern = r"\$\{([^}]+)\}\s*\?\s*([^:]+?)\s*:\s*([^,\s\)]+)"
while re.search(pattern, expr):
expr = re.sub(pattern, self._eval_one_conditional, expr)
return expr
def _eval_one_conditional(self, match: re.Match) -> str:
"""求值单个三元条件表达式。
Args:
match: 正则匹配对象包含条件真值假值三个分组
Returns:
条件求值结果对应的值字符串
"""
2026-05-26 15:24:41 +08:00
cond = match.group(1).strip()
val_true = match.group(2).strip()
val_false = match.group(3).strip()
try:
result = eval(cond, {"__builtins__": {}}, {}) # noqa: S307
return val_true if result else val_false
except Exception:
return val_false
# ------------------------------------------------------------------
# 附加费计算
# ------------------------------------------------------------------
def _calculate_surcharges(
self, surcharge_json, user_inputs: dict, normalized: dict,
area_sqm: float, product_attrs: dict,
) -> list[dict]:
"""计算所有启用的附加费项。
支持四种计费方式按面积(per_sqm)按数量(per_piece)
按长度(per_linear_m)按面积阈值(per_sqm_threshold)
Args:
surcharge_json: 附加费配置 JSON 字符串
user_inputs: 前端原始输入
normalized: 标准化后的输入
area_sqm: 面积平方米
product_attrs: 产品属性字典
Returns:
附加费计算结果列表每项包含 keynamemethodamount
"""
2026-05-26 15:24:41 +08:00
surcharge = self._safe_load_json(surcharge_json) or {}
results = []
for key, cfg in surcharge.items():
if not user_inputs.get(f"surcharge_{key}", False):
continue
method = cfg.get("method", "per_sqm")
amount = 0.0
if method == "per_sqm":
amount = round(area_sqm * cfg.get("price", 0), 2)
elif method == "per_piece":
input_key = cfg.get("input_key", key)
qty = float(user_inputs.get(input_key, 0) or 0)
amount = round(qty * cfg.get("price", 0), 2)
elif method == "per_linear_m":
input_key = cfg.get("input_key", key)
length = float(user_inputs.get(input_key, 0) or 0)
amount = round(length * cfg.get("price", 0), 2)
elif method == "per_sqm_threshold":
threshold = cfg.get("threshold", {})
field_val = float(product_attrs.get(threshold.get("field", ""), 0) or 0)
price = threshold["price_true"] if field_val <= threshold.get("lte", 0) else threshold["price_false"]
amount = round(area_sqm * price, 2)
results.append({
"key": key, "name": cfg.get("name", key),
"method": method, "amount": amount,
})
return results
# ------------------------------------------------------------------
# 公式说明构建
# ------------------------------------------------------------------
def _build_formula_detail(self, rule, normalized: dict, base_cost: float, surcharges: list) -> str:
"""构建人类可读的计算过程文本。
输出格式示例2m x 1.2m x ¥50/ = ¥120.00 -> + 加急费 ¥20.00 = ¥140.00
Args:
rule: 定价规则 ORM 对象
normalized: 标准化后的输入
base_cost: 基础费用
surcharges: 附加费列表
Returns:
公式说明字符串
"""
2026-05-26 15:24:41 +08:00
parts = []
length = normalized.get("length_m", 0)
width = normalized.get("width_m", 0)
parts.append(f"{length}m × {width}m × ¥{rule.base_unit_price}/㎡ = ¥{base_cost:.2f}")
for s in surcharges:
parts.append(f"+ {s['name']} ¥{s['amount']:.2f}")
total = base_cost + sum(s["amount"] for s in surcharges)
parts.append(f"= ¥{total:.2f}")
return "".join(parts)
# ------------------------------------------------------------------
# 工具方法
# ------------------------------------------------------------------
@staticmethod
def _safe_load_json(text: str | None) -> dict | list | None:
"""安全解析 JSON 字符串。
Args:
text: JSON 字符串
Returns:
解析后的 dict/list空值或解析失败时返回 None
"""
2026-05-26 15:24:41 +08:00
if not text:
return None
try:
return json.loads(text)
except (json.JSONDecodeError, TypeError):
return None
@staticmethod
def _to_str(v) -> str:
"""将值转换为字符串,布尔值特殊处理为 True/False。
Args:
v: 任意值
Returns:
字符串表示
"""
2026-05-26 15:24:41 +08:00
if isinstance(v, bool):
return "True" if v else "False"
return str(v)
# 模块级单例
pricing_engine = PricingEngine()