Compare commits

...

56 Commits

Author SHA1 Message Date
Hermes Agent
c7b364926d feat: add i18n translations (zh/en/jp/ko) for all modules 2026-06-19 15:01:35 +08:00
Hermes Agent
38f0c10dfe Revert "fix: root VBox height:100% instead of cheight:40"
This reverts commit d89961c1e49598cbc833ddf86e8927cae041b74b.
2026-06-19 14:26:29 +08:00
Hermes Agent
d89961c1e4 fix: root VBox height:100% instead of cheight:40 2026-06-19 14:25:09 +08:00
Hermes Agent
851ea8b51b feat: add data_filter for name and providerid 2026-06-19 14:17:32 +08:00
Hermes Agent
9a0f82b28d fix: remove providerid alters from CRUD JSON, let model codes handle it 2026-06-19 14:02:09 +08:00
Hermes Agent
74d9848674 fix: add uitype:code to providerid alters for orgname display 2026-06-19 08:46:39 +08:00
Hermes Agent
a6150a4309 feat: add =~= (closed-closed interval) support in check_value for between ranges 2026-06-18 23:22:14 +08:00
Hermes Agent
7d8fae01ca fix: check_value支持filter_item级别的value_mode
- check_value增加value_mode参数,优先用传入值,回退到field.value_mode
- filter_item遍历时跳过value_mode key
- 提取filter_item.get('value_mode')传给check_value
- 修复between类型filter报invalid literal for int()错误
2026-06-18 17:49:28 +08:00
Hermes Agent
0d841f078e feat: pricing_program discount field validation [0-1]
- Added rules to discount field: required, number, min:0, max:1
- Frontend: Form validates on submit, shows error below field
- Backend: auto-generated dspy validates before DB write
2026-06-18 17:36:40 +08:00
Hermes Agent
9241edfb6c fix: test_pricing使用urlwidget加载控件JSON 2026-06-18 16:18:28 +08:00
Hermes Agent
edee247a11 fix: test_pricing结果直接返回完整控件JSON
- dspy: 直接return VScrollPanel+Text子控件的完整JSON,不再用status/data包装
- ui: actiontype从urldata改为bricks,直接实例化返回的控件
- 解决status_of模板${items}数组替换不支持的问题
2026-06-18 16:13:52 +08:00
Hermes Agent
d22dbc57ae fix: test_pricing结果用subwidgets控件数组渲染
- dspy: items改为Text控件数组([{widgettype:Text, options:{text:json}}])
- ui: status_of.ok改为VScrollPanel+subwidgets引用items
- 解决之前返回纯文本字符串无法渲染为bricks控件的问题
2026-06-18 16:08:59 +08:00
Hermes Agent
bcdb1e02cd feat: 定价测试窗口添加标题'定价测试' 2026-06-18 16:06:16 +08:00
Hermes Agent
972400e382 feat: test_pricing_program结果每条记录单独显示
- dspy: 每条结果json.dumps(r, ensure_ascii=False)后以\n\n分隔
- ui: 结果区改为VScrollPanel,支持长结果滚动
2026-06-18 16:03:43 +08:00
Hermes Agent
759cd14a56 fix: filters数组改为AND逻辑,缺键视为匹配
- 原OR逻辑导致model不匹配时仅因video匹配就计费
- 带unit_prices的filter_item为tiered定价,跳过AND检查
- seedance2.0案例:390825 tokens从75.82(5条匹配)修正为17.98(1条匹配)
2026-06-18 15:26:04 +08:00
Hermes Agent
616c3a1926 fix: 供应商折扣负数校验,clamp到0~1范围(bug6) 2026-06-16 10:45:29 +08:00
c5eb5b9399 fix: get_pricing_display 异常保护,返回None 2026-06-12 15:32:27 +08:00
c875a8dc2b fix: test_pricing 传入已解析的 dict 导致 yaml.safe_load 报错
ppt_db2app 将 pricing_data 从 YAML 字符串解析为 Python dict,
但 test_pricing 直接传给 get_pricing_from_ymalstr(期望 YAML 字符串),
导致 yaml.safe_load(dict) 报 TypeError。

修复:检测 ppt.pricing_data 类型,如果是 dict 则 yaml.dump 转回字符串。
2026-06-12 15:12:44 +08:00
7df8e530a4 remove: pricing_item CRUD and related functions (not used) 2026-06-12 14:41:58 +08:00
ac3d7c6d91 fix: implement missing get_all_spec_fields_by_pptid, sor_get_spec_fields, get_pricing_specs_by_pptid 2026-06-12 14:18:52 +08:00
00abc0caa4 bugfix 2026-06-12 14:04:57 +08:00
c8b5bba342 bugfix 2026-06-12 13:55:10 +08:00
8d1808fe96 bgufix 2026-06-12 13:49:27 +08:00
9094e07465 bgufix 2026-06-12 13:47:09 +08:00
22a3a96833 bgufix 2026-06-12 13:03:55 +08:00
fffcd03c87 fix: remove nonexistent ownerid filter from pricing_program_timing CRUD 2026-06-11 16:30:57 +08:00
c2a7dbb98f docs: 添加HTTP API文档和pricing_data使用指南
1. HTTP API接口文档:get_pricing_display.dspy 调用方式和返回示例
2. pricing_data两种格式说明:新格式(price_factors+unit_prices+unit)和旧格式(formula)
3. 完整示例:Token定价、视频生成定价、公式计算定价
4. 关键规则说明:unit_values、filters格式、value_mode等
2026-06-05 19:18:46 +08:00
c1bfaca049 fix: 支持filters列表格式 + display_text展示过滤条件
1. 从 p['filters'] 列表提取filter条件(数据库实际存储格式)
2. 兼容dict直接字段和list两种filter格式
3. tiered仅用于价格不同的阶梯定价
4. display_text展示每条定价的过滤条件
2026-06-05 19:07:11 +08:00
502468e0cb fix: model filter提升为item级适用条件
- model filter从tiered移到item.filters(适用条件)
- tiered只保留区间阶梯定价(如token范围)
- 区分适用条件vs阶梯定价的业务语义
2026-06-05 18:53:33 +08:00
3ad50beda8 refactor: 清理冗余空字段
- 空 filters/filter_labels/formula 不返回
- min_amount=0 不返回
- 返回只保留有实际值的字段
2026-06-05 18:49:51 +08:00
66b4744def fix: tiered只展示价格不同的条目,过滤冗余
- 当tiered价格等于主unit_price时不展示
- 去掉重复的model=qwen3.7-max:6.0等冗余行
2026-06-05 18:47:50 +08:00
38ff69ca55 refactor: 精简pricing_display, tiered只保留有意义filter
- 过滤内部参数: value_mode, xxx_tokens 区间条件不展示
- tiered 只保留 model 等有意义的 filter key
- display_text 去掉冗余 prefix
2026-06-05 18:34:27 +08:00
3eefe1a67f fix: pricing_display 不乘unit_val + 添加可读display_text字段
1. 新格式 unit_prices 已是展示价,不再乘以 unit_val
2. tiered 定价同步修复
3. 返回增加 display_text 字段,格式如官网价格表
2026-06-05 18:26:30 +08:00
2706815dee fix: use DictObject for config_data to support dot notation in derived fields 2026-06-05 16:07:35 +08:00
022bab8314 feat: add derived field support for nested usage data
- Support dot notation in derived expressions (e.g., prompt_tokens_details.cached_tokens)
- Auto-flatten nested dict keys to underscore format for eval
- Calculate uncached_prompt_tokens and cached_tokens from raw usage data
- Test case: qwen3.7-max pricing with derived fields

Test result:
- uncached_prompt_tokens = prompt_tokens - cached_tokens = 1075
- Total cost: 0.007116 元 ✓
2026-06-05 15:04:47 +08:00
df1fa2cfe0 fix: 百万单位定价展示乘以单位值 + 生产混合格式走formula路径
1. get_pricing_display: unit_price 乘以 unit_val 得到人类可读展示价
   (如 4e-06 * 1000000 = 4.0 元/百万tokens)
2. get_pricing_display: unit_values 默认值补全百万/秒/千等映射
3. is_new_format 判断增加类型检查: price_factors为list或unit_prices为dict
   时走旧格式formula路径,避免生产数据TypeError
4. filters区间定价展示价同步乘以unit_val
2026-06-05 14:33:36 +08:00
e2d46f4074 fix: add fallback unit_values in get_pricing_display 2026-06-05 14:32:27 +08:00
da07250049 feat: update unit_values and get_pricing_display for new format
- unit_values: only include units actually used in pricing_data
- get_pricing_display: support new format (price_factors + unit_prices + unit)
- Add fallback unit values for common units
2026-06-05 14:06:58 +08:00
7200454c46 feat: support new pricing format (price_factors + unit_prices)
- get_pricing_from_ymalstr: 支持新格式和旧公式格式
  * 新格式: price_factors + unit_prices + unit 自动计算
  * 旧格式: formula eval 计算(向后兼容)
  * filters: 支持区间定价,多个区间只要匹配一个即可
  * min_amount: 支持最低消费
  * flat: 支持固定费用

- convert_pricing_to_new_design.py: 转换脚本
  * 自动转换 35 条定价记录
  * 无需人工审核

测试通过:
- 多因子计费 (prompt_tokens + completion_tokens)
- 按时长计费 (duration)
- 固定费用 (flat)
- 最低消费 (min_amount)
- 区间定价 (filters)
- 旧公式兼容
2026-06-05 14:00:24 +08:00
392f281758 fix: replace wildcard patterns with explicit per-file entries in load_path.py 2026-06-04 13:03:24 +08:00
977be0d39c feat: add pricing display API for customer-facing pricing data
- generate_formula_from_factors(): auto-generate formula from price_factors array
- get_pricing_display(ppid): return structured human-readable pricing data
- wwwroot/api/get_pricing_display.dspy: API endpoint for frontend consumption
- Supports price_factors display layer (label, unit_price, unit_label)
- Backward compatible: old YAML without pricing_type/price_factors works
- Registered via load_pricing() with ServerEnv
2026-06-04 12:13:55 +08:00
5d4e008ec8 fix: guard hot_reload binding with None check
hasattr only checks attribute existence, but event_dispatcher
can exist as None when running standalone (backend_accounting.py).
Use getattr with None check instead.
2026-06-02 17:36:38 +08:00
ba69fb84d1 debug: add hot_reload handler logging 2026-06-01 22:53:09 +08:00
b7ca795127 cleanup: remove dead module-level get_pricing_program and duplicate assignment 2026-06-01 18:57:50 +08:00
52dd91c6ee chore: remove __pycache__, add to gitignore 2026-06-01 18:10:39 +08:00
7b9d3d2ba0 refactor: bind hot_reload event via EventDispatcher, add on_hot_reload to PricingProgram 2026-06-01 18:10:30 +08:00
71329af722 fix: 定价管理菜单直接进入pricing_program CRUD,跳过index.ui 2026-06-01 15:55:20 +08:00
12472b792f fix: reduce module card height (remove cheight, compact padding/icons) 2026-05-30 21:20:48 +08:00
fc1f8bb182 fix: wrap Tabular in VBox with cheight for proper scrolling 2026-05-29 22:09:53 +08:00
8159c79d55 feat: respect module_cache config for pricing cache 2026-05-29 17:59:06 +08:00
c041c76c9f refactor: use wildcard % in load_path.py for auto-coverage 2026-05-29 00:52:20 +08:00
b7e69b48cd fix: responsive UI with VScrollPanel, cfontsize, css:card 2026-05-29 00:12:52 +08:00
5813749a98 bugfix 2026-05-27 18:17:44 +08:00
c1bdc467a6 refactor(models): convert to json format per database-table-definition-spec 2026-05-27 13:23:30 +08:00
6379c1a0e3 feat: add load_path.py RBAC permission registration script 2026-05-27 13:16:09 +08:00
ce5062215e fix: remove hardcoded dark theme colors from index.ui
- Remove bgcolor/color/border hardcoded dark theme values
- Use css:'card' class for navigation card
- Let system theme handle styling for proper contrast
2026-05-27 11:31:15 +08:00
34 changed files with 2173 additions and 492 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__/

252
README.md
View File

@ -331,6 +331,258 @@ 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`: 过滤条件(如适用模型、分辨率等)
- `filter_labels`: 过滤条件的中文标签
- `price_factors`: 计价因子列表
- `factor`: 因子字段名
- `label`: 因子中文名称
- `unit_price`: 单位价格(已转换为展示价,无需再乘)
- `unit`: 单位(百万、秒、次等)
- `unit_label`: 单位标签(元/百万、元/秒等)
- `tiered`: 阶梯定价(仅当价格不同时展示)
- `display_text`: 人类可读的价格表文本
---
## pricing_data 使用指南
### 两种格式
pricing_data 支持两种格式:
#### 1. 新格式推荐price_factors + unit_prices + unit
适用于简单定价场景,直接指定计价因子和单位价格。
**示例 1Token 定价(文本生成)**
```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 描述,支持字段定义、选项、匹配模式、计算公式

49
i18n/en/msg.txt Normal file
View File

@ -0,0 +1,49 @@
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

49
i18n/jp/msg.txt Normal file
View File

@ -0,0 +1,49 @@
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)
项目名称: プロジェクト名
验证定价: 価格検証

49
i18n/ko/msg.txt Normal file
View File

@ -0,0 +1,49 @@
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)
项目名称: 프로젝트 이름
验证定价: 가격 검증

49
i18n/zh/msg.txt Normal file
View File

@ -0,0 +1,49 @@
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

@ -7,13 +7,22 @@
"browserfields": { "browserfields": {
"exclouded": ["id", "ownerid", "pricing_spec" ], "exclouded": ["id", "ownerid", "pricing_spec" ],
"alters": { "alters": {
"providerid":{ "discount": {
"valueField": "id", "rules": [
"textField": "orgname", {"type": "required", "message": "折扣不能为空"},
"dataurl":"{{entire_url('/rbac/get_provider.dspy')}}" {"type": "number", "message": "折扣必须是数字"},
} {"type": "min", "value": 0, "message": "折扣不能小于0"},
{"type": "max", "value": 1, "message": "折扣不能大于1"}
]
}
} }
}, },
"data_filter": {
"fields": [
{"field": "name", "title": "项目名称", "uitype": "str"},
{"field": "providerid", "title": "供应商", "uitype": "code"}
]
},
"editexclouded": [ "editexclouded": [
"id", "ownerid" "id", "ownerid"
], ],
@ -45,7 +54,8 @@
"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,13 +2,12 @@
"tblname": "pricing_program_timing", "tblname": "pricing_program_timing",
"title": "定价项目时序", "title": "定价项目时序",
"params": { "params": {
"logined_userorgid": "ownerid",
"browserfields": { "browserfields": {
"exclouded": ["id", "ownerid", "ppid" ], "exclouded": ["id", "ppid" ],
"alters": {} "alters": {}
}, },
"editexclouded": [ "editexclouded": [
"id", "ownerid", "ppid", "name" "id", "ppid", "name"
], ],
"subtables":[ "subtables":[
{ {
@ -29,13 +28,16 @@
{ {
"name": "upload_pricing_data", "name": "upload_pricing_data",
"label": "上传定价数据", "label": "上传定价数据",
"selected_row": true,
"icon": "{{entire_url('/bricks/imgs/upload.svg')}}" "icon": "{{entire_url('/bricks/imgs/upload.svg')}}"
}, { }, {
"name": "download_pricing_data", "name": "download_pricing_data",
"label": "下载定价数据", "label": "下载定价数据",
"selected_row": true,
"icon": "{{entire_url('/bricks/imgs/download.svg')}}" "icon": "{{entire_url('/bricks/imgs/download.svg')}}"
}, { }, {
"name": "test", "name": "test",
"selected_row": true,
"label": "验证定价", "label": "验证定价",
"icon": "{{entire_url('/bricks/imgs/test.svg')}}" "icon": "{{entire_url('/bricks/imgs/test.svg')}}"
} }
@ -59,7 +61,10 @@
"wid": "self", "wid": "self",
"event": "upload_pricing_data", "event": "upload_pricing_data",
"actiontype": "urlwidget", "actiontype": "urlwidget",
"target": "self", "target": "PopupWindow",
"popup_options": {
"title": "上传定价数据"
},
"options": { "options": {
"params": { "params": {
"ppid": "{{params_kw.ppid}}" "ppid": "{{params_kw.ppid}}"
@ -87,6 +92,7 @@
"actiontype": "urlwidget", "actiontype": "urlwidget",
"target": "PopupWindow", "target": "PopupWindow",
"popup_options": { "popup_options": {
"title": "验证定价"
}, },
"options":{ "options":{
"params": { "params": {

View File

@ -44,7 +44,8 @@
"name": "discount", "name": "discount",
"title": "供应商折扣", "title": "供应商折扣",
"type": "float", "type": "float",
"length": 18 "length": 18,
"dec": 2
}, },
{ {
"name": "description", "name": "description",

View File

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

View File

@ -3,7 +3,8 @@ from sqlor.dbpools import DBPools
from pricing.pricing import ( from pricing.pricing import (
PricingProgram, PricingProgram,
test_pricing, test_pricing,
get_pricing_program generate_formula_from_factors,
get_pricing_display,
) )
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
@ -27,7 +28,6 @@ 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
@ -35,6 +35,11 @@ def load_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.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:
@ -42,4 +47,3 @@ 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

@ -6,9 +6,22 @@ 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.timeUtils import curDateString from appPublic.timeUtils import curDateString
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的pricing_spec表中定义定价的数据字段 在pricing_program的pricing_spec表中定义定价的数据字段
@ -115,8 +128,10 @@ def typevalue(v, t):
return v return v
return f(v) return f(v)
def check_value(field, spec_value, data_value): def check_value(field, spec_value, data_value, value_mode=None):
if field.value_mode == 'between': 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'
@ -142,17 +157,19 @@ def check_value(field, spec_value, data_value):
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]
e = f'{arr[1]}不认识的期间逻辑,只支持:~ =~ ~=' if 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 field.value_mode == 'in': if 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 = field.value_mode mode = value_mode
if not mode or mode == '=': if not mode or mode == '=':
mode = '==' mode = '=='
ns = { ns = {
@ -173,6 +190,13 @@ def data_mapping(ns, name, v):
class PricingProgram: class PricingProgram:
pricing_data = {} 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})')
PricingProgram.pricing_data.clear()
@staticmethod @staticmethod
async def get_pricing_program(ppid): async def get_pricing_program(ppid):
env = ServerEnv() env = ServerEnv()
@ -424,9 +448,10 @@ 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 = PricingProgram.pricing_data.get(k) if _cache_enabled():
if d: d = PricingProgram.pricing_data.get(k)
return d if 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, sql = """select a.name, a.ownerid, a.providerid,
@ -445,7 +470,8 @@ class PricingProgram:
e = Exception(f'{ppid=},{dat=} data not found') e = Exception(f'{ppid=},{dat=} data not found')
exception(f'{e}') exception(f'{e}')
raise e raise e
d = recs[0] d = recs[0]
if _cache_enabled():
PricingProgram.pricing_data[k] = d PricingProgram.pricing_data[k] = d
dates = PricingProgram.pricing_data.get(ppid, []) dates = PricingProgram.pricing_data.get(ppid, [])
dates.append(dat) dates.append(dat)
@ -456,15 +482,16 @@ class PricingProgram:
PricingProgram.pricing_data[dk] PricingProgram.pricing_data[dk]
dates = dates[-2:] dates = dates[-2:]
PricingProgram.pricing_data[ppid] = dates 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)
prices = PricingProgram.get_pricing_from_ymalstr(data, prices = PricingProgram.get_pricing_from_ymalstr(data,
r.pricing_data) r.pricing_data)
amt = 0.0 amt = 0.0
discount = max(0.0, min(1.0, r.discount)) if r.discount is not None else 1.0
for p in prices: for p in prices:
p.cost = p.amount * r.discount p.cost = p.amount * discount
return prices return prices
async def charging(sor, ppid, data): async def charging(sor, ppid, data):
@ -496,20 +523,28 @@ order by b.enabled_date desc"""
r.pricing_data) r.pricing_data)
debug(f'{r.prices=}') debug(f'{r.prices=}')
amt = 0.0 amt = 0.0
discount = max(0.0, min(1.0, r.discount)) if r.discount is not None else 1.0
for p in r.prices: for p in r.prices:
p.cost = p.amount * r.discount p.cost = p.amount * 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):
""" """
yamlstr是从 解析定价YAML并计算费用
支持两种格式
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
config_data = config_data.copy() # 用 DictObject 包装,支持 dot notation 属性访问derived 表达式依赖此特性)
config_data = DictObject(**config_data)
d = None d = None
try: try:
d = yaml.safe_load(yamlstr) d = yaml.safe_load(yamlstr)
@ -523,19 +558,62 @@ 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
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: # 跳过需要人工审核的记录
debug(f'无公式:{p=}') if p.get('_NEEDS_MANUAL_REVIEW'):
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 == 'formula': if k in skip_keys:
continue continue
f = d.fields.get(k) f = d.fields.get(k)
if not f: if not f:
@ -543,8 +621,6 @@ 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():
@ -556,48 +632,338 @@ 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:
np = p.copy() # 检查 filters 区间条件新格式AND逻辑所有filter_item都要匹配
formula = p.formula # 带 unit_prices 的 filter_item 是 tiered 定价,跳过(由后面第二块处理)
if not formula: if p_ok and is_new_format and 'filters' in p:
e = f'{p} not formula found' for filter_item in p['filters']:
exception(e) if 'unit_prices' in filter_item:
raise Exception(e) continue # tiered定价项不在此处检查
debug(f'{formula=}, {ns=}, {p=}, {d.fields=}') item_ok = True
np.data = config_data item_value_mode = filter_item.get('value_mode')
np.amount = eval(formula, config_data.copy()) 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.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:
for filter_item in p['filters']:
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) ret_items.append(np)
else:
info(f'{config_data=}, {p=}, {d.model_mappings=}, mismatched') elif is_old_format:
# 旧格式formula
formula = p.formula
debug(f'{formula=}, {ns=}, {p=}, {d.fields=}')
env_data = DictObject(config_data)
np.amount = eval(formula, env_data)
ret_items.append(np)
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
async def get_pricing_program(ppid): def generate_formula_from_factors(price_factors):
env = ServerEnv() """从 price_factors 数组自动生成 formula 字符串。
async with get_sor_context(env, 'pricing') as sor:
recs = await sor.R('pricing_program', {'id': ppid}) price_factors 格式:
if len(recs) == 0: [
exception(f'{ppid} not found in pricing_program_timing') {"factor": "prompt_tokens", "unit_price": 3.2, "unit_label": "元/百万Token"},
return None {"factor": "completion_tokens", "unit_price": 16.0, "unit_label": "元/百万Token"}
pp = recs[0] ]
if ppt.pricing_data is None: 返回: "(3.2 * float(prompt_tokens) + 16.0 * float(completion_tokens)) / 1000000.0"
exception(f'{ppid} pricing_data is None in pricing_program_timing') """
return None if not price_factors:
try: return None
PricingProgram.pp_db2app(ppt) parts = []
except Exception as e: has_million = False
return None 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):
"""获取定价项目的可读定价数据,供客户/前端展示。
返回结构:
{
"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:
r = await PricingProgram.get_ppid_pricing(ppid)
except Exception:
return None
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})
items = []
for p in pricings:
# 跳过需要人工审核的记录
if p.get('_NEEDS_MANUAL_REVIEW'):
continue
# 提取过滤条件role=filter 的字段)
filters = {}
filter_labels = {}
skip_keys = {'formula', 'price_factors', 'unit_prices', 'unit', 'filters', 'min_amount', 'pricing_type', 'name'}
# 先从 p.items() 中提取(直接字段)
for k, v in p.items():
if k in skip_keys:
continue
fdef = fields.get(k, {})
role = fdef.get('role', 'filter') if isinstance(fdef, dict) else 'filter'
if role == 'filter':
filters[k] = v
label = fdef.get('label', k) if isinstance(fdef, dict) else k
filter_labels[label] = v
# 再从 p['filters'] 列表中提取(如果存在)
raw_filters = p.get('filters')
if isinstance(raw_filters, list):
for fi in raw_filters:
if isinstance(fi, dict):
for k, v in fi.items():
fdef = fields.get(k, {})
filters[k] = v
label = fdef.get('label', k) if isinstance(fdef, dict) else k
filter_labels[label] = v
# 新格式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 ""
price_factors_display = [{
'factor': factor_name,
'label': factor_label,
'unit_price': display_price,
'unit': unit_str,
'unit_label': unit_label
}]
# 处理 filters: 提取适用条件(model)到item级区间条件放tiered
# 注意filters 已在上方提取(支持 dict 和 list 两种格式)
# tiered 仅用于价格不同的阶梯定价
tiered_pricing = []
if isinstance(p.get('filters'), list):
for fi in p['filters']:
if not isinstance(fi, dict):
continue
raw_tier_price = fi.get('unit_prices')
if raw_tier_price is None or raw_tier_price == unit_price:
continue
fi_copy = {k: v for k, v in fi.items()
if k not in ('unit_prices', 'value_mode')
and not k.endswith('_tokens')}
if fi_copy:
tiered_pricing.append({
'filters': fi_copy,
'unit_prices': raw_tier_price
})
item = {'price_factors': price_factors_display}
if filters:
item['filters'] = filters
item['filter_labels'] = filter_labels
min_amount = p.get('min_amount', 0)
if min_amount:
item['min_amount'] = min_amount
if tiered_pricing:
item['price_factors'][0]['tiered'] = tiered_pricing
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)
# 生成可读价格表
display_lines = [f"{getattr(r, 'name', '')}】定价:"]
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', '')
if up is not None:
display_lines.append(f" - {label}: {up} {ul}{filter_text}")
if pf.get('tiered'):
for t in pf['tiered']:
t_filters = ', '.join(f"{k}={v}" for k, v in t.get('filters', {}).items())
t_price = t.get('unit_prices', '')
display_lines.append(f" · {t_filters}: {t_price} {ul}")
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()
@ -617,8 +983,10 @@ async def get_pricing_program_timeing(pptid):
return ppt return ppt
async def test_pricing(pptid, data): async def test_pricing(pptid, data):
ppt = get_pricing_program_timeing(pptid) ppt = await get_pricing_program_timeing(pptid)
prices = PricingProgram.get_pricing_from_ymalstr(data, ppt.pricing_data) # ppt.pricing_data 已被 ppt_db2app 解析为 dict需要转回 YAML 字符串
yamlstr = yaml.dump(ppt.pricing_data, allow_unicode=True) if isinstance(ppt.pricing_data, dict) else ppt.pricing_data
prices = PricingProgram.get_pricing_from_ymalstr(data, yamlstr)
if prices is None: if prices is None:
return None return None
amount = 0 amount = 0

Binary file not shown.

115
scripts/load_path.py Normal file
View File

@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
pricing 模块 RBAC 权限管理脚本
使用方法:
cd ~/repos/sage
./py3/bin/python ~/repos/pricing/scripts/load_path.py
"""
import subprocess
import os
import sys
def find_sage_root():
candidates = [
os.path.expanduser("~/repos/sage"),
os.path.expanduser("~/sage"),
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
]
for c in candidates:
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"
# ============================================================
# 权限路径定义(逐条列举,禁止通配符)
# ============================================================
# any — 无需登录
PATHS_ANY = [
f"/{MOD}/menu.ui",
]
# logined — 所有已登录用户
PATHS_LOGINED = [
# 模块入口
f"/{MOD}",
f"/{MOD}/index.ui",
# 顶层 .ui 页面
f"/{MOD}/load_pricing_data.ui",
f"/{MOD}/pricing_test.ui",
f"/{MOD}/test_pricing_program.ui",
# 顶层 .dspy 文件
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",
# api/ .dspy 文件
f"/{MOD}/api/get_pricing_display.dspy",
# CRUD: pricing_program (auto-generated by xls2ui)
f"/{MOD}/pricing_program",
f"/{MOD}/pricing_program/index.ui",
f"/{MOD}/pricing_program/get_pricing_program.dspy",
f"/{MOD}/pricing_program/add_pricing_program.dspy",
f"/{MOD}/pricing_program/update_pricing_program.dspy",
f"/{MOD}/pricing_program/delete_pricing_program.dspy",
# CRUD: pricing_program_timing (auto-generated by xls2ui)
f"/{MOD}/pricing_program_timing",
f"/{MOD}/pricing_program_timing/index.ui",
f"/{MOD}/pricing_program_timing/get_pricing_program_timing.dspy",
f"/{MOD}/pricing_program_timing/add_pricing_program_timing.dspy",
f"/{MOD}/pricing_program_timing/update_pricing_program_timing.dspy",
f"/{MOD}/pricing_program_timing/delete_pricing_program_timing.dspy",
]
# ============================================================
# 执行注册
# ============================================================
def run_set_perm(role, path):
cmd = [PYTHON, SET_PERM_SCRIPT, role, path]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
def register_role_paths(role, paths):
count = 0
for p in paths:
if run_set_perm(role, p):
count += 1
print(f" {role}: {count}/{len(paths)} paths registered")
return count
def main():
print(f"Sage root: {SAGE_ROOT}")
total = 0
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

@ -0,0 +1,10 @@
ppid = params_kw.get('ppid')
if not ppid:
return json.dumps({"status": "error", "message": "ppid parameter required"}, ensure_ascii=False)
try:
result = await get_pricing_display(ppid)
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

@ -3,8 +3,7 @@
"options": { "options": {
"width": "100%", "width": "100%",
"height": "100%", "height": "100%",
"padding": "0", "padding": "0"
"bgcolor": "#0B1120"
}, },
"subwidgets": [ "subwidgets": [
{ {
@ -18,9 +17,7 @@
{ {
"widgettype": "Title2", "widgettype": "Title2",
"options": { "options": {
"text": "定价管理", "text": "定价管理"
"color": "#F1F5F9",
"fontWeight": "700"
} }
}, },
{ {
@ -30,70 +27,77 @@
"widgettype": "Text", "widgettype": "Text",
"options": { "options": {
"text": "模型定价项目与计费规则配置", "text": "模型定价项目与计费规则配置",
"fontSize": "14px", "cfontsize": 1.2
"color": "#64748B"
} }
} }
] ]
}, },
{ {
"widgettype": "VBox", "widgettype": "VScrollPanel",
"options": { "options": {
"bgcolor": "#1E293B", "css": "filler"
"padding": "24px",
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
}, },
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.pricing_content",
"options": {
"url": "{{entire_url('/pricing/pricing_program')}}"
},
"mode": "replace"
}
],
"subwidgets": [ "subwidgets": [
{ {
"widgettype": "Svg", "widgettype": "VBox",
"options": { "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>", "spacing": 24
"width": "36px", },
"height": "36px", "subwidgets": [
"marginBottom": "16px" {
} "widgettype": "ResponsableBox",
"options": {
"gap": "16px",
"minWidth": "300px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"css": "card",
"cwidth": 25,
"padding": "16px",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.pricing_content",
"options": {
"url": "{{entire_url('/pricing/pricing_program')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Title4",
"options": {
"text": "定价项目管理",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "管理模型定价规则、计费项目和定时任务",
"cfontsize": 1.2
}
}
]
}
]
}
]
}, },
{ {
"widgettype": "Title4", "widgettype": "VBox",
"options": { "id": "pricing_content"
"text": "定价项目管理",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "管理模型定价规则、计费项目和定时任务",
"fontSize": "14px",
"color": "#94A3B8"
}
} }
] ]
},
{
"widgettype": "VBox",
"id": "pricing_content",
"css": "filler",
"options": {
"width": "100%",
"overflowY": "auto"
}
} }
] ]
} }

View File

@ -4,6 +4,7 @@
"options": { "options": {
"title": "{{pp.id}}", "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/index.ui')}}" "url": "{{entire_url('/pricing/pricing_program')}}"
} }
] ]
} }

View File

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

View File

@ -1,118 +0,0 @@
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

@ -1,6 +0,0 @@
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

@ -1,189 +0,0 @@
{
"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,99 @@
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
userorgid = await get_userorgid()
if not userorgid:
return {
"widgettype":"Error",
"options":{
"title":"Authorization Error",
"timeout":3,
"cwidth":16,
"cheight":9,
"message":"Please login"
}
}
ns['ownerid'] = userorgid
_validation_rules = json.loads(r'''{"discount": [{"type": "required", "message": "折扣不能为空"}, {"type": "number", "message": "折扣必须是数字"}, {"type": "min", "value": 0, "message": "折扣不能小于0"}, {"type": "max", "value": 1, "message": "折扣不能大于1"}]}''')
import re as _re
_errors = []
for _fname, _rules in _validation_rules.items():
_val = params_kw.get(_fname, '')
if _val is None: _val = ''
_val = str(_val)
for _rule in _rules:
_rt = _rule.get('type', '')
_rm = _rule.get('message', _fname)
_rv = _rule.get('value')
if _rt == 'required':
if not _val or _val.strip() == '':
_errors.append(_rm)
break
elif _rt == 'minlength':
if _val and len(_val) < int(_rv):
_errors.append(_rm)
break
elif _rt == 'maxlength':
if len(_val) > int(_rv):
_errors.append(_rm)
break
elif _rt in ('min', 'max'):
if _val:
try:
_n = float(_val)
if _rt == 'min' and _n < float(_rv): _errors.append(_rm); break
if _rt == 'max' and _n > float(_rv): _errors.append(_rm); break
except (ValueError, TypeError):
_errors.append(_rm)
break
elif _rt == 'pattern':
if _val and not _re.match(_rv, _val):
_errors.append(_rm)
break
elif _rt == 'email':
if _val and not _re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+$', _val):
_errors.append(_rm)
break
elif _rt == 'number':
if _val:
try: float(_val)
except (ValueError, TypeError): _errors.append(_rm); break
if _errors:
return {"widgettype":"Error","options":{"title":"Validation Failed","cwidth":16,"cheight":9,"timeout":3,"message":"; ".join(_errors)}}
db = DBPools()
dbname = get_module_dbname('pricing')
async with db.sqlorContext(dbname) as sor:
r = await sor.C('pricing_program', ns.copy())
return {
"widgettype":"Message",
"options":{
"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,47 @@
ns = {
'id':params_kw['id'],
}
userorgid = await get_userorgid()
if not userorgid:
return {
"widgettype":"Error",
"options":{
"title":"Authorization Error",
"timeout":3,
"cwidth":16,
"cheight":9,
"message":"Please login"
}
}
ns['ownerid'] = userorgid
db = DBPools()
dbname = get_module_dbname('pricing')
async with db.sqlorContext(dbname) as sor:
r = await sor.D('pricing_program', 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,135 @@
ns = params_kw.copy()
userorgid = await get_userorgid()
if not userorgid:
return {
"widgettype":"Error",
"options":{
"title":"Authorization Error",
"timeout":3,
"cwidth":16,
"cheight":9,
"message":"Please login"
}
}
ns['ownerid'] = userorgid
ns['userorgid'] = userorgid
debug(f'get_pricing_program.dspy:{ns=}')
if not ns.get('page'):
ns['page'] = 1
if not ns.get('sort'):
ns['sort'] = 'name'
sql = '''select a.*, b.ownerid_text, c.providerid_text, d.pricing_belong_text
from (select * from pricing_program where 1=1 [[filterstr]]) a left join (select id as ownerid,
orgname as ownerid_text from organization where 1 = 1) b on a.ownerid = b.ownerid left join (select id as providerid,
orgname as providerid_text from organization where 1 = 1) c on a.providerid = c.providerid left join (select k as pricing_belong,
v as pricing_belong_text from appcodes_kv where parentid='pricing_belong') d on a.pricing_belong = d.pricing_belong'''
filterjson = params_kw.get('data_filter')
if filterjson and isinstance(filterjson, str):
try:
filterjson = json.loads(filterjson)
except (json.JSONDecodeError, TypeError):
filterjson = None
# data_filter可能是CRUD字段定义({"fields":[...]}),不是过滤条件,忽略
if filterjson and isinstance(filterjson, dict) and 'fields' in filterjson:
filterjson = None
fields_str=r'''[
{
"name": "id",
"title": "id",
"type": "str",
"length": 32
},
{
"name": "name",
"title": "项目名称",
"type": "str",
"length": 256
},
{
"name": "ownerid",
"title": "所属机构",
"type": "str",
"length": 32
},
{
"name": "providerid",
"title": "供应商",
"type": "str",
"length": 32
},
{
"name": "pricing_belong",
"title": "定价属于",
"type": "str",
"length": 32
},
{
"name": "discount",
"title": "供应商折扣",
"type": "float",
"length": 18,
"dec": 2
},
{
"name": "description",
"title": "描述",
"type": "text"
},
{
"name": "pricing_spec",
"title": "规格明细",
"type": "text"
}
]'''
ori_fields = json.loads(fields_str)
if not filterjson:
fields = [ f['name'] for f in ori_fields ]
filterjson = default_filterjson(fields, ns)
# 确保 logined 过滤条件始终生效
if filterjson:
if not isinstance(filterjson, dict) or 'AND' not in filterjson:
filterjson = {'AND': [filterjson] if filterjson else []}
filterjson['AND'].append({'field': 'ownerid', 'op': '=', 'var': '__logined_orgid__'})
ns['__logined_orgid__'] = userorgid
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)
return r
return {
"total":0,
"rows":[]
}

View File

@ -0,0 +1,289 @@
{
"widgettype":"VBox",
"options":{"cheight":40,"width":"100%"},
"subwidgets":[{
"id":"pricing_program_tbl",
"widgettype":"Tabular",
"options":{
"width":"100%",
"height":"100%",
"title":"定价项目",
"toolbar":{
"tools": [
{
"name": "test",
"label": "测试",
"selected_row": true,
"icon": "{{entire_url('/bricks/imgs/test.svg')}}"
},
{
"selected_row": true,
"name": "pricing_program_timing",
"icon": "{{entire_url('/imgs/pricing_program_timing.svg')}}",
"label": "定价项目时序"
}
]
},
"css":"card",
"editable":{
"new_data_url":"{{entire_url('add_pricing_program.dspy')}}",
"delete_data_url":"{{entire_url('delete_pricing_program.dspy')}}",
"update_data_url":"{{entire_url('update_pricing_program.dspy')}}"
},
"data_url":"{{entire_url('./get_pricing_program.dspy')}}",
"data_method":"GET",
"data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}},
"row_options":{
"browserfields": {
"exclouded": [
"id",
"ownerid",
"pricing_spec"
],
"alters": {
"providerid": {
"valueField": "id",
"textField": "orgname",
"dataurl": "{{entire_url('/rbac/get_provider.dspy')}}"
},
"discount": {
"rules": [
{
"type": "required",
"message": "折扣不能为空"
},
{
"type": "number",
"message": "折扣必须是数字"
},
{
"type": "min",
"value": 0,
"message": "折扣不能小于0"
},
{
"type": "max",
"value": 1,
"message": "折扣不能大于1"
}
]
}
}
},
"editexclouded":[
"id",
"ownerid"
],
"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": "ownerid",
"title": "所属机构",
"type": "str",
"length": 32,
"label": "所属机构",
"uitype": "code",
"valueField": "ownerid",
"textField": "ownerid_text",
"params": {
"dbname": "{{get_module_dbname('pricing')}}",
"table": "organization",
"tblvalue": "id",
"tbltext": "orgname",
"valueField": "ownerid",
"textField": "ownerid_text"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
{
"name": "providerid",
"title": "供应商",
"type": "str",
"length": 32,
"label": "供应商",
"uitype": "code",
"valueField": "id",
"textField": "orgname",
"params": {
"dbname": "{{get_module_dbname('pricing')}}",
"table": "organization",
"tblvalue": "id",
"tbltext": "orgname",
"valueField": "providerid",
"textField": "providerid_text"
},
"dataurl": "{{entire_url('/rbac/get_provider.dspy')}}"
},
{
"name": "pricing_belong",
"title": "定价属于",
"type": "str",
"length": 32,
"label": "定价属于",
"uitype": "code",
"valueField": "pricing_belong",
"textField": "pricing_belong_text",
"params": {
"dbname": "{{get_module_dbname('pricing')}}",
"table": "appcodes_kv",
"tblvalue": "k",
"tbltext": "v",
"valueField": "pricing_belong",
"textField": "pricing_belong_text",
"cond": "parentid='pricing_belong'"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
{
"name": "discount",
"title": "供应商折扣",
"type": "float",
"length": 18,
"dec": 2,
"cwidth": 18,
"uitype": "float",
"datatype": "float",
"label": "供应商折扣",
"rules": [
{
"type": "required",
"message": "折扣不能为空"
},
{
"type": "number",
"message": "折扣必须是数字"
},
{
"type": "min",
"value": 0,
"message": "折扣不能小于0"
},
{
"type": "max",
"value": 1,
"message": "折扣不能大于1"
}
]
},
{
"name": "description",
"title": "描述",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "描述"
},
{
"name": "pricing_spec",
"title": "规格明细",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "规格明细"
}
]
},
"page_rows":160,
"cache_limit":5
}
,"binds":[
{
"wid": "self",
"event": "test",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"width": "70%",
"height": "70%",
"auto_open": true,
"archor": "cc",
"title": "定价测试"
},
"options": {
"url": "{{entire_url('../test_pricing_program.ui')}}",
"params": {}
}
},
{
"wid": "self",
"event": "pricing_program_timing",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "定价项目时序",
"icon": "{{entire_url('/appbase/get_icon.dspy')}}?id=pricing_program_timing",
"resizable": true,
"height": "70%",
"width": "70%"
},
"params_mapping": {
"mapping": {
"id": "ppid",
"referer_widget": "referer_widget"
},
"need_other": false
},
"options": {
"method": "POST",
"params": {},
"url": "{{entire_url('../pricing_program_timing')}}"
}
}
]
}]
}

View File

@ -0,0 +1,118 @@
ns = params_kw.copy()
for k,v in ns.items():
if v == 'NaN' or v == 'null':
ns[k] = None
userorgid = await get_userorgid()
if not userorgid:
return {
"widgettype":"Error",
"options":{
"title":"Authorization Error",
"timeout":3,
"cwidth":16,
"cheight":9,
"message":"Please login"
}
}
ns['ownerid'] = userorgid
_validation_rules = json.loads(r'''{"discount": [{"type": "required", "message": "折扣不能为空"}, {"type": "number", "message": "折扣必须是数字"}, {"type": "min", "value": 0, "message": "折扣不能小于0"}, {"type": "max", "value": 1, "message": "折扣不能大于1"}]}''')
import re as _re
_errors = []
for _fname, _rules in _validation_rules.items():
_val = params_kw.get(_fname, '')
if _val is None: _val = ''
_val = str(_val)
for _rule in _rules:
_rt = _rule.get('type', '')
_rm = _rule.get('message', _fname)
_rv = _rule.get('value')
if _rt == 'required':
if not _val or _val.strip() == '':
_errors.append(_rm)
break
elif _rt == 'minlength':
if _val and len(_val) < int(_rv):
_errors.append(_rm)
break
elif _rt == 'maxlength':
if len(_val) > int(_rv):
_errors.append(_rm)
break
elif _rt in ('min', 'max'):
if _val:
try:
_n = float(_val)
if _rt == 'min' and _n < float(_rv): _errors.append(_rm); break
if _rt == 'max' and _n > float(_rv): _errors.append(_rm); break
except (ValueError, TypeError):
_errors.append(_rm)
break
elif _rt == 'pattern':
if _val and not _re.match(_rv, _val):
_errors.append(_rm)
break
elif _rt == 'email':
if _val and not _re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+$', _val):
_errors.append(_rm)
break
elif _rt == 'number':
if _val:
try: float(_val)
except (ValueError, TypeError): _errors.append(_rm); break
if _errors:
return {"widgettype":"Error","options":{"title":"Validation Failed","cwidth":16,"cheight":9,"timeout":3,"message":"; ".join(_errors)}}
db = DBPools()
dbname = get_module_dbname('pricing')
async with db.sqlorContext(dbname) as sor:
ns1 = {
"ownerid": userorgid,
"id": params_kw.id
}
recs = await sor.R('pricing_program', ns1)
if len(recs) < 1:
return {
"widgettype":"Error",
"options":{
"title":"Update Error",
"cwidth":16,
"cheight":9,
"timeout":3,
"message":"Record no exist or with wrong ownership"
}
}
r = await sor.U('pricing_program', ns)
debug('update success');
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

@ -8,18 +8,16 @@ if not id or len(id) > 32:
id = uuid() id = uuid()
ns['id'] = id ns['id'] = id
db = DBPools() db = DBPools()
dbname = get_module_dbname('pricing') dbname = get_module_dbname('pricing')
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
fs = await sor_get_spec_fields(sor, ns.id) r = await sor.C('pricing_program_timing', ns.copy())
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 { return {
"widgettype":"Message", "widgettype":"Message",
"options":{ "options":{
"user_data":ns,
"cwidth":16, "cwidth":16,
"cheight":9, "cheight":9,
"title":"Add Success", "title":"Add Success",

View File

@ -7,7 +7,7 @@ ns = {
db = DBPools() db = DBPools()
dbname = get_module_dbname('pricing') dbname = get_module_dbname('pricing')
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
r = await sor.D('pricing_item', ns) r = await sor.D('pricing_program_timing', ns)
debug('delete success'); debug('delete success');
return { return {
"widgettype":"Message", "widgettype":"Message",

View File

@ -0,0 +1,93 @@
ns = params_kw.copy()
debug(f'get_pricing_program_timing.dspy:{ns=}')
if not ns.get('page'):
ns['page'] = 1
if not ns.get('sort'):
ns['sort'] = 'id'
sql = '''select a.*, b.ppid_text
from (select * from pricing_program_timing where 1=1 [[filterstr]]) a left join (select id as ppid,
name as ppid_text from pricing_program where 1 = 1) b on a.ppid = b.ppid'''
filterjson = params_kw.get('data_filter')
if filterjson and isinstance(filterjson, str):
try:
filterjson = json.loads(filterjson)
except (json.JSONDecodeError, TypeError):
filterjson = None
# data_filter可能是CRUD字段定义({"fields":[...]}),不是过滤条件,忽略
if filterjson and isinstance(filterjson, dict) and 'fields' in filterjson:
filterjson = None
fields_str=r'''[
{
"name": "id",
"title": "id",
"type": "str",
"length": 32
},
{
"name": "ppid",
"title": "定价项目id",
"type": "str",
"length": 32
},
{
"name": "name",
"title": "名称",
"type": "str",
"length": 256
},
{
"name": "pricing_data",
"title": "定价数据",
"type": "text"
},
{
"name": "enabled_date",
"title": "启用日期",
"type": "date"
},
{
"name": "expired_date",
"title": "失效日期",
"type": "date",
"default": "9999-12-31"
}
]'''
ori_fields = json.loads(fields_str)
if not filterjson:
fields = [ f['name'] for f in ori_fields ]
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)
return r
return {
"total":0,
"rows":[]
}

View File

@ -0,0 +1,260 @@
{
"widgettype":"VBox",
"options":{"cheight":40,"width":"100%"},
"subwidgets":[{
"id":"pricing_program_timing_tbl",
"widgettype":"Tabular",
"options":{
"width":"100%",
"height":"100%",
"title":"定价项目时序",
"toolbar":{
"tools": [
{
"name": "download_pattern",
"label": "定价模版",
"selected_row": true,
"icon": "{{entire_url('/bricks/imgs/download.svg')}}"
},
{
"name": "upload_pricing_data",
"label": "上传定价数据",
"selected_row": true,
"icon": "{{entire_url('/bricks/imgs/upload.svg')}}"
},
{
"name": "download_pricing_data",
"label": "下载定价数据",
"selected_row": true,
"icon": "{{entire_url('/bricks/imgs/download.svg')}}"
},
{
"name": "test",
"selected_row": true,
"label": "验证定价",
"icon": "{{entire_url('/bricks/imgs/test.svg')}}"
},
{
"selected_row": true,
"name": "pricing_item",
"icon": "{{entire_url('/imgs/pricing_item.svg')}}",
"label": "定价细项"
}
]
},
"css":"card",
"editable":{
"new_data_url":"{{entire_url('add_pricing_program_timing.dspy')}}",
"delete_data_url":"{{entire_url('delete_pricing_program_timing.dspy')}}",
"update_data_url":"{{entire_url('update_pricing_program_timing.dspy')}}"
},
"data_url":"{{entire_url('./get_pricing_program_timing.dspy')}}",
"data_method":"GET",
"data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}},
"row_options":{
"browserfields": {
"exclouded": [
"id",
"ppid"
],
"alters": {}
},
"editexclouded":[
"id",
"ppid",
"name"
],
"fields":[
{
"name": "id",
"title": "id",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "id"
},
{
"name": "ppid",
"title": "定价项目id",
"type": "str",
"length": 32,
"label": "定价项目id",
"uitype": "code",
"valueField": "ppid",
"textField": "ppid_text",
"params": {
"dbname": "{{get_module_dbname('pricing')}}",
"table": "pricing_program",
"tblvalue": "id",
"tbltext": "name",
"valueField": "ppid",
"textField": "ppid_text"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
{
"name": "name",
"title": "名称",
"type": "str",
"length": 256,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "名称"
},
{
"name": "pricing_data",
"title": "定价数据",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "定价数据"
},
{
"name": "enabled_date",
"title": "启用日期",
"type": "date",
"length": 0,
"uitype": "date",
"datatype": "date",
"label": "启用日期"
},
{
"name": "expired_date",
"title": "失效日期",
"type": "date",
"default": "9999-12-31",
"length": 0,
"uitype": "date",
"datatype": "date",
"label": "失效日期"
}
]
},
"page_rows":160,
"cache_limit":5
}
,"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": "PopupWindow",
"popup_options": {
"title": "上传定价数据"
},
"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": {
"title": "验证定价"
},
"options": {
"params": {
"id": "{{params_kw.id}}"
},
"method": "POST",
"url": "{{entire_url('../pricing_test.ui')}}"
}
},
{
"wid": "self",
"event": "pricing_item",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "定价细项",
"icon": "{{entire_url('/appbase/get_icon.dspy')}}?id=pricing_item",
"resizable": true,
"height": "70%",
"width": "70%"
},
"params_mapping": {
"mapping": {
"id": "pptid",
"referer_widget": "referer_widget"
},
"need_other": false
},
"options": {
"method": "POST",
"params": {},
"url": "{{entire_url('../pricing_item')}}"
}
}
]
}]
}

View File

@ -7,21 +7,13 @@ for k,v in ns.items():
db = DBPools() db = DBPools()
dbname = get_module_dbname('pricing') dbname = get_module_dbname('pricing')
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
ori = await sor.R('pricing_item', {'id': ns.id})
ori_sv = {} r = await sor.U('pricing_program_timing', ns)
if ori.spec_value: debug('update success');
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 { return {
"widgettype":"Message", "widgettype":"Message",
"options":{ "options":{

View File

@ -4,19 +4,26 @@ 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)
return { subwidgets = []
"status": "ok", if isinstance(x, list):
"data": { for r in x:
"ppid": ppid, subwidgets.append({
"data": data, "widgettype": "Text",
"result": x "options": {"text": json.dumps(r, ensure_ascii=False), "width": "100%"}
} })
} 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 { return json.dumps({
"status": "error", "widgettype": "Text",
"data": { "options": {"width": "100%", "color": "red", "text": f'{ppid=}, {data=}, {e}'}
"message": f'{ppid=}, {data=}, {e}' }, ensure_ascii=False)
}
}

View File

@ -29,7 +29,7 @@
} }
}, },
{ {
"widgettype": "VBox", "widgettype": "VScrollPanel",
"id": "result", "id": "result",
"options": { "options": {
"height": "50%", "height": "50%",
@ -41,28 +41,11 @@
{ {
"wid": "input_form", "wid": "input_form",
"event": "submit", "event": "submit",
"actiontype": "urldata", "actiontype": "urlwidget",
"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}"
}
}
} }
} }
] ]