feat: P0-1 价格脚本引擎(安全公式解析+脚本CRUD+试算+报价集成)

This commit is contained in:
HHY
2026-09-25 03:06:32 -07:00
parent 5965d572b5
commit 8c2bb5e12d
12 changed files with 585 additions and 66 deletions
+189
View File
@@ -0,0 +1,189 @@
"""安全价格公式引擎:递归下降解析器,支持变量与数学函数,绝不 eval/exec。
文法:
expr := term (('+'|'-') term)*
term := unary (('*'|'/') unary)*
unary := ('-'|'+') unary | primary
primary:= NUMBER | IDENT | IDENT '(' expr (',' expr)* ')' | '(' expr ')'
函数: min max round abs ceil floor sqrt
"""
import math
class FormulaError(ValueError):
"""公式语法/求值错误(message 面向用户可读)。"""
_FUNCTIONS: dict = {
"min": min,
"max": max,
"round": lambda v, ndigits=0: round(v, int(ndigits)),
"abs": abs,
"ceil": math.ceil,
"floor": math.floor,
"sqrt": math.sqrt,
}
def _tokenize(text: str) -> list[tuple[str, str]]:
"""词法分析 → [(类型, 值)]。类型: num/ident/op/lp/rp/comma。"""
tokens: list[tuple[str, str]] = []
i, n = 0, len(text)
while i < n:
ch = text[i]
if ch.isspace():
i += 1
elif ch.isdigit() or (ch == "." and i + 1 < n and text[i + 1].isdigit()):
j = i
while j < n and (text[j].isdigit() or text[j] == "."):
j += 1
lit = text[i:j]
try:
float(lit)
except ValueError:
raise FormulaError(f"非法数字: {lit}")
tokens.append(("num", lit))
i = j
elif ch.isalpha() or ch == "_":
j = i
while j < n and (text[j].isalnum() or text[j] == "_"):
j += 1
tokens.append(("ident", text[i:j]))
i = j
elif ch in "+-*/":
tokens.append(("op", ch))
i += 1
elif ch == "(":
tokens.append(("lp", ch))
i += 1
elif ch == ")":
tokens.append(("rp", ch))
i += 1
elif ch == ",":
tokens.append(("comma", ch))
i += 1
else:
raise FormulaError(f"公式包含非法字符: {ch!r}")
return tokens
class _Parser:
"""递归下降求值器。"""
def __init__(self, tokens: list[tuple[str, str]], variables: dict) -> None:
self.tokens = tokens
self.pos = 0
self.vars = variables
def _peek(self) -> tuple[str, str] | None:
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def _next(self) -> tuple[str, str]:
tk = self._peek()
if tk is None:
raise FormulaError("公式不完整")
self.pos += 1
return tk
def parse(self) -> float:
v = self._expr()
tk = self._peek()
if tk is not None:
raise FormulaError(f"公式有多余内容: {tk[1]}")
return v
def _expr(self) -> float:
v = self._term()
while (tk := self._peek()) and tk[0] == "op" and tk[1] in "+-":
self._next()
rhs = self._term()
v = v + rhs if tk[1] == "+" else v - rhs
return v
def _term(self) -> float:
v = self._unary()
while (tk := self._peek()) and tk[0] == "op" and tk[1] in "*/":
self._next()
rhs = self._unary()
if tk[1] == "/" and rhs == 0:
raise FormulaError("除数为 0")
v = v * rhs if tk[1] == "*" else v / rhs
return v
def _unary(self) -> float:
tk = self._peek()
if tk and tk[0] == "op" and tk[1] in "+-":
self._next()
v = self._unary()
return -v if tk[1] == "-" else v
return self._primary()
def _primary(self) -> float:
kind, val = self._next()
if kind == "num":
return float(val)
if kind == "lp":
v = self._expr()
if (tk := self._next())[0] != "rp":
raise FormulaError("括号不匹配")
return v
if kind == "ident":
nxt = self._peek()
if nxt and nxt[0] == "lp": # 函数调用
self._next()
args: list[float] = []
if (tk := self._peek()) and tk[0] != "rp":
args.append(self._expr())
while (tk := self._peek()) and tk[0] == "comma":
self._next()
args.append(self._expr())
if not (tk := self._next()) or tk[0] != "rp":
raise FormulaError(f"函数 {val} 括号不匹配")
fn = _FUNCTIONS.get(val)
if fn is None:
raise FormulaError(f"不支持的函数: {val}")
try:
return float(fn(*args))
except TypeError:
raise FormulaError(f"函数 {val} 参数数量不正确")
if val not in self.vars:
raise FormulaError(f"未知变量: {val}")
return float(self.vars[val])
raise FormulaError(f"意外的符号: {val}")
def eval_formula(formula: str, variables: dict) -> float:
"""求值价格公式。formula 为空/None 时抛 FormulaError。"""
if not formula or not formula.strip():
raise FormulaError("公式为空")
tokens = _tokenize(formula)
if not tokens:
raise FormulaError("公式为空")
return _Parser(tokens, variables).parse()
# 公式可用变量白名单(与 quote_service 脚本变量表一致)
ALLOWED_VARS = {
"w", "h", "area", "perimeter", "glass_area", "sashes",
"meters_frame", "meters_mullion", "meters_sash", "length",
}
def validate_formula(formula: str) -> str | None:
"""校验公式:变量白名单 + 语法/求值检查。返回错误信息,None=合法。"""
if not formula or not formula.strip():
return "公式为空"
try:
tokens = _tokenize(formula)
except FormulaError as e:
return str(e)
idents = {v for k, v in tokens if k == "ident"}
unknown = idents - ALLOWED_VARS - set(_FUNCTIONS)
if unknown:
return f"未知变量: {sorted(unknown)[0]}(可用变量: {' '.join(sorted(ALLOWED_VARS))})"
try:
# 全变量=1 试算,专查语法结构(括号/运算符/函数参数)
eval_formula(formula, {v: 1 for v in ALLOWED_VARS})
except FormulaError as e:
return str(e)
return None
+63 -13
View File
@@ -26,6 +26,8 @@ class TakeoffResult:
glass_area_m2: float = 0.0
width_mm: int = 0
height_mm: int = 0
sash_count: int = 0
meters: dict = field(default_factory=dict) # 各型材类别总米数 {frame, mullion, sash}
def _shoelace_area(pts: list[tuple[float, float]]) -> float:
@@ -105,6 +107,13 @@ def takeoff(dd) -> TakeoffResult:
if xs:
result.width_mm = round(max(xs) - min(xs))
result.height_mm = round(max(ys) - min(ys))
# 型材米数汇总 + 扇数(供价格脚本变量表)
for kind in ("frame", "mullion", "sash"):
result.meters[kind] = round(sum(b.length * b.qty for b in result.bars if b.kind == kind) / 1000, 3)
result.sash_count = sum(
1 for s in dd.get("sm", []) for sub in (s.get("sm") or [])
if isinstance(sub, dict) and sub.get("bd") and not sub.get("bd").get("hidden")
)
return result
@@ -120,37 +129,76 @@ def merge_bars(bars: list[BarItem]) -> list[dict]:
return sorted(merged.values(), key=lambda x: -x["length"])
def quote(drawing: str | dict, prices: dict | None = None) -> dict:
"""完整报价:算料 + 套价。
def quote(drawing: str | dict, prices: dict | None = None, scripts: dict | None = None) -> dict:
"""完整报价:算料 + 套价(价格脚本可覆盖单项单价或总价)。
prices 结构: {frame: 元/米, mullion: 元/米, sash: 元/米, glass: 元/㎡, hardware: 元/扇}
scripts 结构: {target: 公式字符串},target ∈ frame/mullion/sash/glass/hardware/total。
公式结果 = 该项单价(元/米、元/㎡、元/扇);target=total 时结果直接作为总价。
公式变量: w h(mm) area(㎡) perimeter(m) glass_area(㎡) sashes(扇)
meters_frame meters_mullion meters_sash(m) length(作用项对应量)
"""
from huamenchuang.services.formula import FormulaError, eval_formula
p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180}
if prices:
p.update(prices)
dd = parse_drawing(drawing)
t = takeoff(dd)
rows, total = [], 0.0
unit_kind = {"frame": "frame", "mullion": "mullion", "sash": "sash"}
for b in merge_bars(t.bars):
amount = b["length"] * b["qty"] / 1000 * p[unit_kind[b["kind"]]]
total += amount
rows.append({**b, "unit_price": p[unit_kind[b["kind"]]], "unit": "元/米", "amount": round(amount, 2)})
glass_amount = t.glass_area_m2 * p["glass"]
# 价格脚本求值(公式错误直接上抛,由路由层转为用户可读失败)
total_override: float | None = None
script_unit: dict = {}
if scripts:
base_vars = {
"w": t.width_mm, "h": t.height_mm,
"area": round(t.width_mm * t.height_mm / 1_000_000, 3),
"perimeter": round((t.width_mm + t.height_mm) * 2 / 1000, 3),
"glass_area": round(t.glass_area_m2, 3),
"sashes": t.sash_count,
"meters_frame": t.meters.get("frame", 0),
"meters_mullion": t.meters.get("mullion", 0),
"meters_sash": t.meters.get("sash", 0),
}
length_of = {"frame": t.meters.get("frame", 0), "mullion": t.meters.get("mullion", 0),
"sash": t.meters.get("sash", 0), "glass": round(t.glass_area_m2, 3),
"hardware": t.sash_count, "total": 0}
for target, formula in scripts.items():
if target not in length_of or not formula or not str(formula).strip():
continue
vars_ = {**base_vars, "length": length_of[target]}
value = eval_formula(str(formula), vars_)
if target == "total":
total_override = value
else:
script_unit[target] = value
rows, total = [], 0.0
for b in merge_bars(t.bars):
unit = script_unit.get(b["kind"], p[b["kind"]])
amount = b["length"] * b["qty"] / 1000 * unit
total += amount
rows.append({**b, "unit_price": round(unit, 2), "unit": "元/米",
"amount": round(amount, 2), "scripted": b["kind"] in script_unit})
glass_unit = script_unit.get("glass", p["glass"])
glass_amount = t.glass_area_m2 * glass_unit
total += glass_amount
rows.append({"kind": "glass", "length": 0, "note": "玻璃", "qty": round(t.glass_area_m2, 3),
"unit_price": p["glass"], "unit": "元/㎡", "amount": round(glass_amount, 2)})
"unit_price": round(glass_unit, 2), "unit": "元/㎡",
"amount": round(glass_amount, 2), "scripted": "glass" in script_unit})
sash_count = sum(
1 for s in dd.get("sm", []) for sub in (s.get("sm") or [])
if isinstance(sub, dict) and sub.get("bd") and not sub.get("bd").get("hidden")
)
hw_amount = sash_count * p["hardware"]
hw_unit = script_unit.get("hardware", p["hardware"])
hw_amount = sash_count * hw_unit
if sash_count:
rows.append({"kind": "hardware", "length": 0, "note": "五金", "qty": sash_count,
"unit_price": p["hardware"], "unit": "元/扇", "amount": round(hw_amount, 2)})
"unit_price": round(hw_unit, 2), "unit": "元/扇",
"amount": round(hw_amount, 2), "scripted": "hardware" in script_unit})
total += hw_amount
return {
@@ -158,5 +206,7 @@ def quote(drawing: str | dict, prices: dict | None = None) -> dict:
"glass_area_m2": round(t.glass_area_m2, 3),
"width_mm": t.width_mm,
"height_mm": t.height_mm,
"total": round(total, 2),
"total": round(total_override if total_override is not None else total, 2),
"script_total": total_override is not None,
"scripted": sorted(script_unit.keys()),
}