feat: P0-1 价格脚本引擎(安全公式解析+脚本CRUD+试算+报价集成)
This commit is contained in:
@@ -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="报价成功")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user