dingdanquanliucheng/backend/app/services/pricing_engine.py

469 lines
18 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:
2026-06-30 20:54:17 +08:00
结构化计价结果字典包含 base_costsurcharge_itemstotal_surcharge
cost_priceformula_detailtax 字段以及 rule_snapshot 用于快照存储
被调用路由: pricing.py - POST /pricing/calculate
2026-05-26 15:24:41 +08:00
"""
product_attrs = product_attrs or {}
2026-06-30 20:54:17 +08:00
# 1. 单位换算
2026-05-26 15:24:41 +08:00
normalized = self._normalize_inputs(rule, user_inputs)
area_sqm = normalized.get("length_m", 0) * normalized.get("width_m", 0)
# 2. 构建公式上下文并求值
2026-06-30 20:54:17 +08:00
formula_constants = self._safe_load_json(rule.formula_constants) or {}
2026-05-26 15:24:41 +08:00
context = {
"input": normalized,
"rule": {"base_unit_price": float(rule.base_unit_price)},
2026-06-30 20:54:17 +08:00
"const": formula_constants,
2026-05-26 15:24:41 +08:00
"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)
# 5. 税计算
tax_rate = float(getattr(rule, "tax_rate", 0) or 0)
tax_inclusive = int(getattr(rule, "tax_inclusive", 0) or 0)
if tax_rate > 0:
if tax_inclusive:
price_ex_tax = round(cost_price / (1 + tax_rate / 100), 2)
tax_amount = round(cost_price - price_ex_tax, 2)
price_in_tax = cost_price
else:
price_ex_tax = cost_price
tax_amount = round(cost_price * tax_rate / 100, 2)
price_in_tax = round(cost_price + tax_amount, 2)
else:
price_ex_tax = cost_price
tax_amount = 0
price_in_tax = cost_price
2026-06-30 20:54:17 +08:00
# 6. 构建变量快照(用于审计和回显)
formula_variables = {}
for prefix, values in context.items():
if isinstance(values, dict):
for k, v in values.items():
formula_variables[f"{prefix}.{k}"] = v
# 7. 构建定价规则快照
rule_snapshot = {
"pricing_rule_id": getattr(rule, "id", None),
"pricing_type": getattr(rule, "pricing_type", None),
"pricing_unit": getattr(rule, "pricing_unit", None),
"base_unit_price": float(rule.base_unit_price or 0),
"formula_expr": getattr(rule, "formula_expr", None),
"formula_note": getattr(rule, "formula_note", None),
}
2026-05-26 15:24:41 +08:00
return {
"base_cost": round(base_cost, 2),
"surcharge_items": surcharges,
"total_surcharge": total_surcharge,
"cost_price": cost_price,
"formula_detail": formula_detail,
2026-06-30 20:54:17 +08:00
"formula_variables": formula_variables,
"tax_rate": tax_rate,
2026-06-30 20:54:17 +08:00
"tax_inclusive": tax_inclusive,
"price_ex_tax": price_ex_tax,
"tax_amount": tax_amount,
"price_in_tax": price_in_tax,
2026-06-30 20:54:17 +08:00
"area_sqm": round(area_sqm, 6),
"rule_snapshot": rule_snapshot,
# AI 辅助标记当前由规则引擎计算ai_assisted=False
# 当规则缺失时由 AI 填充,需人工确认
"ai_assisted": False,
"ai_reason": None,
"ai_confidence": None,
"needs_manual_confirmation": False,
}
def calculate_for_order_item(self, rule, user_inputs: dict, product_attrs: dict | None = None) -> dict:
"""为订单项执行计价并返回可直接存入快照的结构化结果。
calculate() 基础上额外返回适合存入 SalesOrderItem 的字段映射
Args:
rule: ProductPricingRule ORM 对象
user_inputs: 前端传入的输入
product_attrs: 产品属性
Returns:
包含 item_snapshot 字段的计价结果可直接写入订单项
"""
result = self.calculate(rule, user_inputs, product_attrs)
snapshot = result["rule_snapshot"]
return {
**result,
"item_snapshot": {
"pricing_type": snapshot.get("pricing_type"),
"pricing_unit": snapshot.get("pricing_unit"),
"area_sqm": result["area_sqm"],
"surcharge_detail": json.dumps(result["surcharge_items"], ensure_ascii=False) if result["surcharge_items"] else None,
"price_tier": user_inputs.get("price_tier"),
"cost_price": result["cost_price"],
},
2026-05-26 15:24:41 +08:00
}
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}
2026-06-30 20:54:17 +08:00
# 重量单位 → 千克
_UNIT_TO_KG = {"kg": 1.0, "g": 0.001, "t": 1000.0}
# 面积单位 → 平方米
_UNIT_TO_SQM = {"sqm": 1.0, "sqcm": 0.0001, "sqmm": 0.000001}
2026-07-03 15:23:14 +08:00
# 体积单位 → 立方米
_UNIT_TO_CBM = {"cbm": 1.0, "cubic_m": 1.0, "cubic_cm": 0.000001, "liter": 0.001}
2026-05-26 15:24:41 +08:00
def _normalize_inputs(self, rule, user_inputs: dict) -> dict:
2026-06-30 20:54:17 +08:00
"""将前端输入转换为统一的标准单位数值。
根据 pricing_inputs 声明解析字段自动读取 _unit 后缀进行单位换算
2026-06-30 20:54:17 +08:00
- 长度类字段统一转为米_m 后缀
- 重量类字段统一转为千克_kg 后缀
- 面积类字段统一转为平方米_sqm 后缀
Args:
rule: 定价规则 ORM 对象
user_inputs: 前端传入的原始输入字典
Returns:
2026-06-30 20:54:17 +08:00
标准化后的输入字典包含原始值和标准化后缀值
"""
2026-05-26 15:24:41 +08:00
normalized = {}
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_key = f"{key}_unit"
unit = user_inputs.get(unit_key, field.get("default_unit", "m"))
2026-06-30 20:54:17 +08:00
field_type = field.get("unit_type", "length")
if field_type == "weight":
multiplier = self._UNIT_TO_KG.get(unit, 1.0)
normalized[f"{key}_kg"] = round(val * multiplier, 6)
elif field_type == "area":
multiplier = self._UNIT_TO_SQM.get(unit, 1.0)
normalized[f"{key}_sqm"] = round(val * multiplier, 6)
2026-07-03 15:23:14 +08:00
elif field_type == "volume":
multiplier = self._UNIT_TO_CBM.get(unit, 1.0)
normalized[f"{key}_cbm"] = round(val * multiplier, 6)
2026-06-30 20:54:17 +08:00
else:
multiplier = self._UNIT_TO_M.get(unit, 1.0)
normalized[f"{key}_m"] = round(val * multiplier, 6)
2026-05-26 15:24:41 +08:00
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 三元条件表达式
2026-06-30 20:54:17 +08:00
使用正则替换按变量名长度降序避免短变量名误匹配长变量名前缀
Args:
expr: 公式表达式字符串
context: 变量上下文字典
Returns:
计算结果浮点数异常时返回 0.0
"""
2026-05-26 15:24:41 +08:00
resolved = expr
2026-06-30 20:54:17 +08:00
# 收集所有 (完整变量名, 替换值),按变量名长度降序排列防止前缀误匹配
replacements = []
2026-05-26 15:24:41 +08:00
for prefix, values in context.items():
if not isinstance(values, dict):
continue
for k, v in values.items():
2026-06-30 20:54:17 +08:00
var_name = f"${prefix}.{k}"
replacements.append((var_name, self._to_str(v)))
replacements.sort(key=lambda x: len(x[0]), reverse=True)
for var_name, replacement in replacements:
# 用 re.escape 转义变量名中的特殊字符(如 $ 和 .
pattern = re.escape(var_name)
resolved = re.sub(pattern, replacement, resolved)
2026-05-26 15:24:41 +08:00
# 解析 ${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:
"""构建人类可读的计算过程文本。
2026-06-30 20:54:17 +08:00
根据定价类型自动生成对应的计算说明
- area: 2m x 1.2m x ¥50/ = ¥120.00
- kg: 5kg x ¥8/kg = ¥40.00
- unit/: 10 x ¥25/ = ¥250.00
- linear_m: 20m x ¥15/m = ¥300.00
Args:
rule: 定价规则 ORM 对象
normalized: 标准化后的输入
base_cost: 基础费用
surcharges: 附加费列表
Returns:
公式说明字符串
"""
2026-06-30 20:54:17 +08:00
pricing_type = getattr(rule, "pricing_type", "area") or "area"
unit_price = float(rule.base_unit_price or 0)
2026-05-26 15:24:41 +08:00
parts = []
2026-06-30 20:54:17 +08:00
if pricing_type == "area":
length = normalized.get("length_m", 0)
width = normalized.get("width_m", 0)
parts.append(f"{length}m × {width}m × ¥{unit_price}/㎡ = ¥{base_cost:.2f}")
elif pricing_type in ("kg", "weight_g"):
weight = normalized.get("weight_kg", normalized.get("weight_kg", 0))
if pricing_type == "weight_g":
weight_g = weight * 1000 if weight else normalized.get("weight", 0)
parts.append(f"{weight_g}g × ¥{unit_price}/g = ¥{base_cost:.2f}")
else:
parts.append(f"{weight}kg × ¥{unit_price}/kg = ¥{base_cost:.2f}")
elif pricing_type == "linear_m":
length = normalized.get("length_m", normalized.get("length", 0))
parts.append(f"{length}m × ¥{unit_price}/m = ¥{base_cost:.2f}")
elif pricing_type in ("unit", ""):
qty = normalized.get("quantity", normalized.get("qty", 0))
parts.append(f"{qty}× ¥{unit_price}/件 = ¥{base_cost:.2f}")
2026-07-03 15:23:14 +08:00
elif pricing_type == "volume":
vol = normalized.get("volume_cbm", normalized.get("volume", 0))
parts.append(f"{vol}× ¥{unit_price}/m³ = ¥{base_cost:.2f}")
2026-06-30 20:54:17 +08:00
else:
# 通用:显示公式结果
parts.append(f"计算结果 = ¥{base_cost:.2f}")
2026-05-26 15:24:41 +08:00
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}")
tax_rate = float(getattr(rule, "tax_rate", 0) or 0)
if tax_rate > 0:
tax_inclusive = int(getattr(rule, "tax_inclusive", 0) or 0)
if tax_inclusive:
parts.append(f"含税价(税率{tax_rate}%)")
else:
parts.append(f"+ 税额(税率{tax_rate}%)")
2026-05-26 15:24:41 +08:00
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()