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
+16 -4
View File
@@ -16,6 +16,8 @@ class QuoteRequest(BaseModel):
order_id: int | None = Field(default=None, description="或对已有订单报价")
prices: dict | None = Field(default=None, description="价格本覆盖 {frame,mullion,sash,glass,hardware}")
use_price_book: bool = Field(default=False, description="从产品管理价格本取价(按材料类别均价)")
use_scripts: bool = Field(default=False, description="启用价格脚本计价(公司启用脚本覆盖默认单价/总价)")
scripts: dict | None = Field(default=None, description="临时脚本 {target: formula}, 优先于脚本库")
async def _resolve_drawing(db, body: QuoteRequest) -> str | dict | None:
@@ -56,15 +58,25 @@ async def _price_book(db, company_id: int) -> dict | None:
@router.post("")
async def offer_price(body: QuoteRequest, user: CurrentUser, db: DbSession):
"""算料报价:型材清单 + 玻璃面积 + 五金 + 总价。"""
"""算料报价:型材清单 + 玻璃面积 + 五金 + 总价(可选价格脚本计价)。"""
drawing = await _resolve_drawing(db, body)
if drawing is None:
return fail("未找到图纸数据")
prices = body.prices
if prices is None and body.use_price_book:
prices = await _price_book(db, user.company_id)
# 脚本解析:显式传入的临时脚本优先,其次取脚本库中启用脚本
scripts = body.scripts
if scripts is None and body.use_scripts:
from sqlalchemy import select as _select
from huamenchuang.api.script import load_script_map
from huamenchuang.models.business import PriceScript
rows = list((await db.execute(
_select(PriceScript).where(PriceScript.company_id == user.company_id))).scalars())
scripts = load_script_map(rows) or None
try:
result = calc_quote(drawing, prices)
except Exception:
return fail("图纸数据解析失败")
result = calc_quote(drawing, prices, scripts)
except Exception as e: # 公式/图纸解析错误 → 用户可读失败
msg = str(e) if "公式" in str(e) or "未知变量" in str(e) else "图纸数据解析失败"
return fail(msg)
return ok(data=result, message="报价成功")
+2
View File
@@ -11,6 +11,7 @@ from huamenchuang.api import (
platform,
quote,
reports,
script,
warehouse,
windoor,
)
@@ -23,6 +24,7 @@ api_router.include_router(contracts.router)
api_router.include_router(windoor.router)
api_router.include_router(optimize.router)
api_router.include_router(quote.router)
api_router.include_router(script.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
api_router.include_router(platform.router)
+111
View File
@@ -0,0 +1,111 @@
"""价格脚本路由:按系列配置定价公式(对齐目标站 script/product-test 语义)。"""
import json
from fastapi import APIRouter
from pydantic import BaseModel, Field
from sqlalchemy import select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.business import PriceScript
from huamenchuang.services.formula import FormulaError, eval_formula, validate_formula
router = APIRouter(prefix="/scripts", tags=["价格脚本"])
class ScriptBody(BaseModel):
name: str = Field(min_length=1, max_length=64, description="系列/脚本名")
target: str = Field(default="frame",
pattern="^(frame|mullion|sash|glass|hardware|total)$",
description="公式作用项")
formula: str = Field(min_length=1, description="定价公式(安全表达式)")
note: str = ""
enabled: int = Field(default=1, ge=0, le=1)
class ScriptPreviewBody(BaseModel):
formula: str = Field(min_length=1)
target: str = Field(default="frame",
pattern="^(frame|mullion|sash|glass|hardware|total)$",
description="作用项(length 变量按此取值)")
w: int = Field(default=1500, description="窗宽 mm")
h: int = Field(default=1800, description="窗高 mm")
glass_area: float = Field(default=2.3, description="玻璃面积 ㎡")
sashes: int = Field(default=2, description="扇数")
meters_frame: float = Field(default=6.6)
meters_mullion: float = Field(default=2.4)
meters_sash: float = Field(default=7.2)
def _to_out(s: PriceScript) -> dict:
return {"id": s.id, "name": s.name, "target": s.target, "formula": s.formula,
"note": s.note, "enabled": s.enabled, "created_at": str(s.created_at)}
@router.get("")
async def list_scripts(user: CurrentUser, db: DbSession):
stmt = (select(PriceScript).where(PriceScript.company_id == user.company_id)
.order_by(PriceScript.id.desc()))
rows = list((await db.execute(stmt)).scalars())
return ok(data=[_to_out(r) for r in rows])
@router.post("")
async def create_script(body: ScriptBody, user: CurrentUser, db: DbSession):
err = validate_formula(body.formula)
if err:
return fail(f"公式错误: {err}")
s = PriceScript(company_id=user.company_id, **body.model_dump())
db.add(s)
await db.flush()
return ok(data=_to_out(s), message="脚本已创建")
@router.put("/{script_id}")
async def update_script(script_id: int, body: ScriptBody, user: CurrentUser, db: DbSession):
s = await db.get(PriceScript, script_id)
if s is None or s.company_id != user.company_id:
return fail("脚本不存在")
err = validate_formula(body.formula)
if err:
return fail(f"公式错误: {err}")
for k, v in body.model_dump().items():
setattr(s, k, v)
return ok(data=_to_out(s), message="脚本已更新")
@router.delete("/{script_id}")
async def delete_script(script_id: int, user: CurrentUser, db: DbSession):
s = await db.get(PriceScript, script_id)
if s is None or s.company_id != user.company_id:
return fail("脚本不存在")
await db.delete(s)
return ok(message="脚本已删除")
@router.post("/preview")
async def preview_script(body: ScriptPreviewBody, user: CurrentUser):
"""脚本试算(对齐目标站 product-test):以示例窗型变量求值公式。"""
length_of = {"frame": body.meters_frame, "mullion": body.meters_mullion,
"sash": body.meters_sash, "glass": body.glass_area,
"hardware": body.sashes, "total": 0}
vars_ = {"w": body.w, "h": body.h,
"area": round(body.w * body.h / 1_000_000, 3),
"perimeter": round((body.w + body.h) * 2 / 1000, 3),
"glass_area": body.glass_area, "sashes": body.sashes,
"meters_frame": body.meters_frame, "meters_mullion": body.meters_mullion,
"meters_sash": body.meters_sash, "length": length_of[body.target]}
try:
result = eval_formula(body.formula, vars_)
except FormulaError as e:
return fail(f"公式错误: {e}")
return ok(data={"result": round(result, 2), "vars": vars_})
def load_script_map(scripts: list[PriceScript]) -> dict:
"""启用脚本按 target 去重(同名 target 取最新一条),供报价使用。"""
mapping: dict = {}
for s in sorted(scripts, key=lambda x: x.id):
if s.enabled and s.formula:
mapping[s.target] = s.formula
return mapping