feat: P0-1 价格脚本引擎(安全公式解析+脚本CRUD+试算+报价集成)
This commit is contained in:
@@ -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)
|
||||
|
||||
+21
-1
@@ -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)。
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<el-descriptions-item label="玻璃面积">{{ result.glass_area_m2 }} ㎡</el-descriptions-item>
|
||||
<el-descriptions-item label="总价">
|
||||
<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>
|
||||
<el-table :data="result.rows" size="small" border style="margin-top:10px">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<el-tabs v-model="tab">
|
||||
<!-- ============ 产品价格本 ============ -->
|
||||
<el-tab-pane label="产品价格本" name="products">
|
||||
<div class="toolbar">
|
||||
<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">
|
||||
@@ -28,27 +31,61 @@
|
||||
</el-table>
|
||||
<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" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-dialog v-model="editVisible" :title="form.id ? '编辑产品' : '新增产品'" width="480px">
|
||||
<el-form :model="form" label-width="70px">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="form.material" style="width:100%">
|
||||
<el-option value="profile" label="型材" /><el-option value="glass" label="玻璃" />
|
||||
<el-option value="hardware" label="五金" /><el-option value="addon" label="配件" />
|
||||
<!-- ============ 价格脚本 ============ -->
|
||||
<el-tab-pane label="价格脚本" name="scripts">
|
||||
<div class="toolbar">
|
||||
<span class="tip">按系列为各计价项配置公式,报价时启用即按脚本计价。变量:
|
||||
<el-tag v-for="v in SCRIPT_VARS" :key="v" size="small" type="info" effect="plain" class="var-tag">{{ v }}</el-tag>
|
||||
</span>
|
||||
<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-form-item>
|
||||
<el-form-item label="规格"><el-input v-model="form.spec" /></el-form-item>
|
||||
<el-form-item label="单位"><el-input v-model="form.unit" style="width:100px" /></el-form-item>
|
||||
<el-form-item label="售价"><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item>
|
||||
<el-form-item label="成本"><el-input-number v-model="form.cost" :min="0" :precision="2" /></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.formula" placeholder="如 (w+h)*2/1000*52" />
|
||||
<div class="formula-help">结果 = 单价;total 项结果 = 总价。支持 + - * / 括号与 min/max/round/abs/ceil/floor/sqrt</div>
|
||||
</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>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
<el-button @click="scriptVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveScript">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
@@ -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<any[]>([])
|
||||
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<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
"""产品价格本(型材/玻璃/五金/配件),供报价算料取价。"""
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user