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 描述,支持字段定义、选项、匹配模式、计算公式

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": {
"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"}
]
}
}
},
"data_filter": {
"fields": [
{"field": "name", "title": "项目名称", "uitype": "str"},
{"field": "providerid", "title": "供应商", "uitype": "code"}
]
},
"editexclouded": [
"id", "ownerid"
],
@ -45,7 +54,8 @@
"width": "70%",
"height": "70%",
"auto_open": true,
"archor": "cc"
"archor": "cc",
"title": "定价测试"
},
"options": {
"url": "{{entire_url('../test_pricing_program.ui')}}",

View File

@ -2,13 +2,12 @@
"tblname": "pricing_program_timing",
"title": "定价项目时序",
"params": {
"logined_userorgid": "ownerid",
"browserfields": {
"exclouded": ["id", "ownerid", "ppid" ],
"exclouded": ["id", "ppid" ],
"alters": {}
},
"editexclouded": [
"id", "ownerid", "ppid", "name"
"id", "ppid", "name"
],
"subtables":[
{
@ -29,13 +28,16 @@
{
"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')}}"
}
@ -59,7 +61,10 @@
"wid": "self",
"event": "upload_pricing_data",
"actiontype": "urlwidget",
"target": "self",
"target": "PopupWindow",
"popup_options": {
"title": "上传定价数据"
},
"options": {
"params": {
"ppid": "{{params_kw.ppid}}"
@ -87,6 +92,7 @@
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "验证定价"
},
"options":{
"params": {

View File

@ -44,7 +44,8 @@
"name": "discount",
"title": "供应商折扣",
"type": "float",
"length": 18
"length": 18,
"dec": 2
},
{
"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 (
PricingProgram,
test_pricing,
get_pricing_program
generate_formula_from_factors,
get_pricing_display,
)
from ahserver.serverenv import ServerEnv
@ -27,7 +28,6 @@ def _bind_pricing_events(dbpools, dbname):
def load_pricing():
env = ServerEnv()
env.get_pricing_program = get_pricing_program
env.write_pricing_patten = PricingProgram.write_pricing_patten
env.write_pricing_data = PricingProgram.write_pricing_data
env.pricing_program_charging = PricingProgram.charging
@ -35,6 +35,11 @@ def load_pricing():
env.load_pricing_data = PricingProgram.load_pricing_data
env.get_pricing_program = PricingProgram.get_pricing_program
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()
dbname = env.get_module_dbname('pricing')
if dbname:
@ -42,4 +47,3 @@ def load_pricing():
debug(f'Pricing event listeners bound for database: {dbname}')
else:
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.timeUtils import curDateString
from appPublic.dictObject import DictObject
from appPublic.jsonConfig import getConfig
from .write_pattern import write_pattern_xlsx, load_xlsx_pricing
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描述定价策略
在pricing_program的pricing_spec表中定义定价的数据字段
@ -115,8 +128,10 @@ def typevalue(v, t):
return v
return f(v)
def check_value(field, spec_value, data_value):
if field.value_mode == 'between':
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()
if len(arr) < 2 or len(arr) > 3:
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]
if 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)
raise Exception(e)
if field.value_mode == 'in':
if value_mode == 'in':
arr = spec_value.strip().split()
arr = [ typevalue(a, field.type) for a in arr ]
# debug(f'{arr=}, {data_value=}')
return data_value in arr
mode = field.value_mode
mode = value_mode
if not mode or mode == '=':
mode = '=='
ns = {
@ -173,6 +190,13 @@ def data_mapping(ns, name, v):
class PricingProgram:
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
async def get_pricing_program(ppid):
env = ServerEnv()
@ -424,9 +448,10 @@ class PricingProgram:
async def get_ppid_pricing(ppid):
dat = curDateString()
k = f'{ppid}.{dat}'
d = PricingProgram.pricing_data.get(k)
if d:
return d
if _cache_enabled():
d = PricingProgram.pricing_data.get(k)
if d:
return d
env = ServerEnv()
async with get_sor_context(env, 'pricing') as sor:
sql = """select a.name, a.ownerid, a.providerid,
@ -445,7 +470,8 @@ class PricingProgram:
e = Exception(f'{ppid=},{dat=} data not found')
exception(f'{e}')
raise e
d = recs[0]
d = recs[0]
if _cache_enabled():
PricingProgram.pricing_data[k] = d
dates = PricingProgram.pricing_data.get(ppid, [])
dates.append(dat)
@ -456,15 +482,16 @@ class PricingProgram:
PricingProgram.pricing_data[dk]
dates = dates[-2:]
PricingProgram.pricing_data[ppid] = dates
return d
return d
async def buffered_charging(ppid, data):
r = await PricingProgram.get_ppid_pricing(ppid)
prices = PricingProgram.get_pricing_from_ymalstr(data,
r.pricing_data)
r.pricing_data)
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:
p.cost = p.amount * r.discount
p.cost = p.amount * discount
return prices
async def charging(sor, ppid, data):
@ -496,20 +523,28 @@ order by b.enabled_date desc"""
r.pricing_data)
debug(f'{r.prices=}')
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:
p.cost = p.amount * r.discount
p.cost = p.amount * discount
return r.prices
@staticmethod
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:
e = Exception(f'config_data is None, {yamlstr=}')
exception(f'{e=}')
raise e
config_data = config_data.copy()
# 用 DictObject 包装,支持 dot notation 属性访问derived 表达式依赖此特性)
config_data = DictObject(**config_data)
d = None
try:
d = yaml.safe_load(yamlstr)
@ -523,19 +558,62 @@ order by b.enabled_date desc"""
if not d.pricings:
exception(f'{d} has not "pricings"')
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 = []
for i, p in enumerate(d.pricings):
if not p.formula:
debug(f'无公式:{p=}')
# 跳过需要人工审核的记录
if p.get('_NEEDS_MANUAL_REVIEW'):
debug(f'跳过需要人工审核的定价项: {i}')
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
times = 1
unit = 1
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:
continue
if k == 'formula':
if k in skip_keys:
continue
f = d.fields.get(k)
if not f:
@ -543,8 +621,6 @@ order by b.enabled_date desc"""
exception(f'{e}')
raise Exception(e)
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)
if data_value is None:
if 'default' in f.keys():
@ -556,48 +632,338 @@ order by b.enabled_date desc"""
try:
flg = check_value(f, spec_value, data_value)
if not flg:
# 条件不满足
# debug(f'条件不满足:{p=},{spec_value=}, {data_value=}, {k=}')
p_ok = False
break
except Exception as e:
msg = f'{p=},{f}: {spec_value=}, {data_value=}'
exception(f'{e}:{msg}')
break
if p_ok and p.formula:
np = p.copy()
formula = p.formula
if not formula:
e = f'{p} not formula found'
exception(e)
raise Exception(e)
debug(f'{formula=}, {ns=}, {p=}, {d.fields=}')
np.data = config_data
np.amount = eval(formula, config_data.copy())
# 检查 filters 区间条件新格式AND逻辑所有filter_item都要匹配
# 带 unit_prices 的 filter_item 是 tiered 定价,跳过(由后面第二块处理)
if p_ok and is_new_format and 'filters' in p:
for filter_item in p['filters']:
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.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)
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:
e = f'{config_data=}{yamlstr=}没有找到合适的定价'
exception(e)
raise Exception(e)
return ret_items
async def get_pricing_program(ppid):
env = ServerEnv()
async with get_sor_context(env, 'pricing') as sor:
recs = await sor.R('pricing_program', {'id': ppid})
if len(recs) == 0:
exception(f'{ppid} not found in pricing_program_timing')
return None
pp = recs[0]
if ppt.pricing_data is None:
exception(f'{ppid} pricing_data is None in pricing_program_timing')
return None
try:
PricingProgram.pp_db2app(ppt)
except Exception as e:
return None
def generate_formula_from_factors(price_factors):
"""从 price_factors 数组自动生成 formula 字符串。
price_factors 格式:
[
{"factor": "prompt_tokens", "unit_price": 3.2, "unit_label": "元/百万Token"},
{"factor": "completion_tokens", "unit_price": 16.0, "unit_label": "元/百万Token"}
]
返回: "(3.2 * float(prompt_tokens) + 16.0 * float(completion_tokens)) / 1000000.0"
"""
if not price_factors:
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):
"""获取定价项目的可读定价数据,供客户/前端展示。
返回结构:
{
"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):
env = ServerEnv()
@ -617,8 +983,10 @@ async def get_pricing_program_timeing(pptid):
return ppt
async def test_pricing(pptid, data):
ppt = get_pricing_program_timeing(pptid)
prices = PricingProgram.get_pricing_from_ymalstr(data, ppt.pricing_data)
ppt = await get_pricing_program_timeing(pptid)
# 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:
return None
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": {
"width": "100%",
"height": "100%",
"padding": "0",
"bgcolor": "#0B1120"
"padding": "0"
},
"subwidgets": [
{
@ -18,9 +17,7 @@
{
"widgettype": "Title2",
"options": {
"text": "定价管理",
"color": "#F1F5F9",
"fontWeight": "700"
"text": "定价管理"
}
},
{
@ -30,70 +27,77 @@
"widgettype": "Text",
"options": {
"text": "模型定价项目与计费规则配置",
"fontSize": "14px",
"color": "#64748B"
"cfontsize": 1.2
}
}
]
},
{
"widgettype": "VBox",
"widgettype": "VScrollPanel",
"options": {
"bgcolor": "#1E293B",
"padding": "24px",
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
"css": "filler"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.pricing_content",
"options": {
"url": "{{entire_url('/pricing/pricing_program')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"widgettype": "VBox",
"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"
}
"spacing": 24
},
"subwidgets": [
{
"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",
"options": {
"text": "定价项目管理",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "管理模型定价规则、计费项目和定时任务",
"fontSize": "14px",
"color": "#94A3B8"
}
"widgettype": "VBox",
"id": "pricing_content"
}
]
},
{
"widgettype": "VBox",
"id": "pricing_content",
"css": "filler",
"options": {
"width": "100%",
"overflowY": "auto"
}
}
]
}

View File

@ -4,6 +4,7 @@
"options": {
"title": "{{pp.id}}",
"description": {{json.dumps(pp.description)}},
"height": "200%",
"fields":[
{
"name": "xlsx_file",

View File

@ -6,7 +6,7 @@
{
"name": "pricing",
"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()
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())
r = await sor.C('pricing_program_timing', ns.copy())
return {
"widgettype":"Message",
"options":{
"user_data":ns,
"cwidth":16,
"cheight":9,
"title":"Add Success",

View File

@ -7,7 +7,7 @@ ns = {
db = DBPools()
dbname = get_module_dbname('pricing')
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');
return {
"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()
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=}');
r = await sor.U('pricing_program_timing', ns)
debug('update success');
return {
"widgettype":"Message",
"options":{

View File

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

View File

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