Compare commits

..

No commits in common. "main" and "feat/modern-ui-pricing" have entirely different histories.

32 changed files with 725 additions and 1508 deletions

7
.gitignore vendored
View File

@ -1,7 +0,0 @@
__pycache__/
wwwroot/pricing_program/
wwwroot/pricing_program_timing/
*.pyc
*.pyo
*.egg-info/
.DS_Store

292
README.md
View File

@ -331,298 +331,6 @@ llmage (模型管理) ──→ llm.ppid ──→ pricing_program.id
--- ---
## HTTP API 接口
### 获取定价展示数据
```
GET /pricing/api/get_pricing_display.dspy?ppid={ppid}
Authorization: Bearer {token}
```
**说明:** 返回指定定价项目的可读定价数据,供前端展示。自动解析 pricing_data 字段,转换为人类可读的价格表。
**返回示例(Token 定价):**
```json
{
"status": "ok",
"data": {
"ppid": "5i1JIpqERgCWqKQ4DCegD",
"name": "通义千问 qwen3.7-max",
"pricing_type": "per_use",
"items": [
{
"filters": {"model": "qwen3.7-max"},
"filter_labels": {"模型": "qwen3.7-max"},
"price_factors": [
{
"factor": "uncache_tokens",
"label": "非缓存tokens",
"unit_price": 6.0,
"unit": "百万",
"unit_label": "元/百万"
}
]
},
{
"filters": {"model": "qwen3.7-max"},
"filter_labels": {"模型": "qwen3.7-max"},
"price_factors": [
{
"factor": "cached_tokens",
"label": "缓存tokens",
"unit_price": 1.2,
"unit": "百万",
"unit_label": "元/百万"
}
]
}
],
"display_text": "【通义千问 qwen3.7-max】定价:\n - 非缓存tokens: 6.0 元/百万\n - 缓存tokens: 1.2 元/百万"
}
}
```
**返回示例(视频生成定价):**
```json
{
"status": "ok",
"data": {
"ppid": "vidu_video_pricing",
"name": "viduq3视频定价",
"pricing_type": "per_use",
"items": [
{
"filters": {"model": "viduq3-turbo", "resolution": "1080p", "off_peak": "0"},
"filter_labels": {"模型": "viduq3-turbo", "分辨率": "1080p", "错峰": "0"},
"price_factors": [
{
"factor": "duration",
"label": "时长",
"unit_price": 0.56,
"unit": "秒",
"unit_label": "元/秒"
}
]
}
],
"display_text": "【viduq3视频定价】定价:\n - 时长: 0.56 元/秒 [model=viduq3-turbo, resolution=1080p, off_peak=0]"
}
}
```
**返回字段说明:**
- `ppid`: 定价项目 ID
- `name`: 定价项目名称
- `pricing_type`: 定价类型(`per_use` 按次计费)
- `items`: 定价项列表
- `filters`: 过滤条件(如适用模型、分辨率等),值为 YAML 原始值(供匹配用)
- `filter_labels`: 过滤条件的中文标签 → **美化后的展示值**(区间语法已转数学记号,见下)
- `price_factors`: 计价因子列表
- `factor`: 因子字段名
- `label`: 因子中文名称
- `unit_price`: 单位价格(已转换为展示价,无需再乘)
- `unit`: 单位(百万、秒、次等)
- `unit_label`: 单位标签(元/百万、元/秒等)
- `display_text`: 人类可读的价格表文本
**返回示例(分档/忙闲时定价,2026-09-09 起每档展开为独立 item):**
YAML 里 `filters` 列表中带 `unit_prices` 的条目是价格档位(tier),如 MiniMax-M3 按输入长度分档、
deepseek 按忙闲时分档。旧版把档位塞进 `price_factors[0].tiered` 嵌套且用
「`not k.endswith('_tokens')`」词匹配剥维度键,`prompt_tokens` 档位被误杀整条丢失
(只剩顶层兜底价,档位价从未展示)。现在每个档位展开为一个独立 item:
```json
{
"items": [
{
"filters": {"prompt_tokens": "0 =~= 512000"},
"filter_labels": {"prompt_tokens": "[0, 512000]"},
"price_factors": [
{"factor": "uncache_tokens", "label": "非缓存输入Token",
"unit_price": 2.1, "unit": "百万", "unit_label": "元/百万"}
]
},
{
"filters": {"prompt_tokens": "512000 ~= 1000000"},
"filter_labels": {"prompt_tokens": "(512000, 1000000]"},
"price_factors": [
{"factor": "uncache_tokens", "label": "非缓存输入Token",
"unit_price": 4.2, "unit": "百万", "unit_label": "元/百万"}
]
}
]
}
```
要点:
- **区间值美化**(`beautify_filter_value`,纯展示层):`filter_labels` 中 between 值转数学区间记号,
括号语义与计费引擎 `check_value` 完全一致——`=~`→`[lo, hi)`、`~`→`(lo, hi)`、
`~=`→`(lo, hi]`、`=~=`→`[lo, hi]`;`filters` 保留原始值不动(内部匹配用)。
`value_mode: in` 的 `'a b c'` → `'a / b / c'`。数字原样不换算。
- **机制键不泄漏**:`value_mode`、档位 `unit_prices` 是引擎机制键,绝不进 `filter_labels`
(旧版泄漏后被商店表格当表头显示)。
- **顶层兜底价不展示**:有档位的定价项,顶层 `unit_prices` 只是引擎在档位全不命中时的兜底
(计费路径 `get_pricing_from_ymalstr` 独立,不受展示层影响),展示它会出现一条无条件的
同价行让客户困惑,故只展开档位行。
---
## pricing_data 使用指南
### 两种格式
pricing_data 支持两种格式:
#### 1. 新格式(推荐):price_factors + unit_prices + unit
适用于简单定价场景,直接指定计价因子和单位价格。
**示例 1:Token 定价(文本生成)**
```yaml
unit_values:
百万: 1000000
fields:
model:
type: str
role: filter
label: 模型
uncache_tokens:
type: float
role: factor
label: 非缓存tokens
cached_tokens:
type: float
role: factor
label: 缓存tokens
completion_tokens:
type: float
role: factor
label: 输出tokens
pricings:
# 非缓存输入定价
- price_factors: uncache_tokens
unit_prices: 6.0
unit: 百万
filters:
- model: qwen3.7-max
# 缓存输入定价
- price_factors: cached_tokens
unit_prices: 1.2
unit: 百万
filters:
- model: qwen3.7-max
# 输出定价
- price_factors: completion_tokens
unit_prices: 18.0
unit: 百万
filters:
- model: qwen3.7-max
```
**示例 2:视频生成定价(多维度过滤)**
```yaml
unit_values:
次: 1
fields:
model:
type: str
role: filter
label: 模型
resolution:
type: str
role: filter
label: 分辨率
duration:
type: int
role: filter
label: 时长(秒)
value_mode: between
off_peak:
type: str
role: filter
label: 错峰执行
flat:
type: float
role: factor
label: 固定费用
pricings:
- price_factors: flat
unit_prices: 85.0
unit: 次
filters:
- model: viduq2-pro
- resolution: 1080p
- duration: '1'
- off_peak: false
- price_factors: flat
unit_prices: 43.0
unit: 次
filters:
- model: viduq2-pro
- resolution: 1080p
- duration: '1'
- off_peak: true
- price_factors: duration
unit_prices: 0.56
unit: 秒
filters:
- model: viduq3-turbo
- resolution: 1080p
- off_peak: 0
```
#### 2. 旧格式:formula(公式计算)
适用于复杂定价场景,使用 Python 表达式计算金额。
**示例:按 Token 数量计算**
```yaml
fields:
model:
type: str
role: filter
label: 模型
prompt_tokens:
type: float
role: factor
label: 输入tokens
completion_tokens:
type: float
role: factor
label: 输出tokens
pricings:
- model: gpt-4
formula: (3.2 * prompt_tokens + 16 * completion_tokens) / 1000000.0
- model: gpt-3.5
formula: (0.5 * prompt_tokens + 1.5 * completion_tokens) / 1000000.0
```
### 关键规则
1. **unit_values 必须定义**:在 pricing_data 顶部定义 `unit_values`,如 `百万: 1000000`,用于单位换算
2. **unit_prices 存储展示价**:新格式中 `unit_prices` 直接存储展示价格(如 `6.0` 表示 6.0 元/百万),不需要再乘以单位值
3. **filters 支持两种格式**:
- 列表格式:`filters: [{model: xxx}, {resolution: yyy}]`
- 字典格式:直接作为字段写在 pricing 条目中
4. **value_mode 默认精确匹配**:不指定时做 `=` 精确匹配,可指定 `between`、`in`、`>`、`<` 等
5. **formula 可引用任意字段**:旧格式中 formula 可引用 config_data 中的任意字段名
6. **多条件匹配**:如果多条定价规则都匹配,全部返回(不是只返回第一条)
---
## 关键设计要点 ## 关键设计要点
1. **YAML 驱动定价规则**:所有定价逻辑用 YAML 描述,支持字段定义、选项、匹配模式、计算公式 1. **YAML 驱动定价规则**:所有定价逻辑用 YAML 描述,支持字段定义、选项、匹配模式、计算公式

View File

@ -1,49 +0,0 @@
Add Error: Add Error
Add Success: Add Success
Authorization Error: Authorization Error
Cancel: Cancel
Conform: Confirm
Delete Error: Delete Error
Delete Success: Delete Success
Discard: Discard
Please login: Please login
Record no exist or with wrong ownership: Record no exist or with wrong ownership
Reset: Reset
Submit: Submit
Update Error: Update Error
Update Success: Update Success
Validation Failed: Validation Failed
failed: failed
id: id
ok: ok
ppid parameter required: ppid parameter required
上传定价数据: Upload Pricing Data
下载定价数据: Download Pricing Data
价格: Price
供应商: Supplier
供应商折扣: Supplier Discount
名称: Name
启用日期: Effective Date
失效日期: Expiry Date
定价属于: Pricing Belongs To
定价数据: Pricing Data
定价文件: Pricing File
定价时序数据没找到: Pricing time-series data not found
定价模版: Pricing Template
定价测试: Pricing Test
定价管理: Pricing Management
定价细项: Pricing Line Items
定价项目: Pricing Item
定价项目id: Pricing Item ID
定价项目时序: Pricing Item Time Series
所属机构: Organization
折扣不能为空: Discount cannot be empty
折扣不能大于1: Discount cannot be greater than 1
折扣不能小于0: Discount cannot be less than 0
折扣必须是数字: Discount must be a number
描述: Description
测试: Test
规格明细: Specification Details
计费数据(json): Billing Data (JSON)
项目名称: Project Name
验证定价: Validate Pricing

View File

@ -1,49 +0,0 @@
Add Error: 追加エラー
Add Success: 追加成功
Authorization Error: 認証エラー
Cancel: キャンセル
Conform: 確認
Delete Error: 削除エラー
Delete Success: 削除成功
Discard: 破棄
Please login: ログインしてください
Record no exist or with wrong ownership: レコードが存在しないか、所有権が不正です
Reset: リセット
Submit: 送信
Update Error: 更新エラー
Update Success: 更新成功
Validation Failed: バリデーション失敗
failed: 失敗
id: id
ok: ok
ppid parameter required: ppidパラメーターが必要です
上传定价数据: 価格データアップロード
下载定价数据: 価格データダウンロード
价格: 価格
供应商: サプライヤー
供应商折扣: サプライヤー割引
名称: 名称
启用日期: 有効開始日
失效日期: 有効期限
定价属于: 価格所属
定价数据: 価格データ
定价文件: 価格ファイル
定价时序数据没找到: 価格時系列データが見つかりません
定价模版: 価格テンプレート
定价测试: 価格テスト
定价管理: 価格管理
定价细项: 価格明細
定价项目: 価格項目
定价项目id: 価格項目ID
定价项目时序: 価格項目時系列
所属机构: 所属組織
折扣不能为空: 割引は必須です
折扣不能大于1: 割引は1より大きくできません
折扣不能小于0: 割引は0より小さくできません
折扣必须是数字: 割引は数値である必要があります
描述: 説明
测试: テスト
规格明细: 仕様明細
计费数据(json): 課金データ(JSON)
项目名称: プロジェクト名
验证定价: 価格検証

View File

@ -1,49 +0,0 @@
Add Error: 추가 오류
Add Success: 추가 성공
Authorization Error: 인증 오류
Cancel: 취소
Conform: 확인
Delete Error: 삭제 오류
Delete Success: 삭제 성공
Discard: 폐기
Please login: 로그인해주세요
Record no exist or with wrong ownership: 레코드가 존재하지 않거나 소유권이 잘못되었습니다
Reset: 초기화
Submit: 제출
Update Error: 업데이트 오류
Update Success: 업데이트 성공
Validation Failed: 유효성 검사 실패
failed: 실패
id: id
ok: ok
ppid parameter required: ppid 파라미터가 필요합니다
上传定价数据: 가격 데이터 업로드
下载定价数据: 가격 데이터 다운로드
价格: 가격
供应商: 공급업체
供应商折扣: 공급업체 할인
名称: 이름
启用日期: 유효 시작일
失效日期: 만료일
定价属于: 가격 소속
定价数据: 가격 데이터
定价文件: 가격 파일
定价时序数据没找到: 가격 시계열 데이터를 찾을 수 없습니다
定价模版: 가격 템플릿
定价测试: 가격 테스트
定价管理: 가격 관리
定价细项: 가격 세부 항목
定价项目: 가격 항목
定价项目id: 가격 항목 ID
定价项目时序: 가격 항목 시계열
所属机构: 소속 조직
折扣不能为空: 할인은 비워둘 수 없습니다
折扣不能大于1: 할인은 1보다 클 수 없습니다
折扣不能小于0: 할인은 0보다 작을 수 없습니다
折扣必须是数字: 할인은 숫자여야 합니다
描述: 설명
测试: 테스트
规格明细: 규격 상세
计费数据(json): 과금 데이터(JSON)
项目名称: 프로젝트 이름
验证定价: 가격 검증

View File

@ -1,49 +0,0 @@
Add Error: Add Error
Add Success: Add Success
Authorization Error: Authorization Error
Cancel: Cancel
Conform: Conform
Delete Error: Delete Error
Delete Success: Delete Success
Discard: Discard
Please login: Please login
Record no exist or with wrong ownership: Record no exist or with wrong ownership
Reset: Reset
Submit: Submit
Update Error: Update Error
Update Success: Update Success
Validation Failed: Validation Failed
failed: failed
id: id
ok: ok
ppid parameter required: ppid parameter required
上传定价数据: 上传定价数据
下载定价数据: 下载定价数据
价格: 价格
供应商: 供应商
供应商折扣: 供应商折扣
名称: 名称
启用日期: 启用日期
失效日期: 失效日期
定价属于: 定价属于
定价数据: 定价数据
定价文件: 定价文件
定价时序数据没找到: 定价时序数据没找到
定价模版: 定价模版
定价测试: 定价测试
定价管理: 定价管理
定价细项: 定价细项
定价项目: 定价项目
定价项目id: 定价项目id
定价项目时序: 定价项目时序
所属机构: 所属机构
折扣不能为空: 折扣不能为空
折扣不能大于1: 折扣不能大于1
折扣不能小于0: 折扣不能小于0
折扣必须是数字: 折扣必须是数字
描述: 描述
测试: 测试
规格明细: 规格明细
计费数据(json): 计费数据(json)
项目名称: 项目名称
验证定价: 验证定价

View File

@ -2,27 +2,18 @@
"tblname": "pricing_program", "tblname": "pricing_program",
"title": "定价项目", "title": "定价项目",
"params": { "params": {
"editable": {
"get_data_url": "{{entire_url('get_pricing_program.dspy')}}"
},
"sortby": "name", "sortby": "name",
"logined_userorgid": "ownerid",
"browserfields": { "browserfields": {
"exclouded": ["id", "ownerid"], "exclouded": ["id", "ownerid", "pricing_spec" ],
"alters": { "alters": {
"providerid": { "providerid":{
"uitype": "code", "valueField": "id",
"dataurl": "{{entire_url('../api/get_search_providerid.dspy')}}", "textField": "orgname",
"valueField": "providerid", "dataurl":"{{entire_url('/rbac/get_provider.dspy')}}"
"textField": "providerid_text"
} }
} }
}, },
"data_filter": {
"AND": [
{"field": "name", "op": "LIKE", "var": "name"},
{"field": "providerid", "op": "LIKE", "var": "providerid"}
]
},
"editexclouded": [ "editexclouded": [
"id", "ownerid" "id", "ownerid"
], ],
@ -54,8 +45,7 @@
"width": "70%", "width": "70%",
"height": "70%", "height": "70%",
"auto_open": true, "auto_open": true,
"archor": "cc", "archor": "cc"
"title": "定价测试"
}, },
"options": { "options": {
"url": "{{entire_url('../test_pricing_program.ui')}}", "url": "{{entire_url('../test_pricing_program.ui')}}",

View File

@ -2,16 +2,100 @@
"tblname": "pricing_program_timing", "tblname": "pricing_program_timing",
"title": "定价项目时序", "title": "定价项目时序",
"params": { "params": {
"editable": { "logined_userorgid": "ownerid",
"get_data_url": "{{entire_url('get_pricing_program_timing.dspy')}}",
"new_data_url": "{{entire_url('../api/add_pricing_program_timing.dspy')}}"
},
"sortby": "enabled_date desc",
"browserfields": { "browserfields": {
"exclouded": ["id", "ppid", "pricing_data"] "exclouded": ["id", "ownerid", "ppid" ],
"alters": {}
}, },
"editexclouded": [ "editexclouded": [
"id", "ppid" "id", "ownerid", "ppid", "name"
],
"subtables":[
{
"field": "pptid",
"title": "定价细项",
"icon": "{{entire_url('/pricing/imgs/pricing_item.svg')}}",
"subtable": "pricing_item"
}
],
"toolbar":{
"tools":[
{
"name": "download_pattern",
"label": "定价模版",
"selected_row": true,
"icon": "{{entire_url('/bricks/imgs/download.svg')}}"
},
{
"name": "upload_pricing_data",
"label": "上传定价数据",
"icon": "{{entire_url('/bricks/imgs/upload.svg')}}"
}, {
"name": "download_pricing_data",
"label": "下载定价数据",
"icon": "{{entire_url('/bricks/imgs/download.svg')}}"
}, {
"name": "test",
"label": "验证定价",
"icon": "{{entire_url('/bricks/imgs/test.svg')}}"
}
]
},
"binds": [
{
"wid": "self",
"event": "download_pattern",
"actiontype": "newwindow",
"target": "self",
"options":{
"params": {
"ppid": "{{params_kw.ppid}}"
},
"method": "POST",
"url": "{{entire_url('../download_pricing_pattern.dspy')}}"
}
},
{
"wid": "self",
"event": "upload_pricing_data",
"actiontype": "urlwidget",
"target": "self",
"options": {
"params": {
"ppid": "{{params_kw.ppid}}"
},
"method": "POST",
"url": "{{entire_url('../load_pricing_data.ui')}}"
}
},
{
"wid": "self",
"event": "download_pricing_data",
"actiontype": "newwindow",
"target": "self",
"options": {
"params": {
"id": "{{params_kw.id}}"
},
"method": "POST",
"url": "{{entire_url('../download_pricing_data.dspy')}}"
}
},
{
"wid": "self",
"event": "test",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
},
"options":{
"params": {
"id": "{{params_kw.id}}"
},
"method": "POST",
"url": "{{entire_url('../pricing_test.ui')}}"
}
}
] ]
} }
} }

View File

@ -40,17 +40,21 @@
"type": "str", "type": "str",
"length": 32 "length": 32
}, },
{
"name": "discount",
"title": "供应商折扣",
"type": "float",
"length": 18
},
{ {
"name": "description", "name": "description",
"title": "描述", "title": "描述",
"type": "text" "type": "text"
}, },
{ {
"name": "currency", "name": "pricing_spec",
"title": "结算币种", "title": "规格明细",
"type": "str", "type": "text"
"length": 3,
"default": "CNY"
} }
], ],
"codes": [ "codes": [
@ -62,9 +66,9 @@
}, },
{ {
"field": "providerid", "field": "providerid",
"table": "suppliers", "table": "organization",
"valuefield": "id", "valuefield": "id",
"textfield": "supplier_name" "textfield": "orgname"
}, },
{ {
"field": "pricing_belong", "field": "pricing_belong",
@ -72,12 +76,6 @@
"valuefield": "k", "valuefield": "k",
"textfield": "v", "textfield": "v",
"cond": "parentid='pricing_belong'" "cond": "parentid='pricing_belong'"
},
{
"field": "currency",
"table": "currency",
"valuefield": "id",
"textfield": "name"
} }
] ]
} }

View File

@ -1,7 +0,0 @@
from pricing.pricing import (
PricingProgram,
test_pricing,
generate_formula_from_factors,
get_pricing_display,
add_pricing_program_timing,
)

View File

@ -3,10 +3,7 @@ from sqlor.dbpools import DBPools
from pricing.pricing import ( from pricing.pricing import (
PricingProgram, PricingProgram,
test_pricing, test_pricing,
get_pricing_program_timeing, get_pricing_program
add_pricing_program_timing,
generate_formula_from_factors,
get_pricing_display,
) )
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
@ -30,21 +27,14 @@ def _bind_pricing_events(dbpools, dbname):
def load_pricing(): def load_pricing():
env = ServerEnv() env = ServerEnv()
env.get_pricing_program = get_pricing_program
env.write_pricing_patten = PricingProgram.write_pricing_patten env.write_pricing_patten = PricingProgram.write_pricing_patten
env.write_pricing_data = PricingProgram.write_pricing_data env.write_pricing_data = PricingProgram.write_pricing_data
env.pricing_program_charging = PricingProgram.charging env.pricing_program_charging = PricingProgram.charging
env.buffered_charging = PricingProgram.buffered_charging env.buffered_charging = PricingProgram.buffered_charging
env.get_ppid_pricing = PricingProgram.get_ppid_pricing
env.load_pricing_data = PricingProgram.load_pricing_data env.load_pricing_data = PricingProgram.load_pricing_data
env.get_pricing_program = PricingProgram.get_pricing_program env.get_pricing_program = PricingProgram.get_pricing_program
env.test_pricing = test_pricing env.test_pricing = test_pricing
env.get_pricing_program_timeing = get_pricing_program_timeing
env.add_pricing_program_timing = add_pricing_program_timing
env.generate_formula_from_factors = generate_formula_from_factors
env.get_pricing_display = get_pricing_display
# Bind hot_reload event — only when running in ahserver (event_dispatcher available)
if getattr(env, 'event_dispatcher', None) is not None:
env.event_dispatcher.bind('hot_reload', PricingProgram.on_hot_reload)
dbpools = DBPools() dbpools = DBPools()
dbname = env.get_module_dbname('pricing') dbname = env.get_module_dbname('pricing')
if dbname: if dbname:
@ -52,3 +42,4 @@ def load_pricing():
debug(f'Pricing event listeners bound for database: {dbname}') debug(f'Pricing event listeners bound for database: {dbname}')
else: else:
debug('Pricing event listeners skipped: no database configured for pricing module') debug('Pricing event listeners skipped: no database configured for pricing module')

View File

@ -1,33 +1,17 @@
import json import json
import re
import yaml import yaml
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
from ahserver.filestorage import FileStorage from ahserver.filestorage import FileStorage
from sqlor.dbpools import DBPools, get_sor_context from sqlor.dbpools import DBPools, get_sor_context
from appPublic.log import debug, exception, info, MyLogger from appPublic.log import debug, exception, info, MyLogger
from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString from appPublic.timeUtils import curDateString
from appPublic.share_cache import cache_get
from appPublic.dictObject import DictObject from appPublic.dictObject import DictObject
from appPublic.jsonConfig import getConfig
from .write_pattern import write_pattern_xlsx, load_xlsx_pricing from .write_pattern import write_pattern_xlsx, load_xlsx_pricing
import yaml import yaml
def _cache_enabled():
"""Check if cache is enabled for pricing module in config.json"""
try:
config = getConfig()
module_cache = config.module_cache
if module_cache is None:
return True
return getattr(module_cache, 'pricing', True)
except Exception:
return True
""" """
采用yaml描述定价策略, 采用yaml描述定价策略,
在pricing_program_timing的pricing_data中定义定价的数据字段(YAML格式) 在pricing_program的pricing_spec表中定义定价的数据字段
遵循一下规格: 遵循一下规格:
字典结构,key是字段名,需定义字段的type(类型),label(标题),value_mode(值方式)(可选),options(可选项)(可选) 字典结构,key是字段名,需定义字段的type(类型),label(标题),value_mode(值方式)(可选),options(可选项)(可选)
必须要有一个price字段,其type: float 必须要有一个price字段,其type: float
@ -81,7 +65,7 @@ price:
''' '''
pricing_program_timing表中的pricing_data字段的数据是一个只有一个属性"pricings"的字典 pricing_program_timing表中的pricing_data字段的数据是一个只有一个属性"pricings"的字典
其值为定价条目列表,每个定价条目是个字典,key值为pricing_data中fields定义的字段, 其值为定价条目列表,每个定价条目是个字典,key值为pricing_spec字段定义的字段,
pricings: pricings:
- resolution: 480p - resolution: 480p
duration: 4 duration: 4
@ -131,94 +115,8 @@ def typevalue(v, t):
return v return v
return f(v) return f(v)
# ── 忙闲时时段解析(2026-09-10)── def check_value(field, spec_value, data_value):
# peak_times 串语法:逗号分隔的时段,每段 "H:MM =~= H:MM"(含两端)或 if field.value_mode == 'between':
# "H:MM =~ H:MM"(下含上开);跨午夜段写成两段(如 22:00 =~ 24:00,0:00 =~ 8:00)。
# 比较单位是小时(浮点),与出账注入的 hour(int 小时数)对齐:
# 9:00 =~= 12:00 覆盖 hour∈{9,10,11,12}(12点整落在忙时内)。
_PEAK_SEG_RE = None
def _parse_hhmm(s):
s = (s or '').strip()
if ':' in s:
h, m = s.split(':', 1)
return float(h) + float(m) / 60.0
return float(s)
def _in_peak_times(peak_times, hour):
"""hour(0-23 的整数小时或浮点小时)是否落在 peak_times 任一时段内。
hour 为 None(上游未注入调用时刻)→ 返回 False(按非忙时兜底,不炸计费)。
时段串解析失败 → False 并记异常日志(宁可走闲时价也不抛异常中断出账)。
"""
if hour is None:
return False
try:
hv = float(hour)
except (TypeError, ValueError):
return False
for seg in str(peak_times).split(','):
seg = seg.strip()
if not seg:
continue
m = re.match(r'^([^~]+?)(=~=|=~|~=|~)([^~]+)$', seg)
if not m:
exception(f'peak_times 段解析失败: {seg}')
continue
lo = _parse_hhmm(m.group(1))
hi = _parse_hhmm(m.group(3))
op = m.group(2)
if op == '=~=':
ok = lo <= hv <= hi
elif op == '=~':
ok = lo <= hv < hi
elif op == '~=':
ok = lo < hv <= hi
else:
ok = lo < hv < hi
if ok:
return True
return False
# ── 展示层:filter/tier 维度值可读化(2026-09-09,仅展示不参与计费比较)──
# 区间操作符 → 数学区间记号,语义与 check_value 的 between 分支一一对应
_INTERVAL_BRACKETS = {
'=~': ('[', ')'), # lo <= v < hi
'~': ('(', ')'), # lo < v < hi
'~=': ('(', ']'), # lo < v <= hi
'=~=': ('[', ']'), # lo <= v <= hi
}
def beautify_filter_value(v, fdef=None, value_mode=None):
"""把 filter/tier 维度值渲染成可读文本(纯展示,绝不参与计费比较)。
- bool(is_peak 等计算字段):True/False → 是/否
- value_mode=between:原始 '0 =~= 512000' → '[0, 512000]',
'8 =~ 22' → '[8, 22)',操作符语义对齐 check_value,无歧义
- value_mode=in:'a b c' → 'a / b / c'
- 其余:原样返回(如 resolution=1K)
数字保持原值(不转「万」、不加千分位)——与实际比较值一字不差。
"""
if isinstance(v, bool):
return '是' if v else '否'
vm = value_mode
if vm is None and isinstance(fdef, dict):
vm = fdef.get('value_mode')
s = str(v).strip()
if vm == 'between':
arr = s.split()
if len(arr) == 3 and arr[1] in _INTERVAL_BRACKETS:
lb, rb = _INTERVAL_BRACKETS[arr[1]]
return '%s%s, %s%s' % (lb, arr[0], arr[2], rb)
return s
if vm == 'in':
return ' / '.join(s.split())
return v
def check_value(field, spec_value, data_value, value_mode=None):
if value_mode is None:
value_mode = field.value_mode
if value_mode == 'between':
arr = spec_value.strip().split() arr = spec_value.strip().split()
if len(arr) < 2 or len(arr) > 3: if len(arr) < 2 or len(arr) > 3:
e = f'{spec_value=} error' e = f'{spec_value=} error'
@ -244,19 +142,17 @@ def check_value(field, spec_value, data_value, value_mode=None):
return arr[0] < fvalue and fvalue < arr[-1] return arr[0] < fvalue and fvalue < arr[-1]
if arr[1] == '~=': if arr[1] == '~=':
return arr[0] < fvalue and fvalue <= arr[-1] return arr[0] < fvalue and fvalue <= arr[-1]
if arr[1] == '=~=': e = f'{arr[1]}不认识的期间逻辑,只支持:~ =~ ~='
return arr[0] <= fvalue and fvalue <= arr[-1]
e = f'{arr[1]}不认识的期间逻辑,只支持:~ =~ ~= =~='
exception(e) exception(e)
raise Exception(e) raise Exception(e)
if value_mode == 'in': if field.value_mode == 'in':
arr = spec_value.strip().split() arr = spec_value.strip().split()
arr = [ typevalue(a, field.type) for a in arr ] arr = [ typevalue(a, field.type) for a in arr ]
# debug(f'{arr=}, {data_value=}') # debug(f'{arr=}, {data_value=}')
return data_value in arr return data_value in arr
mode = value_mode mode = field.value_mode
if not mode or mode == '=': if not mode or mode == '=':
mode = '==' mode = '=='
ns = { ns = {
@ -275,15 +171,7 @@ def data_mapping(ns, name, v):
return v return v
class PricingProgram: class PricingProgram:
pass # pricing_data moved to Redis share_cache pricing_data = {}
@staticmethod
def on_hot_reload(data=None):
"""Event handler for hot_reload event. Clears pricing cache."""
from appPublic.log import debug
debug(f'[pricing] on_hot_reload called, clearing pricing_data (data={data})')
from appPublic.share_cache import cache_invalidate
pass # share_cache TTL handles expiry
@staticmethod @staticmethod
async def get_pricing_program(ppid): async def get_pricing_program(ppid):
@ -312,7 +200,7 @@ class PricingProgram:
e = f'pricing_program({pptid}) can not find pricing_program' e = f'pricing_program({pptid}) can not find pricing_program'
exception(f) exception(f)
pp = pps[0] pp = pps[0]
pp_spec = {} # pricing_spec removed pp_spec = yaml.safe_load(pp.pricing_spec)
formula = None formula = None
if pp_spec.get('fields'): if pp_spec.get('fields'):
fields = pp_spec.get('fields') fields = pp_spec.get('fields')
@ -354,7 +242,7 @@ class PricingProgram:
if not recs: if not recs:
debug(f'id={ppid} pricing_program not found') debug(f'id={ppid} pricing_program not found')
r = recs[0] r = recs[0]
x = DictObject() # pricing_spec removed x = DictObject(** yaml.safe_load(r.pricing_spec))
fields = x fields = x
if x.get('fields'): if x.get('fields'):
fields = x['fields'] fields = x['fields']
@ -380,6 +268,24 @@ class PricingProgram:
fpath = write_pattern_xlsx(recs[0].name, fields, data=data) fpath = write_pattern_xlsx(recs[0].name, fields, data=data)
return fpath return fpath
@staticmethod
def pp_db2app(pp):
try:
pp.pricing_spec = yaml.safe_load(pp.pricing_spec)
except Exception as e:
e = f'{pp.pricing_spec}:yaml数据格式错误'
exception(e)
raise Exception(e)
@staticmethod
def pp_app2db(pp):
try:
pp.pricing_spec = yaml.dump(pp.pricing_spec, allow_unicode=True)
except Exception as e:
e = f'{pp.pricing_spec}:导出到yaml失败'
exception(e)
raise Exception(e)
@staticmethod @staticmethod
def ppt_db2app(ppt): def ppt_db2app(ppt):
try: try:
@ -518,13 +424,13 @@ class PricingProgram:
async def get_ppid_pricing(ppid): async def get_ppid_pricing(ppid):
dat = curDateString() dat = curDateString()
k = f'{ppid}.{dat}' k = f'{ppid}.{dat}'
d = await cache_get('pricing', k, ttl=300) d = PricingProgram.pricing_data.get(k)
if d: if d:
return d return d
env = ServerEnv() env = ServerEnv()
async with get_sor_context(env, 'pricing') as sor: async with get_sor_context(env, 'pricing') as sor:
sql = """select a.name, a.ownerid, a.providerid, a.currency, sql = """select a.name, a.ownerid, a.providerid,
pricing_belong, b.pricing_data pricing_belong, discount, b.pricing_data
from pricing_program a, pricing_program_timing b from pricing_program a, pricing_program_timing b
where a.id = b.ppid where a.id = b.ppid
and a.id = ${ppid}$ and a.id = ${ppid}$
@ -540,24 +446,25 @@ class PricingProgram:
exception(f'{e}') exception(f'{e}')
raise e raise e
d = recs[0] d = recs[0]
from appPublic.share_cache import cache_set PricingProgram.pricing_data[k] = d
await cache_set('pricing', k, d, ttl=300) dates = PricingProgram.pricing_data.get(ppid, [])
dates.append(dat)
dates_cnt = len(dates)
if dates_cnt > 2:
for i in range(dates_cnt - 2):
dk = f'{ppid}.{dates[i]}'
PricingProgram.pricing_data[dk]
dates = dates[-2:]
PricingProgram.pricing_data[ppid] = dates
return d return d
async def buffered_charging(ppid, data): async def buffered_charging(ppid, data):
r = await PricingProgram.get_ppid_pricing(ppid) r = await PricingProgram.get_ppid_pricing(ppid)
# get_ppid_pricing 缓存命中时返回 dict(Redis JSON 反序列化), prices = PricingProgram.get_pricing_from_ymalstr(data,
# 未命中时返回 DictObject(sqlor 行对象),两者都要兼容。 r.pricing_data)
if hasattr(r, 'pricing_data'):
pricing_data = r.pricing_data
elif isinstance(r, dict):
pricing_data = r.get('pricing_data')
else:
pricing_data = r['pricing_data']
prices = PricingProgram.get_pricing_from_ymalstr(data, pricing_data)
amt = 0.0 amt = 0.0
for p in prices: for p in prices:
p.cost = p.amount # discount removed p.cost = p.amount * r.discount
return prices return prices
async def charging(sor, ppid, data): async def charging(sor, ppid, data):
@ -572,7 +479,7 @@ class PricingProgram:
env = ServerEnv() env = ServerEnv()
biz_date = await env.get_business_date(sor) biz_date = await env.get_business_date(sor)
sql = """select a.name, a.ownerid, a.providerid, sql = """select a.name, a.ownerid, a.providerid,
pricing_belong, b.pricing_data pricing_belong, discount, b.pricing_data
from pricing_program a, pricing_program_timing b from pricing_program a, pricing_program_timing b
where a.id = b.ppid where a.id = b.ppid
and a.id = ${ppid}$ and a.id = ${ppid}$
@ -590,26 +497,19 @@ order by b.enabled_date desc"""
debug(f'{r.prices=}') debug(f'{r.prices=}')
amt = 0.0 amt = 0.0
for p in r.prices: for p in r.prices:
p.cost = p.amount # discount removed p.cost = p.amount * r.discount
return r.prices return r.prices
@staticmethod @staticmethod
def get_pricing_from_ymalstr(config_data, yamlstr): def get_pricing_from_ymalstr(config_data, yamlstr):
""" """
解析定价YAML并计算费用。 yamlstr是从
支持两种格式:
1. 旧格式:formula 字段(eval计算)
2. 新格式:price_factors + unit_prices + unit(自动计算)
支持 derived 字段:在 fields 中定义 derived 表达式,从原始 usage 数据计算衍生字段
例如:uncached_prompt_tokens.derived = "prompt_tokens - prompt_tokens_details.cached_tokens"
""" """
if config_data is None: if config_data is None:
e = Exception(f'config_data is None, {yamlstr=}') e = Exception(f'config_data is None, {yamlstr=}')
exception(f'{e=}') exception(f'{e=}')
raise e raise e
# 用 DictObject 包装,支持 dot notation 属性访问(derived 表达式依赖此特性) config_data = config_data.copy()
config_data = DictObject(**config_data)
d = None d = None
try: try:
d = yaml.safe_load(yamlstr) d = yaml.safe_load(yamlstr)
@ -623,75 +523,19 @@ order by b.enabled_date desc"""
if not d.pricings: if not d.pricings:
exception(f'{d} has not "pricings"') exception(f'{d} has not "pricings"')
raise Exception(f'定价定义中没有pricing数据') raise Exception(f'定价定义中没有pricing数据')
# 处理 derived 字段:从原始 usage 数据计算衍生字段
# DictObject 支持属性访问嵌套字段,如 prompt_tokens_details.cached_tokens
for field_name, field_def in d.fields.items():
if not isinstance(field_def, dict):
continue
# 忙闲时计算字段(2026-09-10):fields 里声明 peak_times 时段串,
# 引擎按调用时刻(usage 的 hour 键,出账侧从 llm_usage.created_at 注入)
# 计算 is_peak 布尔值,pricings 用 is_peak: true/false 作定价要素。
# 例:peak_times: "9:00 =~= 12:00,14:00 =~= 18:00"
# is_peak: {type: bool, role: filter, label: 忙时, derived: peak_times}
# hour 缺失(老流水/上游未注入)时按非忙时兜底,不炸计费。
peak_times = field_def.get('peak_times')
if peak_times:
config_data[field_name] = _in_peak_times(
peak_times, config_data.get('hour'))
debug(f'peak_times field {field_name} = {config_data[field_name]} '
f'(hour={config_data.get("hour")}, peak_times={peak_times})')
continue
derived_expr = field_def.get('derived')
if not derived_expr:
continue
# eval 环境:用 config_data 本身(DictObject 支持 dot notation 属性访问)
eval_env = dict(config_data)
# 将顶层 key 也注入 eval 环境,使 eval 能找到嵌套属性
for k in list(config_data.keys()):
eval_env[k] = config_data[k]
try:
result = eval(derived_expr, {}, eval_env)
config_data[field_name] = result
debug(f'derived field {field_name} = {derived_expr} = {result}')
except Exception as e:
debug(f'derived field {field_name} evaluation failed: {derived_expr}, error: {e}')
config_data[field_name] = 0
# 单位映射表
unit_values = d.get('unit_values', {'百万': 1000000, '秒': 1, '千': 1000, '次': 1, '张': 1, '毫秒': 0.001, '元/百万tokens': 1000000, '元/total_tokens': 1, '元/times': 1})
ret_items = [] ret_items = []
for i, p in enumerate(d.pricings): for i, p in enumerate(d.pricings):
# 跳过需要人工审核的记录 if not p.formula:
if p.get('_NEEDS_MANUAL_REVIEW'): debug(f'无公式:{p=}')
debug(f'跳过需要人工审核的定价项: {i}')
continue continue
# 判断是旧格式还是新格式
# 新格式要求 price_factors 是标量(str)、unit_prices 是标量(number)
# 生产数据中 price_factors 可能是 list、unit_prices 可能是 dict,此时走旧格式 formula
_raw_pf = p.get('price_factors')
_raw_up = p.get('unit_prices')
is_new_format = (_raw_pf is not None and _raw_up is not None
and not isinstance(_raw_pf, list) and not isinstance(_raw_up, dict))
is_old_format = p.get('formula') is not None
if not is_new_format and not is_old_format:
debug(f'无公式也无price_factors:{p=}')
continue
p_ok = True p_ok = True
times = 1
unit = 1
ns = DictObject(**config_data) ns = DictObject(**config_data)
for k,spec_value in p.items():
# 检查过滤条件(排除定价计算字段)
skip_keys = {'formula', 'price_factors', 'unit_prices', 'unit', 'min_amount', 'filters', 'pricing_type'}
for k, spec_value in p.items():
if spec_value is None: if spec_value is None:
continue continue
if k in skip_keys: if k == 'formula':
continue continue
f = d.fields.get(k) f = d.fields.get(k)
if not f: if not f:
@ -699,6 +543,8 @@ order by b.enabled_date desc"""
exception(f'{e}') exception(f'{e}')
raise Exception(e) raise Exception(e)
data_value = config_data.get(k) data_value = config_data.get(k)
# p[f'old_{k}'] = data_value
# p[f'mapping_{k}'] = data_mapping(d, k, data_value) #需要mapping的数据转换
data_value = data_mapping(d, k, data_value) data_value = data_mapping(d, k, data_value)
if data_value is None: if data_value is None:
if 'default' in f.keys(): if 'default' in f.keys():
@ -710,420 +556,48 @@ order by b.enabled_date desc"""
try: try:
flg = check_value(f, spec_value, data_value) flg = check_value(f, spec_value, data_value)
if not flg: if not flg:
# 条件不满足
# debug(f'条件不满足:{p=},{spec_value=}, {data_value=}, {k=}')
p_ok = False p_ok = False
break break
except Exception as e: except Exception as e:
msg = f'{p=},{f}: {spec_value=}, {data_value=}' msg = f'{p=},{f}: {spec_value=}, {data_value=}'
exception(f'{e}:{msg}') exception(f'{e}:{msg}')
break break
if p_ok and p.formula:
# 检查 filters 区间条件(新格式,AND逻辑:所有filter_item都要匹配)
# 带 unit_prices 的 filter_item 是 tiered 定价,跳过(由后面第二块处理)
# 兼容两种格式:list of dicts 或 dict
if p_ok and is_new_format and 'filters' in p:
raw_filters = p['filters']
if isinstance(raw_filters, dict):
filter_items = [raw_filters]
else:
filter_items = raw_filters
for filter_item in filter_items:
if not isinstance(filter_item, dict):
continue
if 'unit_prices' in filter_item:
continue # tiered定价项,不在此处检查
item_ok = True
item_value_mode = filter_item.get('value_mode')
for fk, fv in filter_item.items():
if fk in ('unit_prices', 'value_mode'):
continue
f = d.fields.get(fk)
if not f:
continue
data_value = config_data.get(fk)
data_value = data_mapping(d, fk, data_value)
if data_value is None:
continue # 数据中没有该键,视为匹配
try:
flg = check_value(f, fv, data_value, item_value_mode)
if not flg:
item_ok = False
break
except Exception as e:
debug(f'filter check error: {e}')
item_ok = False
break
if not item_ok:
p_ok = False
break
if not p_ok:
info(f'{config_data=}, {p=}, mismatched')
continue
np = p.copy() np = p.copy()
np.data = config_data
if is_new_format:
# 新格式:price_factors + unit_prices + unit
factor_name = p['price_factors']
unit_price = p['unit_prices']
unit_str = p.get('unit', '次')
# 处理 filters 中的区间定价(查找匹配的 unit_prices)
if 'filters' in p:
raw_filters2 = p['filters']
if isinstance(raw_filters2, dict):
filter_items2 = [raw_filters2]
else:
filter_items2 = raw_filters2
for filter_item in filter_items2:
if not isinstance(filter_item, dict):
continue
item_value_mode = filter_item.get('value_mode')
for fk, fv in filter_item.items():
if fk in ('unit_prices', 'value_mode'):
continue
f = d.fields.get(fk)
if not f:
continue
data_value = config_data.get(fk)
data_value = data_mapping(d, fk, data_value)
if data_value is None:
continue
try:
flg = check_value(f, fv, data_value, item_value_mode)
if flg and 'unit_prices' in filter_item:
unit_price = filter_item['unit_prices']
except:
pass
# 获取 usage 值
if factor_name == 'flat':
# 固定费用
usage_value = 1
else:
usage_value = config_data.get(factor_name)
if usage_value is None:
debug(f'新格式:config_data中缺少{factor_name}')
continue
usage_value = float(usage_value)
# 获取单位值
unit_val = unit_values.get(unit_str, 1)
if isinstance(unit_val, str):
unit_val = float(unit_val)
# 计算金额
amount = unit_price * usage_value / unit_val
# 应用 min_amount
min_amount = p.get('min_amount', 0)
if min_amount and amount < min_amount:
amount = min_amount
np.amount = amount
ret_items.append(np)
elif is_old_format:
# 旧格式:formula
formula = p.formula formula = p.formula
if not formula:
e = f'{p} not formula found'
exception(e)
raise Exception(e)
debug(f'{formula=}, {ns=}, {p=}, {d.fields=}') debug(f'{formula=}, {ns=}, {p=}, {d.fields=}')
env_data = DictObject(config_data) np.data = config_data
np.amount = eval(formula, env_data) np.amount = eval(formula, config_data.copy())
ret_items.append(np) ret_items.append(np)
else:
info(f'{config_data=}, {p=}, {d.model_mappings=}, mismatched')
if len(ret_items) == 0: if len(ret_items) == 0:
e = f'{config_data=}{yamlstr=}没有找到合适的定价' e = f'{config_data=}{yamlstr=}没有找到合适的定价'
exception(e) exception(e)
raise Exception(e) raise Exception(e)
return ret_items return ret_items
def generate_formula_from_factors(price_factors): async def get_pricing_program(ppid):
"""从 price_factors 数组自动生成 formula 字符串。 env = ServerEnv()
async with get_sor_context(env, 'pricing') as sor:
price_factors 格式: recs = await sor.R('pricing_program', {'id': ppid})
[ if len(recs) == 0:
{"factor": "prompt_tokens", "unit_price": 3.2, "unit_label": "元/百万Token"}, exception(f'{ppid} not found in pricing_program_timing')
{"factor": "completion_tokens", "unit_price": 16.0, "unit_label": "元/百万Token"} return None
] pp = recs[0]
返回: "(3.2 * float(prompt_tokens) + 16.0 * float(completion_tokens)) / 1000000.0" if ppt.pricing_data is None:
""" exception(f'{ppid} pricing_data is None in pricing_program_timing')
if not price_factors:
return None return None
parts = []
has_million = False
for f in price_factors:
factor = f.get('factor', '')
unit_price = f.get('unit_price', 0)
unit_label = f.get('unit_label', '')
# 判断单位是否是"百万"级别(元/百万Token 等)
if '百万' in unit_label:
has_million = True
parts.append(f'{unit_price} * float({factor})')
formula = ' + '.join(parts)
if has_million:
formula = f'({formula}) / 1000000.0'
return formula
async def get_pricing_display(ppid, model=None):
"""获取定价项目的可读定价数据,供客户/前端展示。
Args:
ppid: 定价项目ID
model: 可选,按模型名过滤,只返回匹配该模型的定价项
返回结构:
{
"ppid": "...",
"name": "...",
"pricing_type": "per_use",
"items": [
{
"filters": {"model": "doubao-seed-2-0-pro"},
"filter_labels": {"模型": "doubao-seed-2-0-pro"},
"price_factors": [
{
"factor": "prompt_tokens",
"label": "输入Token",
"unit_price": 0.000006,
"unit": "百万",
"unit_label": "元/百万Token"
}
],
"formula": "...",
"min_amount": 0.01
}
]
}
"""
try: try:
r = await PricingProgram.get_ppid_pricing(ppid) PricingProgram.pp_db2app(ppt)
except Exception: except Exception as e:
return None return None
# get_ppid_pricing 缓存命中时返回 dict(Redis JSON 反序列化),
# 未命中时返回 DictObject(sqlor 行对象),两者都要兼容(同 buffered_charging 的处理,
# 2026-09-06 修复:此前缓存命中即抛 AttributeError 致展示价间歇性"定价数据为空")。
if hasattr(r, 'pricing_data'):
pricing_data_str = r.pricing_data
elif isinstance(r, dict):
pricing_data_str = r.get('pricing_data')
else:
pricing_data_str = r['pricing_data']
d = yaml.safe_load(pricing_data_str)
if not d:
raise Exception(f'{ppid} pricing_data 为空')
fields = d.get('fields', {})
pricings = d.get('pricings', [])
pricing_type = d.get('pricing_type', 'per_use')
unit_values = d.get('unit_values', {'百万': 1000000, '秒': 1, '千': 1000, '次': 1, '张': 1, '毫秒': 0.001, '元/百万tokens': 1000000, '元/total_tokens': 1, '元/times': 1})
model_mappings = d.get('model_mappings', {})
items = []
for p in pricings:
# 跳过需要人工审核的记录
if p.get('_NEEDS_MANUAL_REVIEW'):
continue
# 提取过滤条件(role=filter 的字段)
# filters[k]=原始值(供 model 过滤等内部匹配);filter_labels[label]=美化后的展示值
filters = {}
filter_labels = {}
skip_keys = {'formula', 'price_factors', 'unit_prices', 'unit', 'filters', 'min_amount', 'pricing_type', 'name'}
# filter/tier 内的机制键:unit_prices=档位单价、value_mode=区间操作符提示,均非展示维度
_mechanism_keys = {'unit_prices', 'value_mode'}
# 先从 p.items() 中提取(item 级平铺维度,如视频 SR/resolution)
for k, v in p.items():
if k in skip_keys:
continue
fdef = fields.get(k, {})
if not isinstance(fdef, dict):
fdef = {}
if fdef.get('role', 'filter') == 'filter':
filters[k] = v
filter_labels[fdef.get('label', k)] = beautify_filter_value(v, fdef)
# 再从 p['filters'] 列表中提取 item 级条件(如果存在)
# ⚠️ 列表中带 unit_prices 的条目是价格档位(tier),不是 item 级条件——
# 旧代码把它们当扁平 filter 提取,导致 value_mode/unit_prices 机制键泄漏进
# filter_labels(被表格当成表头),且多档被压扁只剩最后一档。档位由下方
# tier_rows 块单独展开成行(2026-09-09 根治)。
raw_filters = p.get('filters')
if isinstance(raw_filters, list):
for fi in raw_filters:
if not isinstance(fi, dict) or 'unit_prices' in fi:
continue
vm = fi.get('value_mode')
for k, v in fi.items():
if k in _mechanism_keys:
continue
fdef = fields.get(k, {})
if not isinstance(fdef, dict):
fdef = {}
if fdef.get('role', 'filter') != 'filter':
continue
filters[k] = v
filter_labels[fdef.get('label', k)] = beautify_filter_value(v, fdef, vm)
# 新格式:price_factors(scalar) + unit_prices(scalar) + unit
# 注意:生产数据中 price_factors 可能是 list、unit_prices 可能是 dict(混合格式),
# 此时应走旧格式 formula 路径
_raw_pf = p.get('price_factors')
_raw_up = p.get('unit_prices')
is_new_format = (_raw_pf is not None and _raw_up is not None
and not isinstance(_raw_pf, list) and not isinstance(_raw_up, dict))
if is_new_format:
factor_name = _raw_pf
unit_price = _raw_up
unit_str = p.get('unit', '次')
# 获取 factor label
fdef = fields.get(factor_name, {})
factor_label = fdef.get('label', factor_name) if isinstance(fdef, dict) else factor_name
# 新格式 unit_prices 已是展示价(如 6.0 元/百万),无需再乘 unit_val
display_price = unit_price
# 构建 unit_label (元/单位)
unit_label = f"元/{unit_str}" if unit_str else "元"
min_amount = p.get('min_amount', 0)
# 价格档位(tier):filters 列表中带 unit_prices 的条目(分档 token 定价、
# 忙闲时定价)。每档展开为独立 item(条件+价各成一行)——旧实现把档位塞进
# price_factors[0]['tiered'] 嵌套结构且维度键按「not k.endswith('_tokens')」
# 词匹配误杀,prompt_tokens 分档整条丢失(MiniMax-M3 只剩兜底价 4.2,
# 档位价 2.1 从未展示过)。tiered 嵌套全仓无消费方(2026-09-09 grep 实证),
# 展开为 item 对 llmage 卡片/商店表格反而更可读。
tier_filters = []
if isinstance(raw_filters, list):
for fi in raw_filters:
if isinstance(fi, dict) and fi.get('unit_prices') is not None:
tier_filters.append(fi)
if tier_filters:
# 只展示档位行:顶层 unit_prices 是引擎兜底价(无档位命中时用,计费
# 引擎 get_pricing_from_ymalstr 独立路径不受影响),不是文档定价档位——
# 展示它会在表格里出现与某档同价的无条件行,客户无法判断何时用哪个价
for fi in tier_filters:
tier_labels = dict(filter_labels) # item级条件(如 model)对每档都适用
tier_raw = dict(filters)
vm = fi.get('value_mode')
for k, v in fi.items():
if k in _mechanism_keys:
continue
kfdef = fields.get(k, {})
if not isinstance(kfdef, dict):
kfdef = {}
tier_raw[k] = v
tier_labels[kfdef.get('label', k)] = beautify_filter_value(v, kfdef, vm)
tier_item = {}
tier_item['price_factors'] = [{
'factor': factor_name,
'label': factor_label,
'unit_price': fi['unit_prices'],
'unit': unit_str,
'unit_label': unit_label
}]
if tier_raw:
tier_item['filters'] = tier_raw
tier_item['filter_labels'] = tier_labels
if min_amount:
tier_item['min_amount'] = min_amount
items.append(tier_item)
continue
price_factors_display = [{
'factor': factor_name,
'label': factor_label,
'unit_price': display_price,
'unit': unit_str,
'unit_label': unit_label
}]
item = {'price_factors': price_factors_display}
if filters:
item['filters'] = filters
item['filter_labels'] = filter_labels
if min_amount:
item['min_amount'] = min_amount
items.append(item)
else:
# 旧格式:formula
price_factors = p.get('price_factors', None)
if not price_factors:
# fallback: 从 fields 中构建展示信息
price_factors = []
for k, fdef in fields.items():
if not isinstance(fdef, dict):
continue
role = fdef.get('role', 'filter')
if role == 'factor' or fdef.get('type') == 'float':
label = fdef.get('label', k)
price_factors.append({
'factor': k,
'label': label,
'unit_price': None,
'unit_label': ''
})
item = {'price_factors': price_factors}
if filters:
item['filters'] = filters
item['filter_labels'] = filter_labels
if p.get('formula'):
item['formula'] = p['formula']
items.append(item)
# 按模型过滤:只返回匹配该模型的定价项
# 如果过滤后为空(单模型ppid没有model字段),退回不过滤
if model:
filtered_items = []
for item in items:
item_model = None
if item.get('filters') and isinstance(item['filters'], dict):
item_model = item['filters'].get('model')
if not item_model and item.get('filter_labels'):
item_model = item['filter_labels'].get('模型')
# 直接匹配或通过 model_mappings 映射匹配
if item_model == model or model_mappings.get(item_model) == model:
filtered_items.append(item)
if filtered_items:
items = filtered_items
# 生成可读价格表
currency = getattr(r, 'currency', 'CNY')
currency_symbols = {'CNY': '元', 'USD': '$', 'JPY': '¥', 'GBP': '£'}
currency_label = currency_symbols.get(currency, currency)
display_lines = []
for item in items:
# 构建过滤条件文本
filter_text = ''
if item.get('filter_labels'):
filter_parts = [f"{k}={v}" for k, v in item['filter_labels'].items()]
filter_text = f" [{', '.join(filter_parts)}]"
for pf in item.get('price_factors', []):
label = pf.get('label', pf.get('factor', ''))
up = pf.get('unit_price')
ul = pf.get('unit_label', '')
# Replace hardcoded "元" in unit_label with correct currency
if ul and currency != 'CNY':
ul = ul.replace('元', currency_label)
if up is not None:
display_lines.append(f" - {label}: {up} {ul}{filter_text}")
return {
'ppid': ppid,
'name': getattr(r, 'name', ''),
'pricing_type': pricing_type,
'items': items,
'display_text': '\n'.join(display_lines)
}
async def get_pricing_program_timeing(pptid): async def get_pricing_program_timeing(pptid):
env = ServerEnv() env = ServerEnv()
@ -1143,17 +617,8 @@ async def get_pricing_program_timeing(pptid):
return ppt return ppt
async def test_pricing(pptid, data): async def test_pricing(pptid, data):
# 直接从 DB 获取原始 pricing_data(不调用 ppt_db2app),避免 DictObject 序列化问题 ppt = get_pricing_program_timeing(pptid)
env = ServerEnv() prices = PricingProgram.get_pricing_from_ymalstr(data, ppt.pricing_data)
async with get_sor_context(env, 'pricing') as sor:
recs = await sor.R('pricing_program_timing', {'id': pptid})
if len(recs) == 0:
return None
ppt = recs[0]
if ppt.pricing_data is None:
return None
yamlstr = ppt.pricing_data # 原始 YAML 字符串
prices = PricingProgram.get_pricing_from_ymalstr(data, yamlstr)
if prices is None: if prices is None:
return None return None
amount = 0 amount = 0
@ -1190,25 +655,3 @@ pricings:
p = PricingProgram.get_pricing_from_ymalstr(config_data, yamlstr) p = PricingProgram.get_pricing_from_ymalstr(config_data, yamlstr)
print(f'{p=}') print(f'{p=}')
async def add_pricing_program_timing(env, sor, ns):
"""拉链逻辑:新增定价时序时自动处理 expired_date"""
ppid = ns.get('ppid')
enabled_date = ns.get('enabled_date')
ns['expired_date'] = '9999-12-31'
if ppid and enabled_date:
prev = await sor.R('pricing_program_timing', {'ppid': ppid, 'expired_date': '9999-12-31'})
if prev:
await sor.U('pricing_program_timing', {'id': prev[0].id, 'expired_date': enabled_date})
# 插入新记录
ns['id'] = ns.get('id') or getID()
await sor.C('pricing_program_timing', ns)
def load_pricing():
from ahserver.serverenv import ServerEnv
env = ServerEnv()
env.add_pricing_program_timing = add_pricing_program_timing
env.get_pricing_program_timeing = get_pricing_program_timeing
env.test_pricing = test_pricing

View File

@ -1,96 +0,0 @@
-- ============================================================
-- kimi-k3 定价接入
-- 数据来源: 用户提供
-- 定价: 输入(缓存命中)¥2.00/百万 输入(缓存未命中)¥20.00/百万 输出¥100.00/百万
-- 上下文: 1,048,576 tokens
-- ============================================================
-- 步骤1: 确认 modelprovider 已存在,如没有先创建
-- INSERT IGNORE INTO `modelprovider` (`id`, `name`) VALUES ('<provider_id>', '月之暗面');
-- 步骤2: 新建 pricing_program
INSERT IGNORE INTO `pricing_program` (`id`, `name`, `ownerid`, `providerid`, `pricing_belong`, `description`, `currency`)
VALUES (
'kimi_k3_pricing', -- id: 按需替换
'kimi-k3 模型定价',
'0',
'<provider_id>', -- 替换为月之暗面的 modelprovider.id
'provider',
'kimi-k3 按百万token计费,支持缓存命中差异化定价',
'CNY'
);
-- 步骤3: 新建 pricing_program_timing (含分段定价 YAML)
INSERT IGNORE INTO `pricing_program_timing` (`id`, `ppid`, `name`, `enabled_date`, `expired_date`, `pricing_data`)
VALUES (
'kimi_k3_timing', -- id: 按需替换
'kimi_k3_pricing', -- ppid: 对应上一步的 pricing_program.id
'kimi-k3 全价',
'2026-07-17', -- 启用日期
'9999-12-31',
'unit_values:
百万: 1000000
fields:
price_factors:
type: string
role: factor
label: "计价因子"
unit_prices:
type: float
role: factor
label: "单位定价"
unit:
type: string
role: factor
label: "计价单位"
prompt_tokens:
type: int
role: filter
label: "输入Token数"
value_mode: between
completion_tokens:
type: int
role: factor
label: "输出Token数"
value_mode: between
model:
type: string
role: filter
label: "模型"
uncached_prompt_tokens:
type: int
role: factor
label: "非缓存输入Token"
derived: "prompt_tokens - usage.cached_tokens"
cached_tokens:
type: int
role: factor
label: "缓存Token"
derived: "usage.cached_tokens"
pricings:
- price_factors: uncached_prompt_tokens
unit_prices: 20.0
unit: 百万
prompt_tokens: "0.0 =~= 1048576.0"
model: kimi-k3
- price_factors: cached_tokens
unit_prices: 2.0
unit: 百万
prompt_tokens: "0.0 =~= 1048576.0"
model: kimi-k3
- price_factors: completion_tokens
unit_prices: 100.0
unit: 百万
prompt_tokens: "0.0 =~= 1048576.0"
model: kimi-k3
'
);

View File

@ -1,62 +0,0 @@
#!/usr/bin/env python3
"""
pricing 模块 RBAC 权限管理脚本
"""
import subprocess, os, sys
def find_sage_root():
for c in [os.path.expanduser("~/repos/sage"), os.path.expanduser("~/sage"),
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))]:
if os.path.isdir(os.path.join(c, "py3")) and os.path.isdir(os.path.join(c, "wwwroot")):
return c
return None
SAGE_ROOT = find_sage_root()
if not SAGE_ROOT: print("ERROR: Cannot find Sage root directory"); sys.exit(1)
PYTHON = os.path.join(SAGE_ROOT, "py3", "bin", "python")
SET_PERM_SCRIPT = os.path.join(SAGE_ROOT, "set_role_perm.py")
MOD = "pricing"
PATHS_ANY = [f"/{MOD}/menu.ui"]
PATHS_LOGINED = [
f"/{MOD}", f"/{MOD}/index.ui",
f"/{MOD}/load_pricing_data.ui", f"/{MOD}/pricing_test.ui",
f"/{MOD}/test_pricing_program.ui", f"/{MOD}/pricing_data_display.ui",
f"/{MOD}/download_pricing_data.dspy", f"/{MOD}/download_pricing_pattern.dspy",
f"/{MOD}/get_all_pricing_programs.dspy", f"/{MOD}/get_platform_providers.dspy",
f"/{MOD}/test_pricing_program.dspy", f"/{MOD}/upload_pricing_data.dspy",
f"/{MOD}/api/get_search_providerid.dspy",
f"/{MOD}/api/get_pricing_display.dspy",
f"/{MOD}/api/add_pricing_program_timing.dspy",
]
# CRUD aliases
for alias in ["pricing_program", "pricing_program_list", "pricing_program_timing", "pricing_program_timing_list"]:
tbl = alias.replace("_list", "")
PATHS_LOGINED += [
f"/{MOD}/{alias}",
f"/{MOD}/{alias}/index.ui",
f"/{MOD}/{alias}/get_{tbl}.dspy",
f"/{MOD}/{alias}/add_{tbl}.dspy",
f"/{MOD}/{alias}/update_{tbl}.dspy",
f"/{MOD}/{alias}/delete_{tbl}.dspy",
]
def run_set_perm(role, path):
return subprocess.run([PYTHON, SET_PERM_SCRIPT, role, path], capture_output=True, text=True).returncode == 0
def register_role_paths(role, paths):
count = sum(1 for p in paths if run_set_perm(role, p))
print(f" {role}: {count}/{len(paths)} paths registered")
return count
def main():
print(f"Sage root: {SAGE_ROOT}")
total = register_role_paths("any", PATHS_ANY)
total += register_role_paths("logined", PATHS_LOGINED)
print(f"\nDone. Total {total} permission entries registered.")
print("NOTE: Restart Sage after permission changes to reload RBAC cache.")
if __name__ == "__main__":
main()

View File

@ -13,4 +13,3 @@ install_requires =
apppublic apppublic
ahserver ahserver
sqlor sqlor
pyyaml

View File

@ -1,6 +0,0 @@
# 新增定价项目时序,自动处理拉链逻辑
env = request._run_ns
dbname = get_module_dbname('pricing')
async with DBPools().sqlorContext(dbname) as sor:
await add_pricing_program_timing(env, sor, params_kw)
return {'status': 'success'}

View File

@ -1,11 +0,0 @@
ppid = params_kw.get('ppid')
model = params_kw.get('model')
if not ppid:
return json.dumps({"status": "error", "message": "ppid parameter required"}, ensure_ascii=False)
try:
result = await get_pricing_display(ppid, model)
return json.dumps({"status": "ok", "data": result}, ensure_ascii=False, default=str)
except Exception as e:
exception(f'get_pricing_display({ppid}) failed: {e}\n{format_exc()}')
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

View File

@ -1,13 +0,0 @@
result = [{'providerid': '', 'providerid_text': '全部'}]
try:
async with get_sor_context(request._run_ns, 'rbac') as sor:
rows = await sor.sqlExe(
"select id as providerid, supplier_name as providerid_text from suppliers where status = '1' order by supplier_name",
{}
)
return json.dumps([{'providerid': '', 'providerid_text': '全部'}] + list(rows), ensure_ascii=False)
except Exception as e:
debug(f'get_search_providerid error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

@ -3,7 +3,8 @@
"options": { "options": {
"width": "100%", "width": "100%",
"height": "100%", "height": "100%",
"padding": "0" "padding": "0",
"bgcolor": "#0B1120"
}, },
"subwidgets": [ "subwidgets": [
{ {
@ -17,7 +18,9 @@
{ {
"widgettype": "Title2", "widgettype": "Title2",
"options": { "options": {
"text": "定价管理" "text": "定价管理",
"color": "#F1F5F9",
"fontWeight": "700"
} }
}, },
{ {
@ -27,37 +30,19 @@
"widgettype": "Text", "widgettype": "Text",
"options": { "options": {
"text": "模型定价项目与计费规则配置", "text": "模型定价项目与计费规则配置",
"cfontsize": 1.2 "fontSize": "14px",
"color": "#64748B"
} }
} }
] ]
}, },
{
"widgettype": "VScrollPanel",
"options": {
"css": "filler"
},
"subwidgets": [
{ {
"widgettype": "VBox", "widgettype": "VBox",
"options": { "options": {
"spacing": 24 "bgcolor": "#1E293B",
}, "padding": "24px",
"subwidgets": [ "borderRadius": "12px",
{ "border": "1px solid #334155",
"widgettype": "ResponsableBox",
"options": {
"gap": "16px",
"minWidth": "300px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"css": "card",
"cwidth": 25,
"padding": "16px",
"cursor": "pointer" "cursor": "pointer"
}, },
"binds": [ "binds": [
@ -73,10 +58,21 @@
} }
], ],
"subwidgets": [ "subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#F59E0B\" stroke-width=\"1.5\"><path d=\"M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"/></svg>",
"width": "36px",
"height": "36px",
"marginBottom": "16px"
}
},
{ {
"widgettype": "Title4", "widgettype": "Title4",
"options": { "options": {
"text": "定价项目管理", "text": "定价项目管理",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px" "marginBottom": "8px"
} }
}, },
@ -84,20 +80,20 @@
"widgettype": "Text", "widgettype": "Text",
"options": { "options": {
"text": "管理模型定价规则、计费项目和定时任务", "text": "管理模型定价规则、计费项目和定时任务",
"cfontsize": 1.2 "fontSize": "14px",
"color": "#94A3B8"
} }
} }
] ]
}
]
}
]
}, },
{ {
"widgettype": "VBox", "widgettype": "VBox",
"id": "pricing_content" "id": "pricing_content",
"css": "filler",
"options": {
"width": "100%",
"overflowY": "auto"
} }
]
} }
] ]
} }

View File

@ -2,9 +2,8 @@
{ {
"widgettype": "Form", "widgettype": "Form",
"options": { "options": {
"title": "{{pp.name}}", "title": "{{pp.id}}",
"description": {{json.dumps(pp.description)}}, "description": {{json.dumps(pp.description)}},
"height": "200%",
"fields":[ "fields":[
{ {
"name": "xlsx_file", "name": "xlsx_file",

View File

@ -6,7 +6,7 @@
{ {
"name": "pricing", "name": "pricing",
"label": "定价管理", "label": "定价管理",
"url": "{{entire_url('/pricing/pricing_program')}}" "url": "{{entire_url('/pricing/index.ui')}}"
} }
] ]
} }

View File

@ -0,0 +1 @@
return await get_pricing_specs_by_pptid(params_kw.pptid)

View File

@ -1,35 +0,0 @@
{% set sor = db.sqlorContext() %}
{% set row = sor.execute_sql('select name, pricing_data from pricing_program_timing where id = ${id}$').first() %}
{% set pricing_data = row.pricing_data if row and row.pricing_data else '' %}
{% set record_name = row.name if row else '' %}
{
"widgettype": "VScrollPanel",
"options": {
"width": "100%",
"height": "100%"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"width": "100%",
"padding": "16px",
"spacing": 12
},
"subwidgets": [
{
"widgettype": "Title4",
"options": {
"text": "{{ record_name }}"
}
},
{
"widgettype": "Text",
"options": {
"text": {{ pricing_data | tojson }}
}
}
]
}
]
}

View File

@ -0,0 +1,40 @@
ns = params_kw.copy()
for k,v in ns.items():
if v == 'NaN' or v == 'null':
ns[k] = None
id = params_kw.id
if not id or len(id) > 32:
id = uuid()
ns['id'] = id
db = DBPools()
dbname = get_module_dbname('pricing')
async with db.sqlorContext(dbname) as sor:
fs = await sor_get_spec_fields(sor, ns.id)
if len(fs):
ns1 = {k:ns[k] for k in fs}
ns.spec_value = json.dumps(ns1)
r = await sor.C('pricing_item', ns.copy())
return {
"widgettype":"Message",
"options":{
"user_data":ns,
"cwidth":16,
"cheight":9,
"title":"Add Success",
"timeout":3,
"message":"ok"
}
}
return {
"widgettype":"Error",
"options":{
"title":"Add Error",
"cwidth":16,
"cheight":9,
"timeout":3,
"message":"failed"
}
}

View File

@ -0,0 +1,33 @@
ns = {
'id':params_kw['id'],
}
db = DBPools()
dbname = get_module_dbname('pricing')
async with db.sqlorContext(dbname) as sor:
r = await sor.D('pricing_item', ns)
debug('delete success');
return {
"widgettype":"Message",
"options":{
"title":"Delete Success",
"timeout":3,
"cwidth":16,
"cheight":9,
"message":"ok"
}
}
debug('Delete failed');
return {
"widgettype":"Error",
"options":{
"title":"Delete Error",
"timeout":3,
"cwidth":16,
"cheight":9,
"message":"failed"
}
}

View File

@ -0,0 +1,118 @@
ns = params_kw.copy()
debug(f'get_pricing_item.dspy:{ns=}')
if not ns.get('page'):
ns['page'] = 1
if not ns.get('sort'):
ns['sort'] = 'name'
sql = '''select a.*, b.pptid_text, c.psid_text, d.subppid_text
from (select * from pricing_item where 1=1 [[filterstr]]) a left join (select id as pptid,
id as pptid_text from pricing_program_timing where 1 = 1) b on a.pptid = b.pptid left join (select id as psid,
name as psid_text from pricing_spec where 1 = 1) c on a.psid = c.psid left join (select id as subppid,
name as subppid_text from pricing_program where 1 = 1) d on a.subppid = d.subppid'''
filterjson = params_kw.get('data_filter')
if not filterjson:
fields = [ f['name'] for f in [
{
"name": "id",
"title": "id",
"type": "str",
"length": 32
},
{
"name": "name",
"title": "定价项名",
"type": "str",
"length": 256
},
{
"name": "description",
"title": "描述",
"type": "text"
},
{
"name": "pptid",
"title": "项目id",
"type": "str",
"length": 32
},
{
"name": "psid",
"title": "定价规格id",
"type": "str",
"length": 32
},
{
"name": "subppid",
"title": "子项目id",
"type": "str",
"length": 32,
"nullable": "yes"
},
{
"name": "spec_value",
"title": "规格值",
"type": "text"
},
{
"name": "pricing_unit",
"title": "定价单位",
"type": "float",
"length": 18,
"dec": 5
},
{
"name": "pricing_amount",
"title": "定价金额",
"type": "float",
"length": 18,
"dec": 5
},
{
"name": "cost_amount",
"title": "成本金额",
"type": "float",
"length": 18,
"dec": 5
}
] ]
filterjson = default_filterjson(fields, ns)
filterdic = ns.copy()
filterdic['filterstr'] = ''
filterdic['userorgid'] = '${userorgid}$'
filterdic['userid'] = '${userid}$'
if filterjson:
dbf = DBFilter(filterjson)
conds = dbf.gen(ns)
if conds:
ns.update(dbf.consts)
conds = f' and {conds}'
filterdic['filterstr'] = conds
ac = ArgsConvert('[[', ']]')
vars = ac.findAllVariables(sql)
NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' }
filterdic.update(NameSpace)
sql = ac.convert(sql, filterdic)
debug(f'{sql=}')
db = DBPools()
dbname = get_module_dbname('pricing')
async with db.sqlorContext(dbname) as sor:
r = await sor.sqlPaging(sql, ns)
for d in r['rows']:
if d.spec_value:
ad = json.loads(d.spec_value)
d.update(ad)
return r
return {
"total":0,
"rows":[]
}

View File

@ -0,0 +1,6 @@
id = params_kw.id
async with get_sor_context(request._run_ns, 'pricing') as sor:
recs = await sor.R('pricing_spec', {'id': id})
if len(recs):
return recs.spec_names
return []

View File

@ -0,0 +1,189 @@
{
"id":"pricing_item_tbl",
"widgettype":"Tabular",
"options":{
"width":"100%",
"height":"100%",
"title":"项目定价项",
"css":"card",
"editable":{
"new_data_url":"{{entire_url('add_pricing_item.dspy')}}",
"delete_data_url":"{{entire_url('delete_pricing_item.dspy')}}",
"update_data_url":"{{entire_url('update_pricing_item.dspy')}}"
},
"data_url":"{{entire_url('./get_pricing_item.dspy')}}",
"data_method":"GET",
"data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}},
"row_options":{
"browserfields": {
"exclouded": [
"id",
"pptid"
],
"alters": {
"psid": {
"dataurl": "{{entire_url('../pi_get_all_specs.dspy')}}",
"textField": "name",
"valueField": "id",
"params": {
"pptid": "{{params_kw.pptid}}"
}
}
}
},
"editexclouded":[
"id",
"pptid"
],
"fields":[
{
"name": "id",
"title": "id",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "id"
},
{
"name": "name",
"title": "定价项名",
"type": "str",
"length": 256,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "定价项名"
},
{
"name": "description",
"title": "描述",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "描述"
},
{
"name": "pptid",
"title": "项目id",
"type": "str",
"length": 32,
"label": "项目id",
"uitype": "code",
"valueField": "pptid",
"textField": "pptid_text",
"params": {
"dbname": "{{get_module_dbname('pricing')}}",
"table": "pricing_program_timing",
"tblvalue": "id",
"tbltext": "id",
"valueField": "pptid",
"textField": "pptid_text"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
{
"name": "psid",
"title": "定价规格id",
"type": "str",
"length": 32,
"label": "定价规格id",
"uitype": "code",
"valueField": "id",
"textField": "name",
"params": {
"pptid": "{{params_kw.pptid}}"
},
"dataurl": "{{entire_url('../pi_get_all_specs.dspy')}}"
},
{
"name": "subppid",
"title": "子项目id",
"type": "str",
"length": 32,
"nullable": "yes",
"label": "子项目id",
"uitype": "code",
"valueField": "subppid",
"textField": "subppid_text",
"params": {
"dbname": "{{get_module_dbname('pricing')}}",
"table": "pricing_program",
"tblvalue": "id",
"tbltext": "name",
"valueField": "subppid",
"textField": "subppid_text"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
{
"name": "pricing_unit",
"title": "定价单位",
"type": "float",
"length": 18,
"dec": 5,
"cwidth": 18,
"uitype": "float",
"datatype": "float",
"label": "定价单位"
},
{
"name": "pricing_amount",
"title": "定价金额",
"type": "float",
"length": 18,
"dec": 5,
"cwidth": 18,
"uitype": "float",
"datatype": "float",
"label": "定价金额"
},
{
"name": "cost_amount",
"title": "成本金额",
"type": "float",
"length": 18,
"dec": 5,
"cwidth": 18,
"uitype": "float",
"datatype": "float",
"label": "成本金额"
}
{{get_all_spec_fields_by_pptid(request, params_kw.pptid)}}
]
},
"page_rows":160,
"cache_limit":5
}
,"binds":[
{
"wid":"psid",
"event":"changed",
"actiontype":"script",
"target":"self",
"script":"this.hide_field(params.psid);"
}
]
}

View File

@ -0,0 +1,45 @@
ns = params_kw.copy()
for k,v in ns.items():
if v == 'NaN' or v == 'null':
ns[k] = None
db = DBPools()
dbname = get_module_dbname('pricing')
async with db.sqlorContext(dbname) as sor:
ori = await sor.R('pricing_item', {'id': ns.id})
ori_sv = {}
if ori.spec_value:
ori_sv = json.loads(ori.spec_value)
fs = await sor_get_spec_fields(sor, ns.id)
if len(fs):
new_sv = {k:ns[k] for k in fs}
if new_sv:
ori_sv.update(new_sv)
ns.spec_value = json.dumps(ori_sv, indent=4)
r = await sor.U('pricing_item', ns.copy())
debug('update success, {ns=}');
return {
"widgettype":"Message",
"options":{
"title":"Update Success",
"cwidth":16,
"cheight":9,
"timeout":3,
"message":"ok"
}
}
return {
"widgettype":"Error",
"options":{
"title":"Update Error",
"cwidth":16,
"cheight":9,
"timeout":3,
"message":"failed"
}
}

View File

@ -9,7 +9,7 @@
"fields": [ "fields": [
{% if amount is not None %} {% if amount is not None %}
{ {
"name": "amount", "name": amoumt,
"label": "价格", "label": "价格",
"value": {{amount}}, "value": {{amount}},
"uitype": "float" "uitype": "float"

View File

@ -4,26 +4,19 @@ try:
if isinstance(data, str): if isinstance(data, str):
data = json.loads(data) data = json.loads(data)
x = await buffered_charging(ppid, data) x = await buffered_charging(ppid, data)
subwidgets = [] return {
if isinstance(x, list): "status": "ok",
for r in x: "data": {
subwidgets.append({ "ppid": ppid,
"widgettype": "Text", "data": data,
"options": {"text": json.dumps(r, ensure_ascii=False), "width": "100%"} "result": x
}) }
else: }
subwidgets.append({
"widgettype": "Text",
"options": {"text": json.dumps(x, ensure_ascii=False), "width": "100%"}
})
return json.dumps({
"widgettype": "VScrollPanel",
"options": {"width": "100%", "height": "100%"},
"subwidgets": subwidgets
}, ensure_ascii=False)
except Exception as e: except Exception as e:
exception(f'{ppid=}, {data=}, {e}{format_exc()}') exception(f'{ppid=}, {data=}, {e}{format_exc()}')
return json.dumps({ return {
"widgettype": "Text", "status": "error",
"options": {"width": "100%", "color": "red", "text": f'{ppid=}, {data=}, {e}'} "data": {
}, ensure_ascii=False) "message": f'{ppid=}, {data=}, {e}'
}
}

View File

@ -29,7 +29,7 @@
} }
}, },
{ {
"widgettype": "VScrollPanel", "widgettype": "VBox",
"id": "result", "id": "result",
"options": { "options": {
"height": "50%", "height": "50%",
@ -41,11 +41,28 @@
{ {
"wid": "input_form", "wid": "input_form",
"event": "submit", "event": "submit",
"actiontype": "urlwidget", "actiontype": "urldata",
"target": "result", "target": "result",
"options": { "options": {
"url": "{{entire_url('./test_pricing_program.dspy')}}", "url": "{{entire_url('./test_pricing_program.dspy')}}",
"params":{} "params":{}
},
"status_of": {
"ok": {
"widgettype": "Text",
"options":{
"width": "100%",
"text": "${result}"
}
},
"error": {
"widgettype": "Text",
"options":{
"width": "100%",
"color": "red",
"text": "${message}"
}
}
} }
} }
] ]