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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""读取 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-30 07:23:33 +08:00
|
|
|
|
流程:单位换算 -> 构建公式上下文 -> 公式求值 -> 附加费计算 -> 构建公式说明。
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
}
|
2026-05-30 07:23:33 +08:00
|
|
|
|
|
|
|
|
|
|
被调用路由: 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]:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""返回定价规则中可选的附加费配置列表。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
rule: ProductPricingRule ORM 对象
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
附加费选项列表,每项包含 key、name、method、price 等
|
|
|
|
|
|
|
|
|
|
|
|
被调用路由: 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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""将前端输入转换为统一的米制数值。
|
|
|
|
|
|
|
|
|
|
|
|
根据 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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""替换变量 -> 解析条件表达式 -> 安全求值。
|
|
|
|
|
|
|
|
|
|
|
|
支持 $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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""处理 ${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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""求值单个三元条件表达式。
|
|
|
|
|
|
|
|
|
|
|
|
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]:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""计算所有启用的附加费项。
|
|
|
|
|
|
|
|
|
|
|
|
支持四种计费方式:按面积(per_sqm)、按数量(per_piece)、
|
|
|
|
|
|
按长度(per_linear_m)、按面积阈值(per_sqm_threshold)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
surcharge_json: 附加费配置 JSON 字符串
|
|
|
|
|
|
user_inputs: 前端原始输入
|
|
|
|
|
|
normalized: 标准化后的输入
|
|
|
|
|
|
area_sqm: 面积(平方米)
|
|
|
|
|
|
product_attrs: 产品属性字典
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
附加费计算结果列表,每项包含 key、name、method、amount
|
|
|
|
|
|
"""
|
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-05-30 07:23:33 +08:00
|
|
|
|
"""构建人类可读的计算过程文本。
|
|
|
|
|
|
|
|
|
|
|
|
输出格式示例: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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""安全解析 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:
|
2026-05-30 07:23:33 +08:00
|
|
|
|
"""将值转换为字符串,布尔值特殊处理为 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()
|