diff --git a/CHANGELOG b/CHANGELOG index 27a9958..a30bb6a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-25 11:00] FEAT: v2.20 P0-1 价格脚本引擎。①services/formula.py 安全公式引擎(递归下降解析, 无 eval; 四则/括号/min/max/round/abs/ceil/floor/sqrt; ALLOWED_VARS 变量白名单+validate_formula 语法校验); ②PriceScript 模型(系列名/target/frame~total 六作用项/formula/enabled)+种子 5 条示例; ③/api/scripts CRUD+preview 试算(product-test 语义); ④offer-price 集成 use_scripts/scripts 参数, 行级 scripted 标记+script_total 直取; ⑤产品管理页拆双 Tab(价格本/脚本 CRUD+试算), 报价弹窗"脚本计价"标记; 前端 vue-tsc 0 错+构建通过 (Files: src/huamenchuang/services/formula.py, src/huamenchuang/models/business.py, src/huamenchuang/api/script.py, src/huamenchuang/api/quote.py, src/huamenchuang/services/quote_service.py, src/huamenchuang/scripts/seed.py, frontend/src/views/ProductsView.vue, frontend/src/components/QuoteDialog.vue, doc/API.md) + - [2026-09-25 00:45] DOCS: 源站功能差异审计——重新提取源站主包(app.cd1d79e5.js)完整路由表 118 条+内嵌中文词典 2058 键, 与 280 接口清单/本地实现三方交叉核对, 产出未复刻功能清单 50 项(P0×8/P1×18/P2×24)及补齐顺序建议 (Files: doc/未复刻功能清单.md) - [2026-09-23 09:00] FIX: v2.19 组合水平镜像的 3D 开向同步+放大层菜单可见性。①mirrorUnit 补铰链侧换向: hm.openDirection/sashes.openDirection left↔right(含 *_with_* 变体, up 不变)——原镜像后 3D 开合动画仍绕镜像前铰链侧(用户报"3D 不能水平镜像"的实质); 数学确认 mirPt+norm.x 翻转组合下重放树拓扑保持, 扇 polyId 无需换位; ②rotateUnit 补 hm.openDirection 刚体旋转换向(顺时针 left→up→right→down, *_with_* 两段分别映射); ③ctx-menu z-index 40→2100(放大预览层 z-2001 之上, 放大时右键菜单不再被盖); 验证: 真实浏览器镜像前后开合 50% 对比截图(镜像前左下扇绕左铰链外开→镜像后右下扇绕右铰链外开), 全量校验 0 失败 (Files: frontend/src/components/CombineCanvas.vue, shots/mirror-*.png) diff --git a/doc/API.md b/doc/API.md index 79ea43e..9d030b5 100644 --- a/doc/API.md +++ b/doc/API.md @@ -333,7 +333,6 @@ **响应式适配**:侧边栏 <900px 自动折叠为图标栏;<700px 主区边距收窄;表格列横向滚动。 ### 12.14 平台扩展模块(v1.0,`/api`) - | 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 | |---|---|---|---|---| | 渲染设置 + AI 风格 | GET/POST | `/api/rendering-info` | `{wall_color, scene, glass_tint, ai_style, ai_time}` | 存公司参数;风格: modern/chinese/european/nordic,时间: day/dusk/night | @@ -363,3 +362,24 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000 管理员账号:`13800000000 / 123456`(开发用,生产必须修改)。 默认 SQLite(`HMC_DATABASE_URL` 可切 MySQL),JWT 密钥 `HMC_JWT_SECRET` 生产必须设置。 + +### 12.15 价格脚本模块 `/api/scripts`(v2.20 新增,P0-报价真实化) + +对齐目标站 `script`/`product-script`/`product-test` 语义:按系列为各计价项配置**定价公式**,报价时启用即按脚本计价。 + +公式引擎:安全表达式(递归下降解析,无 eval)。支持 `+ - * / ( )` 与函数 `min/max/round/abs/ceil/floor/sqrt`。 + +变量表:`w`/`h`(窗宽高mm)、`area`(㎡)、`perimeter`(m)、`glass_area`(㎡)、`sashes`(扇)、`meters_frame`/`meters_mullion`/`meters_sash`(各类型材米数)、`length`(作用项对应量:型材=米数、玻璃=面积、五金=扇数)。 + +公式语义:结果 = 该作用项**单价**(元/米、元/㎡、元/扇);`target=total` 时结果直接作为**总价**。 + +| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 | +|---|---|---|---|---| +| 脚本列表 | GET | `/api/scripts` | — | 公司全部脚本 | +| 新增脚本 | POST | `/api/scripts` | `{name(系列名), target, formula, note?, enabled?}` | target ∈ frame/mullion/sash/glass/hardware/total;公式后端白名单+语法校验,非法返回 code=0 | +| 编辑脚本 | PUT | `/api/scripts/{id}` | 同新增 | | +| 删除脚本 | DELETE | `/api/scripts/{id}` | 路径 | | +| **脚本试算** | POST | `/api/scripts/preview` | `{formula, target?, w?, h?, glass_area?, sashes?, meters_*?}` | 对齐目标站 product-test;返回 `{result, vars}` | +| 报价启用脚本 | POST | `/api/offer-price` | 增加 `use_scripts: true` 或 `scripts: {target: formula}` | 响应加 `scripted[]`(被脚本覆盖的项)、`script_total`(总价是否被脚本直取);行级 `scripted` 标记 | + +种子示例脚本 5 条:断桥铝-55系列(frame/mullion/sash)、双层中空玻璃(glass)、标准五金(hardware)。 diff --git a/frontend/src/components/QuoteDialog.vue b/frontend/src/components/QuoteDialog.vue index dab86b4..18d932d 100644 --- a/frontend/src/components/QuoteDialog.vue +++ b/frontend/src/components/QuoteDialog.vue @@ -6,6 +6,8 @@ {{ result.glass_area_m2 }} ㎡ {{ result.total }} 元 + 脚本计价 diff --git a/frontend/src/views/ProductsView.vue b/frontend/src/views/ProductsView.vue index 6884550..06353c3 100644 --- a/frontend/src/views/ProductsView.vue +++ b/frontend/src/views/ProductsView.vue @@ -1,54 +1,91 @@ @@ -57,6 +94,9 @@ import { onMounted, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { api } from '../api' +const tab = ref('products') + +// ---------- 产品价格本 ---------- const rows = ref([]) const total = ref(0) const page = ref(1) @@ -99,9 +139,61 @@ async function remove(row: any) { load() } -onMounted(load) +// ---------- 价格脚本 ---------- +const SCRIPT_VARS = ['w', 'h', 'area', 'perimeter', 'glass_area', 'sashes', 'meters_frame', 'meters_mullion', 'meters_sash', 'length'] +const TARGETS: Record = { frame: '外框', mullion: '中梃', sash: '扇框', glass: '玻璃', hardware: '五金', total: '总价直取' } +const targetLabel = (t: string) => TARGETS[t] ?? t + +const scripts = ref([]) +const scriptLoading = ref(false) +const scriptVisible = ref(false) +const testResult = ref(null) +const scriptForm = reactive({ id: 0, name: '', target: 'frame', formula: '', note: '', enabled: 1 }) + +async function loadScripts() { + scriptLoading.value = true + try { scripts.value = (await api('get', '/api/scripts')) ?? [] } finally { scriptLoading.value = false } +} + +function openScript(row?: any) { + testResult.value = null + Object.assign(scriptForm, row ?? { id: 0, name: '', target: 'frame', formula: '', note: '', enabled: 1 }) + scriptVisible.value = true +} + +async function saveScript() { + if (scriptForm.id) await api('put', `/api/scripts/${scriptForm.id}`, scriptForm) + else await api('post', '/api/scripts', scriptForm) + ElMessage.success('已保存') + scriptVisible.value = false + loadScripts() +} + +async function toggle(row: any, v: any) { + await api('put', `/api/scripts/${row.id}`, { ...row, enabled: v ? 1 : 0 }) + row.enabled = v ? 1 : 0 +} + +async function removeScript(row: any) { + await ElMessageBox.confirm(`确定删除脚本「${row.name}」?`, '提示', { type: 'warning' }) + await api('delete', `/api/scripts/${row.id}`) + ElMessage.success('已删除') + loadScripts() +} + +async function test(row: any) { + const d = await api('post', '/api/scripts/preview', { formula: row.formula, target: row.target }) + testResult.value = d.result + if (!row.id) return + ElMessage.success(`试算结果 ¥${d.result}`) +} + +onMounted(() => { load(); loadScripts() }) diff --git a/src/huamenchuang/api/quote.py b/src/huamenchuang/api/quote.py index 73f9764..e5bb506 100644 --- a/src/huamenchuang/api/quote.py +++ b/src/huamenchuang/api/quote.py @@ -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="报价成功") diff --git a/src/huamenchuang/api/router.py b/src/huamenchuang/api/router.py index 31d9874..be9da98 100644 --- a/src/huamenchuang/api/router.py +++ b/src/huamenchuang/api/router.py @@ -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) diff --git a/src/huamenchuang/api/script.py b/src/huamenchuang/api/script.py new file mode 100644 index 0000000..d808a0e --- /dev/null +++ b/src/huamenchuang/api/script.py @@ -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 diff --git a/src/huamenchuang/models/__init__.py b/src/huamenchuang/models/__init__.py index 4130281..902f430 100644 --- a/src/huamenchuang/models/__init__.py +++ b/src/huamenchuang/models/__init__.py @@ -3,6 +3,7 @@ from huamenchuang.models.announcement import Announcement from huamenchuang.models.business import ( AfterSale, MeasureOrder, + PriceScript, Product, ProductCatelog, Quotation, @@ -39,6 +40,7 @@ __all__ = [ "OrderLog", "OrderProcess", "PayOrder", + "PriceScript", "Product", "ProductCatelog", "Quotation", diff --git a/src/huamenchuang/models/business.py b/src/huamenchuang/models/business.py index 20868fd..26e768d 100644 --- a/src/huamenchuang/models/business.py +++ b/src/huamenchuang/models/business.py @@ -7,6 +7,25 @@ from sqlalchemy.orm import Mapped, mapped_column from huamenchuang.core.database import Base +class PriceScript(Base): + """价格脚本(对齐目标站 script/windoorscript:按系列配置定价公式,formula 为安全表达式)。""" + + __tablename__ = "price_scripts" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + company_id: Mapped[int] = mapped_column(default=1, index=True) + name: Mapped[str] = mapped_column(String(64)) # 系列/脚本名(如"断桥铝-55系列") + # 公式作用项: frame外框/mullion中梃/sash扇框/glass玻璃/hardware五金/total总价直取 + target: Mapped[str] = mapped_column(String(16), default="frame", index=True) + formula: Mapped[str] = mapped_column(Text) # 如 "(w+h)*2/1000*length*48" + note: Mapped[str] = mapped_column(Text, default="") + enabled: Mapped[int] = mapped_column(default=1) + created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + ) + + class Product(Base): """产品价格本(型材/玻璃/五金/配件),供报价算料取价。""" diff --git a/src/huamenchuang/scripts/seed.py b/src/huamenchuang/scripts/seed.py index 7e2e147..b6eefac 100644 --- a/src/huamenchuang/scripts/seed.py +++ b/src/huamenchuang/scripts/seed.py @@ -99,6 +99,24 @@ async def seed() -> None: await db.commit() print("平台公告就绪") + # 6) 价格脚本示例(幂等:按名称去重) + async with SessionLocal() as db: + from huamenchuang.models.business import PriceScript + SAMPLE_SCRIPTS = [ + ("断桥铝-55系列", "frame", "(w+h)*2/1000*52"), + ("断桥铝-55系列", "mullion", "length*46"), + ("断桥铝-55系列", "sash", "length*58"), + ("双层中空玻璃", "glass", "max(110, area*135)"), + ("标准五金", "hardware", "sashes*195"), + ] + existing = {n for (n,) in (await db.execute(select(PriceScript.name))).all()} + for name, target, formula in SAMPLE_SCRIPTS: + if name not in existing: + db.add(PriceScript(name=name, target=target, formula=formula, + note="种子示例脚本,可在产品管理中修改")) + await db.commit() + print("价格脚本就绪") + if __name__ == "__main__": asyncio.run(seed()) diff --git a/src/huamenchuang/services/formula.py b/src/huamenchuang/services/formula.py new file mode 100644 index 0000000..45ff671 --- /dev/null +++ b/src/huamenchuang/services/formula.py @@ -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 diff --git a/src/huamenchuang/services/quote_service.py b/src/huamenchuang/services/quote_service.py index 7e4fa8a..22054b5 100644 --- a/src/huamenchuang/services/quote_service.py +++ b/src/huamenchuang/services/quote_service.py @@ -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()), }