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
+2
View File
@@ -1,5 +1,7 @@
# CHANGELOG # 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-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) - [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)
+21 -1
View File
@@ -333,7 +333,6 @@
**响应式适配**:侧边栏 <900px 自动折叠为图标栏;<700px 主区边距收窄;表格列横向滚动。 **响应式适配**:侧边栏 <900px 自动折叠为图标栏;<700px 主区边距收窄;表格列横向滚动。
### 12.14 平台扩展模块(v1.0,`/api`) ### 12.14 平台扩展模块(v1.0,`/api`)
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 | | 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---| |---|---|---|---|---|
| 渲染设置 + AI 风格 | GET/POST | `/api/rendering-info` | `{wall_color, scene, glass_tint, ai_style, ai_time}` | 存公司参数;风格: modern/chinese/european/nordic,时间: day/dusk/night | | 渲染设置 + 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`(开发用,生产必须修改)。 管理员账号:`13800000000 / 123456`(开发用,生产必须修改)。
默认 SQLite(`HMC_DATABASE_URL` 可切 MySQL),JWT 密钥 `HMC_JWT_SECRET` 生产必须设置。 默认 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)。
+2
View File
@@ -6,6 +6,8 @@
<el-descriptions-item label="玻璃面积">{{ result.glass_area_m2 }} ㎡</el-descriptions-item> <el-descriptions-item label="玻璃面积">{{ result.glass_area_m2 }} ㎡</el-descriptions-item>
<el-descriptions-item label="总价"> <el-descriptions-item label="总价">
<span class="total">{{ result.total }} 元</span> <span class="total">{{ result.total }} 元</span>
<el-tag v-if="result.script_total || (result.scripted && result.scripted.length)" size="small"
type="warning" effect="plain" style="margin-left:6px">脚本计价</el-tag>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-table :data="result.rows" size="small" border style="margin-top:10px"> <el-table :data="result.rows" size="small" border style="margin-top:10px">
+108 -16
View File
@@ -1,5 +1,8 @@
<template> <template>
<el-card> <el-card>
<el-tabs v-model="tab">
<!-- ============ 产品价格本 ============ -->
<el-tab-pane label="产品价格本" name="products">
<div class="toolbar"> <div class="toolbar">
<el-input v-model="keyword" placeholder="名称/规格" clearable style="width:200px" @keyup.enter="load" /> <el-input v-model="keyword" placeholder="名称/规格" clearable style="width:200px" @keyup.enter="load" />
<el-select v-model="material" placeholder="全部类型" clearable style="width:130px" @change="load"> <el-select v-model="material" placeholder="全部类型" clearable style="width:130px" @change="load">
@@ -28,27 +31,61 @@
</el-table> </el-table>
<el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next" <el-pagination style="margin-top:12px; justify-content:flex-end" background layout="total, prev, pager, next"
:total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" /> :total="total" :page-size="page_size" v-model:current-page="page" @current-change="load" />
</el-tab-pane>
<el-dialog v-model="editVisible" :title="form.id ? '编辑产品' : '新增产品'" width="480px"> <!-- ============ 价格脚本 ============ -->
<el-form :model="form" label-width="70px"> <el-tab-pane label="价格脚本" name="scripts">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item> <div class="toolbar">
<el-form-item label="类型"> <span class="tip">按系列为各计价项配置公式,报价时启用即按脚本计价。变量:
<el-select v-model="form.material" style="width:100%"> <el-tag v-for="v in SCRIPT_VARS" :key="v" size="small" type="info" effect="plain" class="var-tag">{{ v }}</el-tag>
<el-option value="profile" label="型材" /><el-option value="glass" label="玻璃" /> </span>
<el-option value="hardware" label="五金" /><el-option value="addon" label="配件" /> <div style="flex:1" />
<el-button type="primary" plain @click="openScript()">新增脚本</el-button>
</div>
<el-table :data="scripts" v-loading="scriptLoading" stripe>
<el-table-column prop="name" label="系列/名称" min-width="130" />
<el-table-column label="作用项" width="90">
<template #default="{ row }">{{ targetLabel(row.target) }}</template>
</el-table-column>
<el-table-column prop="formula" label="公式" min-width="220" show-overflow-tooltip />
<el-table-column label="启用" width="80">
<template #default="{ row }"><el-switch :model-value="row.enabled === 1" @change="toggle(row, $event)" /></template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openScript(row)">编辑</el-button>
<el-button size="small" type="success" plain @click="test(row)">试算</el-button>
<el-button size="small" type="danger" plain @click="removeScript(row)">删</el-button>
</template>
</el-table-column>
</el-table>
<!-- 脚本编辑 -->
<el-dialog v-model="scriptVisible" :title="scriptForm.id ? '编辑脚本' : '新增脚本'" width="560px">
<el-form :model="scriptForm" label-width="80px">
<el-form-item label="系列名称"><el-input v-model="scriptForm.name" placeholder="如 断桥铝-55系列" /></el-form-item>
<el-form-item label="作用项">
<el-select v-model="scriptForm.target" style="width:100%">
<el-option v-for="(v, k) in TARGETS" :key="k" :value="k" :label="v" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="规格"><el-input v-model="form.spec" /></el-form-item> <el-form-item label="公式">
<el-form-item label="单位"><el-input v-model="form.unit" style="width:100px" /></el-form-item> <el-input v-model="scriptForm.formula" placeholder="如 (w+h)*2/1000*52" />
<el-form-item label="售价"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item> <div class="formula-help">结果 = 单价;total 项结果 = 总价。支持 + - * / 括号与 min/max/round/abs/ceil/floor/sqrt</div>
<el-form-item label="成本"><el-input-number v-model="form.cost" :min="0" :precision="2" /></el-form-item> </el-form-item>
<el-form-item label="备注"><el-input v-model="form.note" type="textarea" /></el-form-item> <el-form-item label="备注"><el-input v-model="scriptForm.note" /></el-form-item>
<el-form-item label="试算">
<el-button size="small" @click="test(scriptForm)">按示例窗型试算</el-button>
<el-tag v-if="testResult !== null" type="success" style="margin-left:8px">结果 ¥{{ testResult }}</el-tag>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="editVisible = false">取消</el-button> <el-button @click="scriptVisible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button> <el-button type="primary" @click="saveScript">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
</el-tab-pane>
</el-tabs>
</el-card> </el-card>
</template> </template>
@@ -57,6 +94,9 @@ import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api' import { api } from '../api'
const tab = ref('products')
// ---------- 产品价格本 ----------
const rows = ref<any[]>([]) const rows = ref<any[]>([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
@@ -99,9 +139,61 @@ async function remove(row: any) {
load() load()
} }
onMounted(load) // ---------- 价格脚本 ----------
const SCRIPT_VARS = ['w', 'h', 'area', 'perimeter', 'glass_area', 'sashes', 'meters_frame', 'meters_mullion', 'meters_sash', 'length']
const TARGETS: Record<string, string> = { frame: '外框', mullion: '中梃', sash: '扇框', glass: '玻璃', hardware: '五金', total: '总价直取' }
const targetLabel = (t: string) => TARGETS[t] ?? t
const scripts = ref<any[]>([])
const scriptLoading = ref(false)
const scriptVisible = ref(false)
const testResult = ref<number | null>(null)
const scriptForm = reactive<any>({ id: 0, name: '', target: 'frame', formula: '', note: '', enabled: 1 })
async function loadScripts() {
scriptLoading.value = true
try { scripts.value = (await api<any[]>('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<any>('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() })
</script> </script>
<style scoped> <style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 12px; } .toolbar { display: flex; gap: 10px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }
.tip { font-size: 12px; color: #909399; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.var-tag { font-family: Consolas, monospace; }
.formula-help { font-size: 12px; color: #909399; line-height: 1.5; margin-top: 4px; }
</style> </style>
+16 -4
View File
@@ -16,6 +16,8 @@ class QuoteRequest(BaseModel):
order_id: int | None = Field(default=None, description="或对已有订单报价") order_id: int | None = Field(default=None, description="或对已有订单报价")
prices: dict | None = Field(default=None, description="价格本覆盖 {frame,mullion,sash,glass,hardware}") prices: dict | None = Field(default=None, description="价格本覆盖 {frame,mullion,sash,glass,hardware}")
use_price_book: bool = Field(default=False, description="从产品管理价格本取价(按材料类别均价)") 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: 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("") @router.post("")
async def offer_price(body: QuoteRequest, user: CurrentUser, db: DbSession): async def offer_price(body: QuoteRequest, user: CurrentUser, db: DbSession):
"""算料报价:型材清单 + 玻璃面积 + 五金 + 总价。""" """算料报价:型材清单 + 玻璃面积 + 五金 + 总价(可选价格脚本计价)。"""
drawing = await _resolve_drawing(db, body) drawing = await _resolve_drawing(db, body)
if drawing is None: if drawing is None:
return fail("未找到图纸数据") return fail("未找到图纸数据")
prices = body.prices prices = body.prices
if prices is None and body.use_price_book: if prices is None and body.use_price_book:
prices = await _price_book(db, user.company_id) 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: try:
result = calc_quote(drawing, prices) result = calc_quote(drawing, prices, scripts)
except Exception: except Exception as e: # 公式/图纸解析错误 → 用户可读失败
return fail("图纸数据解析失败") msg = str(e) if "公式" in str(e) or "未知变量" in str(e) else "图纸数据解析失败"
return fail(msg)
return ok(data=result, message="报价成功") return ok(data=result, message="报价成功")
+2
View File
@@ -11,6 +11,7 @@ from huamenchuang.api import (
platform, platform,
quote, quote,
reports, reports,
script,
warehouse, warehouse,
windoor, windoor,
) )
@@ -23,6 +24,7 @@ api_router.include_router(contracts.router)
api_router.include_router(windoor.router) api_router.include_router(windoor.router)
api_router.include_router(optimize.router) api_router.include_router(optimize.router)
api_router.include_router(quote.router) api_router.include_router(quote.router)
api_router.include_router(script.router)
api_router.include_router(warehouse.router) api_router.include_router(warehouse.router)
api_router.include_router(business.router) api_router.include_router(business.router)
api_router.include_router(platform.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
+2
View File
@@ -3,6 +3,7 @@ from huamenchuang.models.announcement import Announcement
from huamenchuang.models.business import ( from huamenchuang.models.business import (
AfterSale, AfterSale,
MeasureOrder, MeasureOrder,
PriceScript,
Product, Product,
ProductCatelog, ProductCatelog,
Quotation, Quotation,
@@ -39,6 +40,7 @@ __all__ = [
"OrderLog", "OrderLog",
"OrderProcess", "OrderProcess",
"PayOrder", "PayOrder",
"PriceScript",
"Product", "Product",
"ProductCatelog", "ProductCatelog",
"Quotation", "Quotation",
+19
View File
@@ -7,6 +7,25 @@ from sqlalchemy.orm import Mapped, mapped_column
from huamenchuang.core.database import Base 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): class Product(Base):
"""产品价格本(型材/玻璃/五金/配件),供报价算料取价。""" """产品价格本(型材/玻璃/五金/配件),供报价算料取价。"""
+18
View File
@@ -99,6 +99,24 @@ async def seed() -> None:
await db.commit() await db.commit()
print("平台公告就绪") 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__": if __name__ == "__main__":
asyncio.run(seed()) asyncio.run(seed())
+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 glass_area_m2: float = 0.0
width_mm: int = 0 width_mm: int = 0
height_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: def _shoelace_area(pts: list[tuple[float, float]]) -> float:
@@ -105,6 +107,13 @@ def takeoff(dd) -> TakeoffResult:
if xs: if xs:
result.width_mm = round(max(xs) - min(xs)) result.width_mm = round(max(xs) - min(xs))
result.height_mm = round(max(ys) - min(ys)) 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 return result
@@ -120,37 +129,76 @@ def merge_bars(bars: list[BarItem]) -> list[dict]:
return sorted(merged.values(), key=lambda x: -x["length"]) 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: 元/扇} 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} p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180}
if prices: if prices:
p.update(prices) p.update(prices)
dd = parse_drawing(drawing) dd = parse_drawing(drawing)
t = takeoff(dd) 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 total += glass_amount
rows.append({"kind": "glass", "length": 0, "note": "玻璃", "qty": round(t.glass_area_m2, 3), 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( sash_count = sum(
1 for s in dd.get("sm", []) for sub in (s.get("sm") or []) 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") 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: if sash_count:
rows.append({"kind": "hardware", "length": 0, "note": "五金", "qty": 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 total += hw_amount
return { return {
@@ -158,5 +206,7 @@ def quote(drawing: str | dict, prices: dict | None = None) -> dict:
"glass_area_m2": round(t.glass_area_m2, 3), "glass_area_m2": round(t.glass_area_m2, 3),
"width_mm": t.width_mm, "width_mm": t.width_mm,
"height_mm": t.height_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()),
} }