feat: 代金券模块v2完成前端层+管理/客户全链路——①12个CRUD dspy重写为薄封装(request,params_kw签名对齐,Message widget返回,零import)②实例新增走发券引擎voucher_issue.dspy(原子占额/券码/有效期,禁裸插入)③作废invalidate(unused→void条件UPDATE+原因追加remark审计)④客户我的代金券页my/index.ui(机构隔离+状态过滤+过期清扫)⑤json CRUD定义修正(规则类型短名/实例状态含void/发券作废弹窗binds/流水noedit只读)⑥load_path.py改pipeline-app版(logined逐路径精确注册含CRUD四件套)⑦seed_welcome_voucher.py WELCOME400种子(400元/前100名/限pipeline/30天/幂等)⑧i18n四语key=value格式89词条⑨pyproject packages含voucher.rules⑩删sage时代遗留(sql/tables.sql,conf,menu.json,手写mysql.ddl.sql违禁)⑪update/delete守卫:模板code不可改/实例仅unused可改客户备注/used禁删保审计/删除回吐发行额度

This commit is contained in:
yumoqing 2026-09-12 19:06:25 +08:00
parent e59c7b1f6c
commit 6cf70983c8
56 changed files with 2389 additions and 1464 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# xls2ui 生成产物(部署时由 build.sh 从 json/ + models/ 重新生成,不入库)
wwwroot/voucher_template_list/
wwwroot/voucher_rule_list/
wwwroot/voucher_instance_list/
wwwroot/voucher_usage_log_list/
build/
*.egg-info/
__pycache__/
*.pyc

324
README.md
View File

@ -1,269 +1,133 @@
# voucher — 代金券模块
# voucher — 代金券模块pipeline-app 宿主)
独立代金券管理模块,支持**可配置规则引擎**,一次性使用,不找零。
独立代金券管理模块:**可配置规则引擎**、一次性使用、不找零、并发安全。
- 宿主pipeline-app库名走 `get_module_dbname('voucher')`pipeline 宿主下= `pipeline`
- 版本2.0.02026-09-12 sqlor 风格全异步重写,替代 1.x sage 时代废代码)
## 核心架构
```
模板(类别) → 定义规则集
实例(代金券)→ 属于某个模板,继承规则
使用流水 → 记录每次消费抵扣
模板 voucher_template面值/发行量/有效期/状态org_id 隔离)
└─ 规则集 voucher_rule可配置增删不改代码按 sort_order 依次校验)
└─ 实例 voucher_instance券码/状态 unused→used|expired|void一次性
└─ 流水 voucher_usage_log订单/抵扣金额/产品,只读审计)
```
### 表结构
| 表 | 说明 |
### 关键机制
| 机制 | 实现 |
|---|---|
| voucher_template | 代金券模板(面值、有效期、发行量、状态) |
| voucher_rule | 规则定义(模板关联,可增删启用禁用) |
| voucher_instance | 券实例一次性使用状态unused/used/expired |
| voucher_usage_log | 使用流水(订单、抵扣金额、产品信息) |
| 一次性使用 | 核销用条件 UPDATE`status='unused'` 守卫)+ affected_rows 判定,并发双花只有一个成功 |
| 发行量控制 | `issued_count<total_count` 原子占额total_count=0 不限量;未使用券删除回吐名额 |
| 规则可配置 | `@register_rule` 注册 validator 纯函数,模板下挂 voucher_rule 行即生效 |
| 作废 | `invalidate_voucher_api`unused→void条件 UPDATE已用/过期/已作废拒绝,原因追加 remark 审计留痕 |
| 过期清扫 | `expire_vouchers`:查可用券/我的券前自动把过期 unused 置 expired |
| 规则损坏 | rule_config 非法 JSON → 拒绝使用(不静默放行,防绕过限制) |
| 注册送券 | bind `pipeline:organization:c:after` 事件:新客户机构注册自动发 WELCOME400 欢迎券;幂等(同机构不重发);「前 N 名」由 total_count 原子占额控制;异常只记日志不阻断注册 |
---
## 内置规则类型短名appcodes_kv 组合键 ≤32 字符约束)
## 可配置规则引擎
| rule_type | rule_config 示例 | 说明 |
|-----------|------------------|------|
| `min_amount` | `{"min_value": 100}` | 最低消费门槛 |
| `max_amount` | `{"max_value": 1000}` | 最高消费上限 |
| `product_type` | `{"product_types": ["pipeline"]}` | 限定产品类型 |
| `product` | `{"products": ["PP_X"]}` | 限定特定产品 |
| `exclude_product` | `{"products": ["PP_X"]}` | 排除特定产品 |
| `max_usage_count` | `{"max_count": 1}` | 最大使用次数 |
| `user_level` | `{"min_level": 2}` | 用户等级下限 |
规则通过 `@register_rule` 装饰器注册到 `RULE_REGISTRY`,新增规则只需写 validator 函数,无需修改引擎代码。
新增规则:`voucher/rules/validators.py` 加一个 `@register_rule('xxx')` 纯函数 + data.json 字典补一行,无需改引擎
### 内置规则类型
## 页面与 API
| rule_type | 说明 | rule_config 示例 |
|-----------|------|------------------|
| `min_amount` | 最低消费门槛 | `{"min_value": 100}` |
| `max_amount` | 最高消费限制 | `{"max_value": 1000}` |
| `applicable_product_type` | 限定产品类型llm/image/video/audio | `{"product_types": ["llm", "image"]}` |
| `applicable_product` | 限定特定产品(具体模型名) | `{"products": ["gpt-4", "claude-3"]}` |
| `exclude_product` | 排除特定产品 | `{"products": ["gpt-4"]}` |
| `max_usage_count` | 最大使用次数 | `{"max_count": 1}` |
| `valid_period` | 有效期检查 | `{}` (由实例 valid_from/valid_to 处理) |
| `user_level` | 用户等级限制 | `{"min_level": 2}` |
### 管理侧logined菜单「代金券管理」工单三角色可见
### 规则执行流程
1. 查询券实例状态unused + 未过期)
2. 加载模板关联的所有启用规则(按 sort_order 排序)
3. 依次执行 validator任一失败即拒绝
4. 全部通过 → 计算抵扣金额 = min(面值, 消费金额)
5. 记录流水 + 标记券为已使用(一次性作废)
| 页面 | 路径 | 说明 |
|---|---|---|
| 入口 | `/voucher/index.ui` | 卡片导航:模板管理/券实例管理/使用流水 |
| 模板管理 | `/voucher/voucher_template_list/index.ui` | CRUD + 工具栏「发券」弹窗 + 子表「规则配置」 |
| 券实例 | `/voucher/voucher_instance_list/index.ui` | 新增=发券引擎(禁裸插入)+ 工具栏「作废」弹窗 + 子表「使用记录」 |
| 使用流水 | `/voucher/voucher_usage_log_list/index.ui` | 只读noedit |
### 新增规则步骤
```python
# rules/validators.py
@register_rule('new_rule_type')
def check_new_rule(config, context):
# config: 从 rule_config JSON 解析
# context: 包含 request_amount, product_type, product_name, user_level 等
if not some_condition:
return False, "不满足条件"
return True, None
```
然后在模板管理界面添加 `rule_type: "new_rule_type"` 的规则记录即可。
管理 API`wwwroot/api/`,全部薄封装 → init.py ServerEnv 函数):
- template/create/update/delete/optionsoptions 返回纯 `[{value,text}]` 数组)
- rule/create/update/delete`rule_types.dspy` 返回已注册类型清单
- instance/`voucher_issue.dspy`发券、update仅 unused 可改客户/备注、deleteused 禁删)、`voucher_invalidate.dspy`(作废)
- `apply_voucher.dspy`(核销)、`get_available.dspy`(内部查可用)
---
### 客户侧logined菜单「我的代金券」
## 与其他模块交互
| 页面/端点 | 路径 | 说明 |
|---|---|---|
| 我的代金券 | `/voucher/my/index.ui` | 本机构全部券(含已用/过期/作废),状态过滤 |
| 自助查询 | `/voucher/api/v1/available.dspy` | 可用券(登录态按机构隔离,支持 product_type/product_name/request_amount 过滤) |
| 列表 API | `/voucher/api/my_vouchers.dspy` | Tabular 契约 `{total, rows}` |
### llmage 模块(消费时使用代金券)
### 其他模块调用ServerEnv
```python
# llmage 的计费逻辑中调用 voucher 引擎
from voucher.rules.engine import apply_voucher, get_available_vouchers
# 查询客户可用代金券
vouchers = get_available_vouchers(sor, customer_id, context={
'product_type': 'llm', # 从 llm.catelog 获取
'product_name': 'gpt-4', # 具体模型名
'request_amount': amount,
'user_level': customer.level
})
# 使用代金券抵扣
ok, deducted, err = apply_voucher(sor, instance_id, customer_id, order_id, context={
'request_amount': amount,
'product_type': 'llm',
'product_name': 'gpt-4'
})
# 批量使用多张券
from voucher.rules.engine import batch_apply_vouchers
result = batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context)
# result: {total_deducted: 150.0, remaining: 50.0, details: [...]}
# remaining > 0 时从余额扣减
# 结算联动product_management/accounting 等):
result = await env.voucher_batch_apply(sor, customer_id, order_id, voucher_ids, context)
# → {'total_deducted': 150.0, 'remaining': 50.0, 'details': [...]}
# remaining > 0 时从余额扣
# context: {request_amount(必填), product_type, product_name, user_level, used_by}
```
### accounting 模块(余额扣减联动)
```python
# accounting 消费流程中先尝试代金券,剩余从余额扣
total_amount = calculate_consumption(customer_id, period)
# Step 1: 尝试代金券抵扣
result = batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context)
# Step 2: 剩余金额从余额扣减
if result['remaining'] > 0:
accounting.deduct_balance(customer_id, result['remaining'], order_id)
# Step 3: 记录完整账单
accounting.record_order(order_id, total_amount,
voucher_deducted=result['total_deducted'],
balance_deducted=result['remaining'])
```
### 客户自助查询 APIvoucher 模块提供)
voucher 模块自身提供面向远端客户的查询 API无需通过 dapi 转发。
**接口**: `GET /voucher/api/v1/available.dspy`
**认证**: Bearer Token登录后用户
**参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| product_type | string | 否 | 产品类型过滤llm/image/video/audio |
| product_name | string | 否 | 具体产品名过滤(如 gpt-4 |
| request_amount | float | 否 | 预期消费金额(用于规则预校验) |
**返回**:
```json
{
"status": "success",
"data": [
{
"id": "xxx",
"code": "VCH-A1B2C3D4E5F6",
"face_value": 50.00,
"valid_from": "2026-01-01 00:00:00",
"valid_to": "2026-01-31 00:00:00",
"template_name": "新用户满减券"
}
],
"total": 1
}
```
**调用示例**:
```bash
# 查询所有可用券
curl -H "Authorization: Bearer <token>" \
https://ai.atvoe.com/voucher/api/v1/available.dspy
# 按产品类型过滤
curl -H "Authorization: Bearer <token>" \
"https://ai.atvoe.com/voucher/api/v1/available.dspy?product_type=llm&request_amount=100"
```
### 集成点总结
| 调用方 | 场景 | 调用方式 |
|--------|------|----------|
| llmage | 推理/生成时抵扣 | `apply_voucher()` 函数调用 |
| accounting | 月度账单结算 | `batch_apply_vouchers()` 函数调用 |
| 远端客户 | 自助查询可用券 | `GET /voucher/api/v1/available.dspy` HTTP API |
| 任意模块 | 新增规则类型 | `@register_rule` + 模板管理界面 |
---
注册函数全集(`load_voucher()``get_available_vouchers_api` / `apply_voucher_api` / `validate_voucher_api` / `my_vouchers_api` / 模板规则实例管理函数 / `issue_voucher_api` / `invalidate_voucher_api` / `get_voucher_template_options` / `get_registered_rule_types` / 引擎级 `voucher_issue` / `voucher_apply` / `voucher_batch_apply` / `voucher_get_available`
## 目录结构
```
voucher/
├── voucher/ # Python 包
│ ├── __init__.py
│ └── init.py # 模块初始化 + ServerEnv 注册
├── rules/ # 规则引擎(纯 Python
│ ├── registry.py # @register_rule 装饰器
│ ├── validators.py # 内置规则校验器
│ └── engine.py # 校验/使用/批量使用
├── wwwroot/ # 前端
│ ├── index.ui # 入口页(卡片导航)
│ ├── menu.json # 菜单定义
│ └── api/ # API 端点
│ ├── template/ # 模板 CRUD
│ ├── rule/ # 规则 CRUD
│ ├── instance/ # 实例 CRUD
│ ├── usage/ # 流水 CRUD
│ ├── v1/ # 客户自助查询 API
│ │ └── available.dspy # 查询可用券Bearer 认证)
│ ├── apply_voucher.dspy # 使用代金券
│ ├── get_available.dspy # 内部查询可用券
│ └── rule_types.dspy # 获取规则类型列表
├── models/ # 表定义 JSON
├── json/ # CRUD 定义 JSON
├── sql/
│ └── tables.sql # 建表 + 编码初始化
│ ├── init.py # ServerEnv 注册 + 注册送券挂钩 + load_voucher()
│ └── rules/ # registry(@register_rule) + validators(7种) + engine(全异步)
├── init/data.json # 字典种子(模板状态/实例状态/规则类型/来源/启用标志)
├── models/ # 4 张表定义建表唯一事实源json2ddl 渲染)
├── json/ # 4 个 CRUD 定义xls2ui 生成 wwwroot/voucher_*_list/,生成物不入库)
├── wwwroot/
│ ├── index.ui # 管理入口(卡片导航)
│ ├── my/index.ui # 客户「我的代金券」
│ ├── issue_form.ui # 发券弹窗表单
│ ├── invalidate_form.ui # 作废弹窗表单
│ └── api/ # 薄封装 dspy零 import委托 init.py 注册函数)
├── scripts/
│ └── load_path.py # RBAC 权限注册
├── pyproject.toml
└── README.md
│ ├── load_path.py # RBAC 注册logined 全路径逐个精确配)
│ └── seed_welcome_voucher.py # WELCOME400 种子(幂等)
├── i18n/{zh,en,jp,ko}/msg.txt # key=value 格式merge_i18n 消费)
└── pyproject.toml # packages = ["voucher", "voucher.rules"]
```
---
## 部署清单pipeline-app 宿主,六处登记)
## 部署步骤
1. `build.sh`clone/pip install/xls2ui/软链四个清单加 `voucher`(已登记)
2. `app/pipeline_app.py`import + `load_voucher()`已登记ticket 之后)
3. `scripts/import_init.py`INIT_MODULES 加 voucher 行(已登记)
4. 建表:`create_tables.py` 动态扫描 pkgs/*/models 自动覆盖(无需改)
5. `wwwroot/index.ui`:客户「我的代金券」+ 管理「代金券管理」菜单已登记is_ticket_staff 门禁)
6. RBAC`./load_path.sh`(自动扫 pkgs/*/scripts/load_path.py`redis-cli -n 0 FLUSHDB` → 重启
### 1. 代码部署
部署后一次性操作:
```bash
cd ~/repos/voucher && git pull
cd ~/repos/sage/pkgs && ln -sf ~/repos/voucher voucher
cd voucher && ~/repos/sage/py3/bin/pip install -e .
cd <APP_ROOT>
./py3/bin/python pkgs/voucher/scripts/seed_welcome_voucher.py # WELCOME400 种子
```
### 2. 数据库建表
```bash
mysql -u root -p sage < ~/repos/voucher/sql/tables.sql
```
## Pitfalls
### 3. Sage 集成
```python
# sage/app/sage.py
from voucher.init import load_voucher
# ... in init():
load_voucher()
```
- **实例「新增」必须走发券 API**voucher_issue.dspy → issue_voucher 引擎):裸插入会绕过原子占额/券码生成/有效期计算。CRUD json 的 `editable.new_data_url` 已指向它。
- **状态字段禁止界面直改**:使用走核销 API、作废走 invalidate API都有条件 UPDATE 原子守卫update_voucher_instance 只允许改 customer_id/remark 且仅 unused 券。
- 一次性使用:不追踪 remaining_value/partial 状态,抵扣 = min(面值, 消费金额),不找零。
- rule_config 数组字段用 JSON arrayvalidators 兼容逗号分隔串,但写入必须是 JSON
- rule_type 用短名product_type 而非 applicable_product_type——appcodes_kv.id = `{parentid}_{k}` ≤32 字符。
- 模板删除已发放issued_count>0拒绝引导用「停用」删除级联删规则已发出的券保留但 validate 拒绝(模板非 active
- dspy 薄封装返回 dict框架自动序列化**禁 json.dumps 双重序列化**Pitfall 38
- datetime 字段回前端必须 str 化(`_jsonable_instance`),框架 json.dumps 无 default=str。
- 弹窗表单 submited 事件的 params 是 **Response 对象**,要 `await resp.json()` 后再 widgetBuildform.js dispatch 原始 resp
```bash
# sage/build.sh 安装循环
for m in ... voucher
```
## 仓库
### 4. 菜单入口
```json
// sage/wwwroot/global_menu.ui items 数组
,{
"name": "voucher",
"label": "代金券",
"icon": "fa fa-ticket",
"url": "{{entire_url('/voucher/index.ui')}}",
"target": "app.sage_main_content"
}
```
### 5. RBAC 权限
```bash
cd ~/repos/sage && ./py3/bin/python ~/repos/voucher/scripts/load_path.py
```
### 6. 重启
```bash
cd ~/repos/sage && ./stop.sh && ./start.sh
```
---
## 使用示例
### 创建模板 + 配置规则
1. 模板管理 → 新增 → 名称"新用户满减券",面值 50有效期 30 天
2. 点击模板 → 规则配置 → 添加规则:
- 规则类型: `min_amount`,配置: `{"min_value": 100}`
- 规则类型: `applicable_product_type`,配置: `{"product_types": ["llm"]}`
### 发放代金券
1. 券实例管理 → 新增 → 选择模板,填写客户 ID
2. 系统自动生成券码,设置有效期
### 消费时抵扣
```python
# llmage 推理完成后
context = {'request_amount': 120.0, 'product_type': 'llm', 'product_name': 'gpt-4'}
result = batch_apply_vouchers(sor, customer_id, order_id, [vid1, vid2], context)
# → 抵扣 50满100可用剩余 70 从余额扣
```
- 远端git@git.opencomputing.cn:yumoqing/voucher.gitmain
- 宿主接入提交pipeline-appbuild.sh/pipeline_app.py/import_init.py/index.ui

View File

@ -1,12 +0,0 @@
{
"module": "voucher",
"databases": {
"sage": {
"host": "${DB_HOST}",
"port": "${DB_PORT}",
"user": "${DB_USER}",
"passwd": "${DB_PASSWD}",
"db": "sage"
}
}
}

View File

@ -1,42 +1,89 @@
Cancel: Cancel
Conform: Confirm
Discard: Discard
Reset: Reset
Submit: Submit
主键ID: Primary Key ID
产品名称: Product Name
产品类型: Product Type
代金券使用流水: Voucher Usage Records
代金券实例: Voucher Instance
代金券模板: Voucher Template
代金券规则定义: Voucher Rule Definition
使用时间: Usage Time
使用的订单ID: Order ID Used
创建人: Creator
创建时间: Created At
券实例ID: Voucher Instance ID
券面值: Face Value
发放时间: Issue Time
唯一券码: Unique Voucher Code
备注: Remark
实际抵扣金额: Actual Deduction Amount
客户ID: Customer ID
已发放量: Issued Quantity
总发行量: Total Issued
执行顺序: Execution Order
操作人: Operator
是否启用: Enabled
更新人: Updater
更新时间: Updated At
有效期天数: Validity Days
来源: Source
模板ID: Template ID
模板名称: Template Name
模板编码: Template Code
状态: Status
生效时间: Effective Time
规则类型: Rule Type
规则配置(JSON): Rule Config (JSON)
订单/消费ID: Order/Consumption ID
过期时间: Expiry Time
面值: Face Value
Cancel=Cancel
Conform=Confirm
Discard=Discard
Reset=Reset
Submit=Submit
代金券=Voucher
代金券管理=Voucher Management
模板管理=Template Management
券实例管理=Voucher Instance Management
使用流水=Usage Records
管理代金券模板和规则配置=Manage voucher templates and rule configuration
查看和发放代金券=View and issue vouchers
查看代金券使用记录=View voucher usage records
我的代金券=My Vouchers
发放代金券=Issue Voucher
作废=Void
确认作废=Confirm Void
作废代金券=Void Voucher
已作废=Voided
作废原因=Void Reason
必填,将追加到券备注(审计留痕)=Required; will be appended to voucher remark (audit trail)
按此模板发券=Issue from this template
代金券模板=Voucher Template
代金券实例=Voucher Instance
代金券规则=Voucher Rule
代金券使用流水=Voucher Usage Log
主键ID=Primary Key ID
模板名称=Template Name
模板编码=Template Code
模板ID=Template ID
模板=Template
面值=Face Value
面值(元)=Face Value (CNY)
已抵扣(元)=Deducted (CNY)
总发行量(0不限)=Total Issue Count (0=unlimited)
已发放量=Issued Count
有效期天数=Valid Days
状态=Status
草稿=Draft
启用=Active
停用=Inactive
备注=Remark
所属机构ID=Organization ID
创建人=Created By
创建时间=Created At
更新时间=Updated At
规则类型=Rule Type
规则配置JSON=Rule Config JSON
是否启用=Enabled
执行顺序=Sort Order
客户机构ID=Customer Organization ID
客户机构=Customer Organization
券码=Voucher Code
未使用=Unused
已使用=Used
已过期=Expired
实际抵扣金额=Actual Deducted
生效时间=Valid From
过期时间=Valid To
发放时间=Issued At
使用时间=Used At
使用订单ID=Order ID
订单ID=Order ID
发放来源=Source
手动发放=Manual
注册赠券=Registration Gift
促销活动=Promotion
发放人=Issued By
券实例ID=Instance ID
券实例=Voucher Instance
券面值=Face Value
抵扣金额=Deducted Amount
产品类型=Product Type
产品名称=Product Name
操作人=Used By
使用记录=Usage Records
规则配置=Rule Configuration
最低消费=Minimum Amount
最高消费=Maximum Amount
限定产品类型=Product Type Limit
限定特定产品=Product Limit
排除特定产品=Product Exclusion
最大使用次数=Max Usage Count
用户等级=User Level
发券=Issue
客户机构ID必填=Customer is required
可选=Optional
查询=Search
全部=All

View File

@ -1,42 +1,89 @@
Cancel: キャンセル
Conform: 確認
Discard: 破棄
Reset: リセット
Submit: 送信
主键ID: 主キーID
产品名称: 製品名
产品类型: 製品タイプ
代金券使用流水: バウチャー使用履歴
代金券实例: バウチャーインスタンス
代金券模板: バウチャーテンプレート
代金券规则定义: バウチャールール定義
使用时间: 使用日時
使用的订单ID: 使用注文ID
创建人: 作成者
创建时间: 作成日時
券实例ID: バウチャーインスタンスID
券面值: 券面額
发放时间: 発行日時
唯一券码: ユニークバウチャーコード
备注: 備考
实际抵扣金额: 実質控除額
客户ID: 顧客ID
已发放量: 発行済み数量
总发行量: 総発行量
执行顺序: 実行順序
操作人: 操作者
是否启用: 有効化
更新人: 更新者
更新时间: 更新日時
有效期天数: 有効日数
来源: ソース
模板ID: テンプレートID
模板名称: テンプレート名
模板编码: テンプレートコード
状态: ステータス
生效时间: 発効日時
规则类型: ルールタイプ
规则配置(JSON): ルール設定(JSON)
订单/消费ID: 注文/消費ID
过期时间: 有効期限
面值: 額面
Cancel=キャンセル
Conform=確認
Discard=破棄
Reset=リセット
Submit=送信
代金券=クーポン
代金券管理=クーポン管理
模板管理=テンプレート管理
券实例管理=クーポンインスタンス管理
使用流水=使用履歴
管理代金券模板和规则配置=クーポンテンプレートとルール設定を管理
查看和发放代金券=クーポンの確認と発行
查看代金券使用记录=クーポン使用記録の確認
我的代金券=マイクーポン
发放代金券=クーポン発行
作废=無効化
确认作废=無効化の確認
作废代金券=クーポン無効化
已作废=無効化済み
作废原因=無効化理由
必填,将追加到券备注(审计留痕)=必須入力。クーポン備考に追記されます(監査証跡)
按此模板发券=このテンプレートで発行
代金券模板=クーポンテンプレート
代金券实例=クーポンインスタンス
代金券规则=クーポンルール
代金券使用流水=クーポン使用履歴
主键ID=主キーID
模板名称=テンプレート名
模板编码=テンプレートコード
模板ID=テンプレートID
模板=テンプレート
面值=額面
面值(元)=額面(元)
已抵扣(元)=控除額(元)
总发行量(0不限)=総発行枚数(0=無制限)
已发放量=発行済み枚数
有效期天数=有効日数
状态=ステータス
草稿=下書き
启用=有効
停用=無効
备注=備考
所属机构ID=所属組織ID
创建人=作成者
创建时间=作成日時
更新时间=更新日時
规则类型=ルールタイプ
规则配置JSON=ルール設定JSON
是否启用=有効フラグ
执行顺序=実行順序
客户机构ID=顧客組織ID
客户机构=顧客組織
券码=クーポンコード
未使用=未使用
已使用=使用済み
已过期=期限切れ
实际抵扣金额=実控除額
生效时间=有効開始
过期时间=有効期限
发放时间=発行日時
使用时间=使用日時
使用订单ID=注文ID
订单ID=注文ID
发放来源=発行元
手动发放=手動発行
注册赠券=登録プレゼント
促销活动=プロモーション
发放人=発行者
券实例ID=インスタンスID
券实例=クーポンインスタンス
券面值=額面
抵扣金额=控除額
产品类型=製品タイプ
产品名称=製品名
操作人=操作者
使用记录=使用記録
规则配置=ルール設定
最低消费=最低消費額
最高消费=最高消費額
限定产品类型=製品タイプ制限
限定特定产品=製品制限
排除特定产品=製品除外
最大使用次数=最大使用回数
用户等级=ユーザーレベル
发券=発行
客户机构ID必填=顧客組織は必須です
可选=任意
查询=検索
全部=すべて

View File

@ -1,42 +1,89 @@
Cancel: 취소
Conform: 확인
Discard: 폐기
Reset: 초기화
Submit: 제출
主键ID: 기본키 ID
产品名称: 제품 이름
产品类型: 제품 유형
代金券使用流水: 바우처 사용 내역
代金券实例: 바우처 인스턴스
代金券模板: 바우처 템플릿
代金券规则定义: 바우처 규칙 정의
使用时间: 사용 시간
使用的订单ID: 사용 주문 ID
创建人: 생성자
创建时间: 생성 시간
券实例ID: 바우처 인스턴스 ID
券面值: 액면가
发放时间: 발급 시간
唯一券码: 고유 바우처 코드
备注: 비고
实际抵扣金额: 실제 공제 금액
客户ID: 고객 ID
已发放量: 발급 수량
总发行量: 총 발행량
执行顺序: 실행 순서
操作人: 운영자
是否启用: 활성화 여부
更新人: 업데이트 담당자
更新时间: 업데이트 시간
有效期天数: 유효 기간(일)
来源: 출처
模板ID: 템플릿 ID
模板名称: 템플릿 이름
模板编码: 템플릿 코드
状态: 상태
生效时间: 효력 시작 시간
规则类型: 규칙 유형
规则配置(JSON): 규칙 구성(JSON)
订单/消费ID: 주문/소비 ID
过期时间: 만료 시간
面值: 액면가
Cancel=취소
Conform=확인
Discard=폐기
Reset=초기화
Submit=제출
代金券=상품권
代金券管理=상품권 관리
模板管理=템플릿 관리
券实例管理=상품권 인스턴스 관리
使用流水=사용 내역
管理代金券模板和规则配置=상품권 템플릿 및 규칙 구성 관리
查看和发放代金券=상품권 조회 및 발급
查看代金券使用记录=상품권 사용 기록 조회
我的代金券=내 상품권
发放代金券=상품권 발급
作废=무효화
确认作废=무효화 확인
作废代金券=상품권 무효화
已作废=무효화됨
作废原因=무효화 사유
必填,将追加到券备注(审计留痕)=필수 입력, 상품권 비고에 추가됩니다(감사 추적)
按此模板发券=이 템플릿으로 발급
代金券模板=상품권 템플릿
代金券实例=상품권 인스턴스
代金券规则=상품권 규칙
代金券使用流水=상품권 사용 내역
主键ID=기본키 ID
模板名称=템플릿 이름
模板编码=템플릿 코드
模板ID=템플릿 ID
模板=템플릿
面值=액면가
面值(元)=액면가(위안)
已抵扣(元)=차감액(위안)
总发行量(0不限)=총 발행량(0=무제한)
已发放量=발행 수량
有效期天数=유효 일수
状态=상태
草稿=초안
启用=활성
停用=비활성
备注=비고
所属机构ID=소속 기관 ID
创建人=생성자
创建时间=생성 시간
更新时间=수정 시간
规则类型=규칙 유형
规则配置JSON=규칙 구성 JSON
是否启用=활성 여부
执行顺序=실행 순서
客户机构ID=고객 기관 ID
客户机构=고객 기관
券码=상품권 코드
未使用=미사용
已使用=사용됨
已过期=만료됨
实际抵扣金额=실제 차감 금액
生效时间=발효 시간
过期时间=만료 시간
发放时间=발급 시간
使用时间=사용 시간
使用订单ID=주문 ID
订单ID=주문 ID
发放来源=발급 출처
手动发放=수동 발급
注册赠券=가입 증정
促销活动=판촉 활동
发放人=발급자
券实例ID=인스턴스 ID
券实例=상품권 인스턴스
券面值=액면가
抵扣金额=차감 금액
产品类型=제품 유형
产品名称=제품 이름
操作人=운영자
使用记录=사용 기록
规则配置=규칙 구성
最低消费=최소 소비
最高消费=최대 소비
限定产品类型=제품 유형 제한
限定特定产品=특정 제품 제한
排除特定产品=특정 제품 제외
最大使用次数=최대 사용 횟수
用户等级=사용자 등급
发券=발급
客户机构ID必填=고객 기관은 필수입니다
可选=선택
查询=조회
全部=전체

View File

@ -1,42 +1,89 @@
Cancel: Cancel
Conform: Conform
Discard: Discard
Reset: Reset
Submit: Submit
主键ID: 主键ID
产品名称: 产品名称
产品类型: 产品类型
代金券使用流水: 代金券使用流水
代金券实例: 代金券实例
代金券模板: 代金券模板
代金券规则定义: 代金券规则定义
使用时间: 使用时间
使用的订单ID: 使用的订单ID
创建人: 创建人
创建时间: 创建时间
券实例ID: 券实例ID
券面值: 券面值
发放时间: 发放时间
唯一券码: 唯一券码
备注: 备注
实际抵扣金额: 实际抵扣金额
客户ID: 客户ID
已发放量: 已发放量
总发行量: 总发行量
执行顺序: 执行顺序
操作人: 操作人
是否启用: 是否启用
更新人: 更新人
更新时间: 更新时间
有效期天数: 有效期天数
来源: 来源
模板ID: 模板ID
模板名称: 模板名称
模板编码: 模板编码
状态: 状态
生效时间: 生效时间
规则类型: 规则类型
规则配置(JSON): 规则配置(JSON)
订单/消费ID: 订单/消费ID
过期时间: 过期时间
面值: 面值
Cancel=Cancel
Conform=Conform
Discard=Discard
Reset=Reset
Submit=Submit
代金券=代金券
代金券管理=代金券管理
模板管理=模板管理
券实例管理=券实例管理
使用流水=使用流水
管理代金券模板和规则配置=管理代金券模板和规则配置
查看和发放代金券=查看和发放代金券
查看代金券使用记录=查看代金券使用记录
我的代金券=我的代金券
发放代金券=发放代金券
作废=作废
确认作废=确认作废
作废代金券=作废代金券
已作废=已作废
作废原因=作废原因
必填,将追加到券备注(审计留痕)=必填,将追加到券备注(审计留痕)
按此模板发券=按此模板发券
代金券模板=代金券模板
代金券实例=代金券实例
代金券规则=代金券规则
代金券使用流水=代金券使用流水
主键ID=主键ID
模板名称=模板名称
模板编码=模板编码
模板ID=模板ID
模板=模板
面值=面值
面值(元)=面值(元)
已抵扣(元)=已抵扣(元)
总发行量(0不限)=总发行量(0不限)
已发放量=已发放量
有效期天数=有效期天数
状态=状态
草稿=草稿
启用=启用
停用=停用
备注=备注
所属机构ID=所属机构ID
创建人=创建人
创建时间=创建时间
更新时间=更新时间
规则类型=规则类型
规则配置JSON=规则配置JSON
是否启用=是否启用
执行顺序=执行顺序
客户机构ID=客户机构ID
客户机构=客户机构
券码=券码
未使用=未使用
已使用=已使用
已过期=已过期
实际抵扣金额=实际抵扣金额
生效时间=生效时间
过期时间=过期时间
发放时间=发放时间
使用时间=使用时间
使用订单ID=使用订单ID
订单ID=订单ID
发放来源=发放来源
手动发放=手动发放
注册赠券=注册赠券
促销活动=促销活动
发放人=发放人
券实例ID=券实例ID
券实例=券实例
券面值=券面值
抵扣金额=抵扣金额
产品类型=产品类型
产品名称=产品名称
操作人=操作人
使用记录=使用记录
规则配置=规则配置
最低消费=最低消费
最高消费=最高消费
限定产品类型=限定产品类型
限定特定产品=限定特定产品
排除特定产品=排除特定产品
最大使用次数=最大使用次数
用户等级=用户等级
发券=发券
客户机构ID必填=客户机构ID必填
可选=可选
查询=查询
全部=全部

53
init/data.json Normal file
View File

@ -0,0 +1,53 @@
{
"appcodes": [
{
"parentid": "voucher_template_status",
"parentname": "代金券模板状态",
"items": [
{"k": "draft", "v": "草稿"},
{"k": "active", "v": "启用"},
{"k": "inactive", "v": "停用"}
]
},
{
"parentid": "voucher_instance_status",
"parentname": "代金券实例状态",
"items": [
{"k": "unused", "v": "未使用"},
{"k": "used", "v": "已使用"},
{"k": "expired", "v": "已过期"},
{"k": "void", "v": "已作废"}
]
},
{
"parentid": "vchr_rule_type",
"parentname": "代金券规则类型",
"items": [
{"k": "min_amount", "v": "最低消费"},
{"k": "max_amount", "v": "最高消费"},
{"k": "product_type", "v": "限定产品类型"},
{"k": "product", "v": "限定特定产品"},
{"k": "exclude_product", "v": "排除特定产品"},
{"k": "max_usage_count", "v": "最大使用次数"},
{"k": "user_level", "v": "用户等级"}
]
},
{
"parentid": "vchr_source",
"parentname": "代金券发放来源",
"items": [
{"k": "manual", "v": "手动发放"},
{"k": "register", "v": "注册赠券"},
{"k": "promo", "v": "促销活动"}
]
},
{
"parentid": "vchr_enabled",
"parentname": "代金券规则启用标志",
"items": [
{"k": "1", "v": "启用"},
{"k": "0", "v": "禁用"}
]
}
]
}

View File

@ -1,47 +1,130 @@
{
"tblname": "voucher_instance",
"alias": "voucher_instance_list",
"title": "代金券实例管理",
"title": "代金券实例",
"params": {
"sortby": ["created_at desc"],
"data_filter": {
"AND": [
{"field": "customer_id", "op": "=", "var": "customer_id"},
{"field": "status", "op": "=", "var": "status"},
{"field": "template_id", "op": "=", "var": "template_id"}
]
},
"sortby": [
"issued_at desc"
],
"browserfields": {
"exclouded": [],
"exclouded": [
"id",
"template_id"
],
"alters": {
"template_id": {
"uitype": "code",
"dataurl": "{{entire_url('../api/template/voucher_template_options.dspy')}}",
"data_field": "options",
"textField": "text",
"valueField": "value"
},
"status": {
"uitype": "code",
"data": [
{"value": "unused", "text": "未使用"},
{"value": "used", "text": "已使用"},
{"value": "expired", "text": "已过期"}
{
"value": "unused",
"text": "未使用"
},
{
"value": "used",
"text": "已使用"
},
{
"value": "expired",
"text": "已过期"
},
{
"value": "void",
"text": "已作废"
}
]
},
"source": {
"uitype": "code",
"data": [
{
"value": "manual",
"text": "手动发放"
},
{
"value": "register",
"text": "注册赠券"
},
{
"value": "promo",
"text": "促销活动"
}
]
}
}
},
"editexclouded": ["used_at", "order_id", "actual_deducted"],
"editexclouded": [
"id",
"code",
"status",
"face_value",
"actual_deducted",
"valid_from",
"valid_to",
"issued_at",
"used_at",
"order_id",
"created_at",
"updated_at"
],
"data_filter": {
"AND": [
{
"field": "customer_id",
"op": "=",
"var": "customer_id"
},
{
"field": "status",
"op": "=",
"var": "status"
}
]
},
"filter_labels": {
"customer_id": "客户机构",
"status": "状态"
},
"editable": {
"new_data_url": "{{entire_url('../api/instance/voucher_instance_create.dspy')}}",
"new_data_url": "{{entire_url('../api/instance/voucher_issue.dspy')}}",
"update_data_url": "{{entire_url('../api/instance/voucher_instance_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/instance/voucher_instance_delete.dspy')}}"
},
"toolbar": {
"tools": [
{
"name": "invalidate",
"label": "作废",
"selected_row": true
}
]
},
"binds": [
{
"wid": "self",
"event": "invalidate",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "作废代金券",
"width": "40%",
"height": "46%",
"archor": "cc",
"resizable": true,
"dismiss_events": [
"cancel",
"submited"
]
},
"options": {
"url": "{{entire_url('/voucher/invalidate_form.ui')}}"
}
}
],
"subtables": [
{
"field": "id",
"field": "instance_id",
"title": "使用记录",
"url": "{{entire_url('../voucher_usage_log_list/')}}",
"url": "{{entire_url('../voucher_usage_log_list')}}",
"subtable": "voucher_usage_log"
}
]

View File

@ -1,47 +1,91 @@
{
"tblname": "voucher_rule",
"alias": "voucher_rule_list",
"title": "代金券规则管理",
"title": "代金券规则",
"params": {
"sortby": ["sort_order"],
"data_filter": {
"AND": [
{"field": "template_id", "op": "=", "var": "template_id"},
{"field": "rule_type", "op": "=", "var": "rule_type"}
]
},
"sortby": [
"sort_order"
],
"browserfields": {
"exclouded": [],
"exclouded": [
"id"
],
"alters": {
"template_id": {
"uitype": "code",
"dataurl": "{{entire_url('../api/template/voucher_template_options.dspy')}}",
"data_field": "options",
"textField": "text",
"valueField": "value"
},
"rule_type": {
"uitype": "code",
"data": [
{"value": "min_amount", "text": "最低消费"},
{"value": "max_amount", "text": "最高消费"},
{"value": "applicable_product_type", "text": "限定产品类型"},
{"value": "applicable_product", "text": "限定特定产品"},
{"value": "exclude_product", "text": "排除特定产品"},
{"value": "max_usage_count", "text": "最大使用次数"},
{"value": "valid_period", "text": "有效期"},
{"value": "user_level", "text": "用户等级"}
{
"value": "min_amount",
"text": "最低消费"
},
{
"value": "max_amount",
"text": "最高消费"
},
{
"value": "product_type",
"text": "限定产品类型"
},
{
"value": "product",
"text": "限定特定产品"
},
{
"value": "exclude_product",
"text": "排除特定产品"
},
{
"value": "max_usage_count",
"text": "最大使用次数"
},
{
"value": "user_level",
"text": "用户等级"
}
]
},
"enabled": {
"uitype": "code",
"data": [
{"value": "1", "text": "启用"},
{"value": "0", "text": "停用"}
{
"value": "1",
"text": "启用"
},
{
"value": "0",
"text": "停用"
}
]
},
"rule_config": {
"uitype": "text",
"placeholder": "JSON 配置,如 {\"min_value\": 100} 或 {\"product_types\": [\"pipeline\"]}"
}
}
},
"editexclouded": [
"id",
"created_at",
"updated_at"
],
"data_filter": {
"AND": [
{
"field": "template_id",
"op": "=",
"var": "template_id"
},
{
"field": "rule_type",
"op": "=",
"var": "rule_type"
}
]
},
"filter_labels": {
"template_id": "模板",
"rule_type": "规则类型"
},
"editable": {
"new_data_url": "{{entire_url('../api/rule/voucher_rule_create.dspy')}}",
"update_data_url": "{{entire_url('../api/rule/voucher_rule_update.dspy')}}",

View File

@ -1,42 +1,105 @@
{
"tblname": "voucher_template",
"alias": "voucher_template_list",
"title": "代金券模板管理",
"title": "代金券模板",
"params": {
"sortby": ["created_at desc"],
"sortby": [
"created_at desc"
],
"browserfields": {
"exclouded": ["creator", "updater"],
"exclouded": [
"id",
"org_id",
"created_by"
],
"alters": {
"status": {
"uitype": "code",
"data": [
{"value": "draft", "text": "草稿"},
{"value": "active", "text": "启用"},
{"value": "inactive", "text": "停用"}
{
"value": "draft",
"text": "草稿"
},
{
"value": "active",
"text": "启用"
},
{
"value": "inactive",
"text": "停用"
}
]
},
"face_value": {
"uitype": "code",
"data": []
}
}
},
"editexclouded": [
"id",
"code",
"issued_count",
"org_id",
"created_by",
"created_at",
"updated_at"
],
"data_filter": {
"AND": [
{"field": "name", "op": "LIKE", "var": "name"},
{"field": "status", "op": "=", "var": "status"}
{
"field": "name",
"op": "LIKE",
"var": "name"
},
{
"field": "status",
"op": "=",
"var": "status"
}
]
},
"filter_labels": {
"name": "模板名称",
"status": "状态"
},
"editable": {
"new_data_url": "{{entire_url('../api/template/voucher_template_create.dspy')}}",
"update_data_url": "{{entire_url('../api/template/voucher_template_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/template/voucher_template_delete.dspy')}}"
},
"toolbar": {
"tools": [
{
"name": "issue_from_tpl",
"label": "发券",
"selected_row": true
}
]
},
"binds": [
{
"wid": "self",
"event": "issue_from_tpl",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "按此模板发券",
"width": "46%",
"height": "60%",
"archor": "cc",
"resizable": true,
"dismiss_events": [
"cancel",
"submited"
]
},
"options": {
"url": "{{entire_url('/voucher/issue_form.ui')}}"
}
}
],
"subtables": [
{
"field": "id",
"field": "template_id",
"title": "规则配置",
"url": "{{entire_url('../voucher_rule_list/')}}",
"url": "{{entire_url('../voucher_rule_list')}}",
"subtable": "voucher_rule"
}
]

View File

@ -3,30 +3,40 @@
"alias": "voucher_usage_log_list",
"title": "代金券使用流水",
"params": {
"sortby": ["used_at desc"],
"sortby": [
"used_at desc"
],
"noedit": true,
"browserfields": {
"exclouded": [
"id",
"template_id"
],
"alters": {}
},
"data_filter": {
"AND": [
{"field": "instance_id", "op": "=", "var": "instance_id"},
{"field": "customer_id", "op": "=", "var": "customer_id"},
{"field": "order_id", "op": "=", "var": "order_id"}
{
"field": "instance_id",
"op": "=",
"var": "instance_id"
},
{
"field": "customer_id",
"op": "=",
"var": "customer_id"
},
{
"field": "order_id",
"op": "LIKE",
"var": "order_id"
}
]
},
"browserfields": {
"exclouded": [],
"alters": {
"instance_id": {
"uitype": "code",
"dataurl": "{{entire_url('../api/instance/voucher_instance_options.dspy')}}",
"data_field": "options",
"textField": "text",
"valueField": "value"
}
}
},
"editable": {
"new_data_url": "{{entire_url('../api/usage/voucher_usage_log_create.dspy')}}",
"update_data_url": "{{entire_url('../api/usage/voucher_usage_log_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/usage/voucher_usage_log_delete.dspy')}}"
"filter_labels": {
"instance_id": "券实例",
"customer_id": "客户机构",
"order_id": "订单ID"
}
}
}

View File

@ -10,27 +10,27 @@
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "template_id", "title": "模板ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "customer_id", "title": "客户ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "code", "title": "唯一券码", "type": "str", "length": 64, "nullable": "no"},
{"name": "customer_id", "title": "客户机构ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "code", "title": "券码", "type": "str", "length": 64, "nullable": "no"},
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "unused"},
{"name": "face_value", "title": "面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"},
{"name": "actual_deducted", "title": "实际抵扣金额", "type": "double", "length": 10, "dec": 2, "nullable": "yes"},
{"name": "face_value", "title": "面值", "type": "double", "length": 18, "dec": 2, "nullable": "no"},
{"name": "actual_deducted", "title": "实际抵扣金额", "type": "double", "length": 18, "dec": 2, "nullable": "yes"},
{"name": "valid_from", "title": "生效时间", "type": "datetime", "nullable": "no"},
{"name": "valid_to", "title": "过期时间", "type": "datetime", "nullable": "no"},
{"name": "issued_at", "title": "发放时间", "type": "datetime", "nullable": "no"},
{"name": "used_at", "title": "使用时间", "type": "datetime", "nullable": "yes"},
{"name": "order_id", "title": "使用的订单ID", "type": "str", "length": 64, "nullable": "yes"},
{"name": "source", "title": "来源", "type": "str", "length": 32, "nullable": "yes"},
{"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"},
{"name": "order_id", "title": "使用订单ID", "type": "str", "length": 64, "nullable": "yes"},
{"name": "source", "title": "发放来源", "type": "str", "length": 32, "nullable": "no", "default": "manual"},
{"name": "issued_by", "title": "发放人", "type": "str", "length": 32, "nullable": "yes"},
{"name": "remark", "title": "备注", "type": "str", "length": 500, "nullable": "yes"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_instance_code", "idxtype": "unique", "idxfields": ["code"]},
{"name": "idx_instance_customer", "idxtype": "index", "idxfields": ["customer_id"]},
{"name": "idx_instance_template", "idxtype": "index", "idxfields": ["template_id"]},
{"name": "idx_instance_status", "idxtype": "index", "idxfields": ["status"]},
{"name": "idx_instance_valid_to", "idxtype": "index", "idxfields": ["valid_to"]}
{"name": "idx_vi_code", "idxtype": "unique", "idxfields": ["code"]},
{"name": "idx_vi_customer", "idxtype": "index", "idxfields": ["customer_id", "status"]},
{"name": "idx_vi_template", "idxtype": "index", "idxfields": ["template_id"]},
{"name": "idx_vi_valid_to", "idxtype": "index", "idxfields": ["valid_to"]}
],
"codes": [
{
@ -39,12 +39,25 @@
"valuefield": "id",
"textfield": "name"
},
{
"field": "customer_id",
"table": "organization",
"valuefield": "id",
"textfield": "orgname"
},
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='voucher_instance_status'"
},
{
"field": "source",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='vchr_source'"
}
]
}

View File

@ -2,7 +2,7 @@
"summary": [
{
"name": "voucher_rule",
"title": "代金券规则定义",
"title": "代金券规则",
"primary": ["id"],
"catelog": "entity"
}
@ -10,17 +10,16 @@
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "template_id", "title": "模板ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "rule_type", "title": "规则类型", "type": "str", "length": 32, "nullable": "no"},
{"name": "rule_config", "title": "规则配置(JSON)", "type": "text", "nullable": "no"},
{"name": "enabled", "title": "是否启用", "type": "short", "nullable": "no", "default": "1"},
{"name": "rule_type", "title": "规则类型", "type": "str", "length": 64, "nullable": "no"},
{"name": "rule_config", "title": "规则配置JSON", "type": "text", "nullable": "yes"},
{"name": "enabled", "title": "是否启用", "type": "char", "length": 1, "nullable": "no", "default": "1"},
{"name": "sort_order", "title": "执行顺序", "type": "int", "nullable": "no", "default": "0"},
{"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"},
{"name": "remark", "title": "备注", "type": "str", "length": 500, "nullable": "yes"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_rule_template", "idxtype": "index", "idxfields": ["template_id"]},
{"name": "idx_rule_enabled", "idxtype": "index", "idxfields": ["enabled"]}
{"name": "idx_vr_template", "idxtype": "index", "idxfields": ["template_id"]}
],
"codes": [
{
@ -34,14 +33,14 @@
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='voucher_rule_type'"
"cond": "parentid='vchr_rule_type'"
},
{
"field": "enabled",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='yes_no'"
"cond": "parentid='vchr_enabled'"
}
]
}

View File

@ -9,22 +9,22 @@
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "name", "title": "模板名称", "type": "str", "length": 64, "nullable": "no"},
{"name": "code", "title": "模板编码", "type": "str", "length": 32, "nullable": "no"},
{"name": "face_value", "title": "面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"},
{"name": "total_count", "title": "总发行量", "type": "int", "nullable": "no", "default": "0"},
{"name": "name", "title": "模板名称", "type": "str", "length": 128, "nullable": "no"},
{"name": "code", "title": "模板编码", "type": "str", "length": 64, "nullable": "no"},
{"name": "face_value", "title": "面值", "type": "double", "length": 18, "dec": 2, "nullable": "no"},
{"name": "total_count", "title": "总发行量(0不限)", "type": "int", "nullable": "no", "default": "0"},
{"name": "issued_count", "title": "已发放量", "type": "int", "nullable": "no", "default": "0"},
{"name": "valid_days", "title": "有效期天数", "type": "int", "nullable": "no", "default": "30"},
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "draft"},
{"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"},
{"name": "remark", "title": "备注", "type": "str", "length": 500, "nullable": "yes"},
{"name": "org_id", "title": "所属机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"},
{"name": "created_by", "title": "创建人", "type": "str", "length": 32, "nullable": "yes"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"},
{"name": "creator", "title": "创建人", "type": "str", "length": 32, "nullable": "yes"},
{"name": "updater", "title": "更新人", "type": "str", "length": 32, "nullable": "yes"}
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_template_code", "idxtype": "unique", "idxfields": ["code"]},
{"name": "idx_template_status", "idxtype": "index", "idxfields": ["status"]}
{"name": "idx_vt_code", "idxtype": "unique", "idxfields": ["code"]},
{"name": "idx_vt_status", "idxtype": "index", "idxfields": ["status"]}
],
"codes": [
{

View File

@ -4,25 +4,27 @@
"name": "voucher_usage_log",
"title": "代金券使用流水",
"primary": ["id"],
"catelog": "entity"
"catelog": "relation"
}
],
"fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "instance_id", "title": "券实例ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "customer_id", "title": "客户ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "order_id", "title": "订单/消费ID", "type": "str", "length": 64, "nullable": "no"},
{"name": "face_value", "title": "券面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"},
{"name": "deducted_amount", "title": "实际抵扣金额", "type": "double", "length": 10, "dec": 2, "nullable": "no"},
{"name": "template_id", "title": "模板ID", "type": "str", "length": 32, "nullable": "yes"},
{"name": "customer_id", "title": "客户机构ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "order_id", "title": "订单ID", "type": "str", "length": 64, "nullable": "no", "default": ""},
{"name": "face_value", "title": "券面值", "type": "double", "length": 18, "dec": 2, "nullable": "no"},
{"name": "deducted_amount", "title": "实际抵扣金额", "type": "double", "length": 18, "dec": 2, "nullable": "no"},
{"name": "product_type", "title": "产品类型", "type": "str", "length": 32, "nullable": "yes"},
{"name": "product_name", "title": "产品名称", "type": "str", "length": 128, "nullable": "yes"},
{"name": "used_at", "title": "使用时间", "type": "datetime", "nullable": "no"},
{"name": "used_by", "title": "操作人", "type": "str", "length": 32, "nullable": "yes"},
{"name": "product_type", "title": "产品类型", "type": "str", "length": 32, "nullable": "yes"},
{"name": "product_name", "title": "产品名称", "type": "str", "length": 64, "nullable": "yes"}
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}
],
"indexes": [
{"name": "idx_log_instance", "idxtype": "index", "idxfields": ["instance_id"]},
{"name": "idx_log_customer", "idxtype": "index", "idxfields": ["customer_id"]},
{"name": "idx_log_order", "idxtype": "index", "idxfields": ["order_id"]}
{"name": "idx_vul_instance", "idxtype": "index", "idxfields": ["instance_id"]},
{"name": "idx_vul_customer", "idxtype": "index", "idxfields": ["customer_id"]},
{"name": "idx_vul_order", "idxtype": "index", "idxfields": ["order_id"]}
],
"codes": [
{
@ -30,6 +32,12 @@
"table": "voucher_instance",
"valuefield": "id",
"textfield": "code"
},
{
"field": "customer_id",
"table": "organization",
"valuefield": "id",
"textfield": "orgname"
}
]
}

View File

@ -1,17 +1,17 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "voucher"
version = "1.0.0"
description = "代金券模块:模板管理、可配置规则引擎、一次性使用代金券"
requires-python = ">=3.8"
version = "2.0.0"
description = "代金券模块:模板/规则/实例/流水 + 可配置规则引擎 + 注册送券挂钩"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"sqlor",
"bricks_for_python",
"bricks_for_python"
]
[tool.setuptools.packages.find]
where = ["."]
include = ["voucher*"]
[tool.setuptools]
packages = ["voucher", "voucher.rules"]

View File

@ -1 +0,0 @@
# voucher rules engine

View File

@ -1,198 +0,0 @@
"""代金券规则引擎:动态加载规则、校验、使用"""
import json
from decimal import Decimal
from datetime import datetime
from appPublic.uniqueID import getID
from .registry import RULE_REGISTRY
def validate_voucher(sor, instance_id, context):
"""
校验代金券是否可用
Args:
sor: SQLor 上下文
instance_id: 券实例ID
context: dict, 包含 request_amount, product_type, product_name, user_level
Returns:
(bool, Decimal, str): (可用, 抵扣金额, 错误信息)
"""
# 1. 查询券实例
instance = sor.R('voucher_instance', {'id': instance_id}).first()
if not instance:
return False, Decimal('0'), "代金券不存在"
# 2. 状态检查(一次性使用)
if instance.status != 'unused':
return False, Decimal('0'), "代金券已使用或失效"
# 3. 有效期检查
now = datetime.now()
if instance.valid_from and now < instance.valid_from:
return False, Decimal('0'), "代金券尚未生效"
if instance.valid_to and now > instance.valid_to:
return False, Decimal('0'), "代金券已过期"
# 4. 加载模板规则(按 sort_order 排序)
rules = sor.R('voucher_rule', {
'template_id': instance.template_id,
'enabled': 1
}, {'sort': 'sort_order'})
# 5. 构建执行上下文
ctx = {
'instance': instance,
'used_count': instance.get('used_count', 0) if isinstance(instance, dict) else 0,
'valid_from': instance.valid_from,
'valid_to': instance.valid_to,
**context
}
# 6. 依次执行规则
for rule in rules:
rule_type = rule.rule_type if hasattr(rule, 'rule_type') else rule['rule_type']
rule_config_str = rule.rule_config if hasattr(rule, 'rule_config') else rule['rule_config']
if rule_type not in RULE_REGISTRY:
continue # 未知规则类型跳过
try:
config = json.loads(rule_config_str) if isinstance(rule_config_str, str) else rule_config_str
except (json.JSONDecodeError, TypeError):
continue
validator = RULE_REGISTRY[rule_type]
ok, err_msg = validator(config, ctx)
if not ok:
return False, Decimal('0'), err_msg
# 7. 计算抵扣金额(一次性,不找零)
face_value = Decimal(str(instance.face_value if hasattr(instance, 'face_value') else instance['face_value']))
request_amount = Decimal(str(context.get('request_amount', 0)))
deductible = min(face_value, request_amount)
return True, deductible, None
def apply_voucher(sor, instance_id, customer_id, order_id, context):
"""
使用代金券一次性不找零
Args:
sor: SQLor 上下文
instance_id: 券实例ID
customer_id: 客户ID
order_id: 订单ID
context: dict, 包含 request_amount, product_type, product_name
Returns:
(bool, Decimal, str): (成功, 抵扣金额, 错误信息)
"""
# 1. 校验
ok, deductible, err = validate_voucher(sor, instance_id, context)
if not ok:
return False, Decimal('0'), err
# 2. 获取券实例信息
instance = sor.R('voucher_instance', {'id': instance_id}).first()
face_value = Decimal(str(instance.face_value if hasattr(instance, 'face_value') else instance['face_value']))
now = datetime.now()
# 3. 记录使用流水
sor.I('voucher_usage_log', {
'id': getID(),
'instance_id': instance_id,
'customer_id': customer_id,
'order_id': order_id,
'face_value': face_value,
'deducted_amount': deductible,
'used_at': now,
'used_by': sor.env.get_user() if hasattr(sor, 'env') else None,
'product_type': context.get('product_type', ''),
'product_name': context.get('product_name', '')
})
# 4. 标记券为已使用(一次性,直接作废)
sor.U('voucher_instance', {'id': instance_id}, {
'status': 'used',
'actual_deducted': deductible,
'used_at': now,
'order_id': order_id
})
return True, deductible, None
def batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context):
"""
批量使用代金券依次尝试直到消费金额抵扣完
Args:
sor: SQLor 上下文
customer_id: 客户ID
order_id: 订单ID
voucher_ids: 券实例ID列表
context: dict, 包含 request_amount, product_type, product_name
Returns:
dict: {total_deducted, remaining, details: [{voucher_id, deducted, error}]}
"""
total_deducted = Decimal('0')
remaining = Decimal(str(context.get('request_amount', 0)))
details = []
for vid in voucher_ids:
if remaining <= 0:
break
ctx = {**context, 'request_amount': remaining}
ok, deducted, err = apply_voucher(sor, vid, customer_id, order_id, ctx)
if ok:
total_deducted += deducted
remaining -= deducted
details.append({'voucher_id': vid, 'deducted': float(deducted), 'error': None})
else:
details.append({'voucher_id': vid, 'deducted': 0, 'error': err})
return {
'total_deducted': float(total_deducted),
'remaining': float(remaining),
'details': details
}
def get_available_vouchers(sor, customer_id, context=None):
"""
查询客户可用代金券
Args:
sor: SQLor 上下文
customer_id: 客户ID
context: 可选用于预校验
Returns:
list: 可用券实例列表
"""
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
vouchers = sor.R('voucher_instance', {
'customer_id': customer_id,
'status': 'unused'
}, {'sort': 'valid_to'})
if not context:
return list(vouchers)
# 带 context 时预校验过滤
available = []
for v in vouchers:
vid = v.id if hasattr(v, 'id') else v['id']
ok, _, _ = validate_voucher(sor, vid, context)
if ok:
available.append(v)
return available

View File

@ -1,21 +0,0 @@
"""规则注册机制:新增规则只需写一个 validator 函数 + @register_rule 装饰器"""
RULE_REGISTRY = {}
def register_rule(rule_type):
"""装饰器:注册规则类型"""
def decorator(func):
RULE_REGISTRY[rule_type] = func
return func
return decorator
def get_all_rule_types():
"""返回所有已注册的规则类型"""
return list(RULE_REGISTRY.keys())
def get_rule_validator(rule_type):
"""根据类型获取 validator 函数"""
return RULE_REGISTRY.get(rule_type)

View File

@ -1,100 +0,0 @@
"""内置规则校验器,每种规则一个函数,返回 (bool, error_msg)"""
from decimal import Decimal
from datetime import datetime
from .registry import register_rule
@register_rule('min_amount')
def check_min_amount(config, context):
"""最低消费门槛"""
min_val = Decimal(str(config.get('min_value', 0)))
request_amount = Decimal(str(context.get('request_amount', 0)))
if request_amount < min_val:
return False, f"未达最低消费 {min_val}"
return True, None
@register_rule('max_amount')
def check_max_amount(config, context):
"""最高消费限制"""
max_val = Decimal(str(config.get('max_value', 0)))
if max_val <= 0:
return True, None
request_amount = Decimal(str(context.get('request_amount', 0)))
if request_amount > max_val:
return False, f"超过最高消费 {max_val}"
return True, None
@register_rule('applicable_product_type')
def check_product_type(config, context):
"""限定产品类型大类llm/image/video/audio"""
allowed_types = config.get('product_types', [])
if not allowed_types:
return True, None
current_type = context.get('product_type', '')
if current_type and current_type not in allowed_types:
return False, f"仅限 {', '.join(allowed_types)} 类型使用"
return True, None
@register_rule('applicable_product')
def check_specific_product(config, context):
"""限定特定产品(具体模型名)"""
allowed_products = config.get('products', [])
if not allowed_products:
return True, None
current_product = context.get('product_name', '')
if current_product and current_product not in allowed_products:
return False, f"仅限 {', '.join(allowed_products)} 使用"
return True, None
@register_rule('exclude_product')
def check_exclude_product(config, context):
"""排除特定产品"""
excluded = config.get('products', [])
if not excluded:
return True, None
current_product = context.get('product_name', '')
if current_product and current_product in excluded:
return False, f"{current_product} 不可使用此代金券"
return True, None
@register_rule('max_usage_count')
def check_max_usage_count(config, context):
"""最大使用次数"""
max_count = int(config.get('max_count', 0))
if max_count == 0:
return True, None
used_count = int(context.get('used_count', 0))
if used_count >= max_count:
return False, f"已达最大使用次数 {max_count}"
return True, None
@register_rule('valid_period')
def check_valid_period(config, context):
"""有效期检查(通常由 instance 的 valid_from/valid_to 自动处理,此规则用于额外限制)"""
now = datetime.now()
valid_from = context.get('valid_from')
valid_to = context.get('valid_to')
if valid_from and now < valid_from:
return False, "代金券尚未生效"
if valid_to and now > valid_to:
return False, "代金券已过期"
return True, None
@register_rule('user_level')
def check_user_level(config, context):
"""用户等级限制"""
required_level = int(config.get('min_level', 0))
if required_level == 0:
return True, None
user_level = int(context.get('user_level', 0))
if user_level < required_level:
return False, f"{required_level} 级以上用户"
return True, None

View File

@ -1,76 +1,99 @@
#!/usr/bin/env python3
"""voucher 模块 RBAC 权限注册"""
import os
"""voucher 模块 RBAC 路径权限注册pipeline-app 格式,仿 ticket/scripts/load_path.py
从应用根执行py3/bin/python pkgs/voucher/scripts/load_path.py
load_path.sh 自动扫描 pkgs/*/scripts/load_path.py 并调用本脚本
权限设计逐个精确配不用统一兜底
- logined客户侧我的代金券页面 + 自助查询 API + 管理侧全部页面与 API
管理类数据面由服务端 org_id 隔离模板带 org_id CRUD logined_userorgid 机制
不适用平台运营代客户管券是本职不做 org 过滤误操作面由 helper 内校验兜底
模板改删校验 org实例改/作废只动 unused
- CRUD 生成目录voucher_*_list/四件套index.ui + get/add/update/delete dspy
逐一注册
"""
import sys
import subprocess
import os
import asyncio
def find_sage_root():
for candidate in [
os.path.expanduser("~/repos/sage"),
os.path.expanduser("~/sage"),
]:
if os.path.isdir(os.path.join(candidate, "wwwroot")):
return candidate
return None
# 应用根:本脚本在 pkgs/voucher/scripts/ 下,上溯三级
APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../.."))
sys.path.insert(0, os.path.join(APP_ROOT, "py3", "lib", "python3.10", "site-packages"))
sys.path.insert(0, APP_ROOT)
def set_perm(sage_root, role, path):
script = os.path.join(sage_root, "set_role_perm.py")
cmd = [os.path.join(sage_root, "py3/bin/python"), script, role, path]
print(f" {role} {path}")
subprocess.run(cmd, cwd=sage_root, capture_output=True)
from sqlor.dbpools import DBPools
from appPublic.jsonConfig import getConfig
from appPublic.uniqueID import getID
def main():
sage_root = find_sage_root()
if not sage_root:
print("ERROR: Sage root not found")
sys.exit(1)
MOD = "voucher"
print("Registering voucher RBAC permissions...")
# CRUD 生成目录四件套alias -> 表名)
CRUD_DIRS = {
"voucher_template_list": "voucher_template",
"voucher_rule_list": "voucher_rule",
"voucher_instance_list": "voucher_instance",
"voucher_usage_log_list": "voucher_usage_log",
}
# any (公开)
any_paths = [
"/voucher/menu.ui",
PATHS_LOGINED = [
# 客户侧
f"/{MOD}/my",
f"/{MOD}/my/index.ui",
f"/{MOD}/api/my_vouchers.dspy",
f"/{MOD}/api/v1/available.dspy",
# 管理侧页面/表单
f"/{MOD}/index.ui",
f"/{MOD}/issue_form.ui",
f"/{MOD}/invalidate_form.ui",
# 管理侧 API
f"/{MOD}/api/get_available.dspy",
f"/{MOD}/api/apply_voucher.dspy",
f"/{MOD}/api/rule_types.dspy",
f"/{MOD}/api/template/voucher_template_create.dspy",
f"/{MOD}/api/template/voucher_template_update.dspy",
f"/{MOD}/api/template/voucher_template_delete.dspy",
f"/{MOD}/api/template/voucher_template_options.dspy",
f"/{MOD}/api/rule/voucher_rule_create.dspy",
f"/{MOD}/api/rule/voucher_rule_update.dspy",
f"/{MOD}/api/rule/voucher_rule_delete.dspy",
f"/{MOD}/api/instance/voucher_issue.dspy",
f"/{MOD}/api/instance/voucher_instance_update.dspy",
f"/{MOD}/api/instance/voucher_instance_delete.dspy",
f"/{MOD}/api/instance/voucher_invalidate.dspy",
]
# CRUD 目录(框架生成,路径必须逐个注册)
for alias, tbl in CRUD_DIRS.items():
PATHS_LOGINED += [
f"/{MOD}/{alias}",
f"/{MOD}/{alias}/index.ui",
f"/{MOD}/{alias}/get_{tbl}.dspy",
f"/{MOD}/{alias}/add_{tbl}.dspy",
f"/{MOD}/{alias}/update_{tbl}.dspy",
f"/{MOD}/{alias}/delete_{tbl}.dspy",
]
# logined (登录后)
logined_paths = [
"/voucher",
"/voucher/index.ui",
"/voucher/voucher_template_list",
"/voucher/voucher_template_list/index.ui",
"/voucher/voucher_rule_list",
"/voucher/voucher_rule_list/index.ui",
"/voucher/voucher_instance_list",
"/voucher/voucher_instance_list/index.ui",
"/voucher/voucher_usage_log_list",
"/voucher/voucher_usage_log_list/index.ui",
"/voucher/api/template/voucher_template_create.dspy",
"/voucher/api/template/voucher_template_update.dspy",
"/voucher/api/template/voucher_template_delete.dspy",
"/voucher/api/template/voucher_template_options.dspy",
"/voucher/api/rule/voucher_rule_create.dspy",
"/voucher/api/rule/voucher_rule_update.dspy",
"/voucher/api/rule/voucher_rule_delete.dspy",
"/voucher/api/instance/voucher_instance_create.dspy",
"/voucher/api/instance/voucher_instance_update.dspy",
"/voucher/api/instance/voucher_instance_delete.dspy",
"/voucher/api/instance/voucher_instance_options.dspy",
"/voucher/api/usage/voucher_usage_log_create.dspy",
"/voucher/api/usage/voucher_usage_log_update.dspy",
"/voucher/api/usage/voucher_usage_log_delete.dspy",
"/voucher/api/v1/available.dspy",
"/voucher/api/apply_voucher.dspy",
"/voucher/api/get_available.dspy",
"/voucher/api/rule_types.dspy",
]
for p in any_paths:
set_perm(sage_root, "any", p)
async def main():
config = getConfig(APP_ROOT, NS={"workdir": APP_ROOT})
DBPools(config.databases)
cnt = 0
async with DBPools().sqlorContext("pipeline") as sor:
for path in PATHS_LOGINED:
recs = await sor.R("permission", {"path": path})
if recs:
permid = recs[0].id
else:
permid = getID()
await sor.C("permission", {"id": permid, "path": path})
rp = await sor.R("rolepermission", {"roleid": "logined", "permid": permid})
if not rp:
await sor.C("rolepermission", {
"id": getID(), "roleid": "logined", "permid": permid})
cnt += 1
await sor.sqlExe("COMMIT", {})
print(f"voucher load_path: {cnt} logined path-role entries ensured")
for p in logined_paths:
set_perm(sage_root, "logined", p)
print("Done.")
if __name__ == "__main__":
main()
asyncio.run(main())

View File

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""voucher 种子:注册送 400 元欢迎券模板WELCOME400+ 规则。
幂等模板按 code 判重存在即跳过管理员后续在界面调整不受影响
活动参数2026-09-06 用户定夺
- 面值 400 100 total_count=100 原子占额控制30 天有效
- 规则仅限产线订阅类产品product_type [pipeline]
- 状态直接 active注册挂钩只认 active 模板
结束活动管理界面把模板置 inactive 即可无需改代码
从应用根执行py3/bin/python pkgs/voucher/scripts/seed_welcome_voucher.py
"""
import sys
import os
import asyncio
APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../.."))
sys.path.insert(0, os.path.join(APP_ROOT, "py3", "lib", "python3.10", "site-packages"))
sys.path.insert(0, APP_ROOT)
from sqlor.dbpools import DBPools
from appPublic.jsonConfig import getConfig
from appPublic.uniqueID import getID
WELCOME_CODE = "WELCOME400"
async def main():
config = getConfig(APP_ROOT, NS={"workdir": APP_ROOT})
DBPools(config.databases)
async with DBPools().sqlorContext("pipeline") as sor:
recs = await sor.R("voucher_template", {"code": WELCOME_CODE})
if recs:
print(f"seed: {WELCOME_CODE} already exists (id={recs[0].id}), skip")
return
tid = getID()
await sor.C("voucher_template", {
"id": tid,
"name": "新客户欢迎券",
"code": WELCOME_CODE,
"face_value": 400.0,
"total_count": 100,
"issued_count": 0,
"valid_days": 30,
"status": "active",
"remark": "注册自动赠送前100名仅限产线订阅类产品30天有效",
"org_id": "0",
"created_by": "system",
})
rid = getID()
await sor.C("voucher_rule", {
"id": rid,
"template_id": tid,
"rule_type": "product_type",
"rule_config": '{"product_types": ["pipeline"]}',
"enabled": "1",
"sort_order": 1,
"remark": "仅限产线订阅类产品400券只能买产线",
})
await sor.sqlExe("COMMIT", {})
print(f"seed: {WELCOME_CODE} created (template_id={tid}, rule_id={rid})")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,107 +0,0 @@
-- ============================================
-- 代金券模块建表脚本
-- ============================================
-- 代金券模板表
CREATE TABLE IF NOT EXISTS voucher_template (
id VARCHAR(32) NOT NULL COMMENT '主键ID',
name VARCHAR(64) NOT NULL COMMENT '模板名称',
code VARCHAR(32) NOT NULL COMMENT '模板编码',
face_value DECIMAL(10,2) NOT NULL COMMENT '面值',
total_count INT NOT NULL DEFAULT 0 COMMENT '总发行量',
issued_count INT NOT NULL DEFAULT 0 COMMENT '已发放量',
valid_days INT NOT NULL DEFAULT 30 COMMENT '有效期天数',
status VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '状态: draft/active/inactive',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
creator VARCHAR(32) DEFAULT NULL COMMENT '创建人',
updater VARCHAR(32) DEFAULT NULL COMMENT '更新人',
PRIMARY KEY (id),
UNIQUE KEY idx_template_code (code),
KEY idx_template_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代金券模板';
-- 代金券规则表
CREATE TABLE IF NOT EXISTS voucher_rule (
id VARCHAR(32) NOT NULL COMMENT '主键ID',
template_id VARCHAR(32) NOT NULL COMMENT '模板ID',
rule_type VARCHAR(32) NOT NULL COMMENT '规则类型',
rule_config TEXT NOT NULL COMMENT '规则配置(JSON)',
enabled SMALLINT NOT NULL DEFAULT 1 COMMENT '是否启用',
sort_order INT NOT NULL DEFAULT 0 COMMENT '执行顺序',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
KEY idx_rule_template (template_id),
KEY idx_rule_enabled (enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代金券规则定义';
-- 代金券实例表
CREATE TABLE IF NOT EXISTS voucher_instance (
id VARCHAR(32) NOT NULL COMMENT '主键ID',
template_id VARCHAR(32) NOT NULL COMMENT '模板ID',
customer_id VARCHAR(32) NOT NULL COMMENT '客户ID',
code VARCHAR(64) NOT NULL COMMENT '唯一券码',
status VARCHAR(16) NOT NULL DEFAULT 'unused' COMMENT '状态: unused/used/expired',
face_value DECIMAL(10,2) NOT NULL COMMENT '面值',
actual_deducted DECIMAL(10,2) DEFAULT NULL COMMENT '实际抵扣金额',
valid_from DATETIME NOT NULL COMMENT '生效时间',
valid_to DATETIME NOT NULL COMMENT '过期时间',
issued_at DATETIME NOT NULL COMMENT '发放时间',
used_at DATETIME DEFAULT NULL COMMENT '使用时间',
order_id VARCHAR(64) DEFAULT NULL COMMENT '使用的订单ID',
source VARCHAR(32) DEFAULT NULL COMMENT '来源',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY idx_instance_code (code),
KEY idx_instance_customer (customer_id),
KEY idx_instance_template (template_id),
KEY idx_instance_status (status),
KEY idx_instance_valid_to (valid_to)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代金券实例(一次性使用)';
-- 代金券使用流水表
CREATE TABLE IF NOT EXISTS voucher_usage_log (
id VARCHAR(32) NOT NULL COMMENT '主键ID',
instance_id VARCHAR(32) NOT NULL COMMENT '券实例ID',
customer_id VARCHAR(32) NOT NULL COMMENT '客户ID',
order_id VARCHAR(64) NOT NULL COMMENT '订单/消费ID',
face_value DECIMAL(10,2) NOT NULL COMMENT '券面值',
deducted_amount DECIMAL(10,2) NOT NULL COMMENT '实际抵扣金额',
used_at DATETIME NOT NULL COMMENT '使用时间',
used_by VARCHAR(32) DEFAULT NULL COMMENT '操作人',
product_type VARCHAR(32) DEFAULT NULL COMMENT '产品类型',
product_name VARCHAR(64) DEFAULT NULL COMMENT '产品名称',
PRIMARY KEY (id),
KEY idx_log_instance (instance_id),
KEY idx_log_customer (customer_id),
KEY idx_log_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代金券使用流水';
-- 初始化编码数据
INSERT INTO appcodes (id, name, hierarchy_flg) VALUES
('voucher_template_status', '代金券模板状态', '0'),
('voucher_instance_status', '代金券实例状态', '0'),
('voucher_rule_type', '代金券规则类型', '0')
ON DUPLICATE KEY UPDATE name=VALUES(name);
INSERT INTO appcodes_kv (id, parentid, k, v) VALUES
('vts_draft', 'voucher_template_status', 'draft', '草稿'),
('vts_active', 'voucher_template_status', 'active', '启用'),
('vts_inactive', 'voucher_template_status', 'inactive', '停用'),
('vis_unused', 'voucher_instance_status', 'unused', '未使用'),
('vis_used', 'voucher_instance_status', 'used', '已使用'),
('vis_expired', 'voucher_instance_status', 'expired', '已过期'),
('vrt_min', 'voucher_rule_type', 'min_amount', '最低消费'),
('vrt_max', 'voucher_rule_type', 'max_amount', '最高消费'),
('vrt_ptype', 'voucher_rule_type', 'applicable_product_type', '限定产品类型'),
('vrt_prod', 'voucher_rule_type', 'applicable_product', '限定特定产品'),
('vrt_exprod', 'voucher_rule_type', 'exclude_product', '排除特定产品'),
('vrt_usage', 'voucher_rule_type', 'max_usage_count', '最大使用次数'),
('vrt_period', 'voucher_rule_type', 'valid_period', '有效期'),
('vrt_level', 'voucher_rule_type', 'user_level', '用户等级')
ON DUPLICATE KEY UPDATE v=VALUES(v);

View File

@ -1 +1,4 @@
# voucher module
"""voucher 代金券模块"""
from voucher.init import load_voucher
__all__ = ['load_voucher']

View File

@ -1,242 +1,519 @@
"""voucher 模块初始化"""
"""voucher 代金券模块:对外 API注册到 ServerEnv 供 dspy 裸名调用)。
架构模板(voucher_template) 规则集(voucher_rule) 实例(voucher_instance) 流水(voucher_usage_log)
注册挂钩bind pipeline:organization:c:after 事件新客户机构注册即自动送欢迎券
N 名由模板 total_count 发行量原子控制活动结束把模板置 inactive 即可
"""
from datetime import datetime
from ahserver.serverenv import ServerEnv
from appPublic.jsonConfig import getConfig
from appPublic.log import debug, exception, info
from appPublic.uniqueID import getID
from sqlor.dbpools import DBPools
MODULE_NAME = "voucher"
MODULE_VERSION = "1.0.0"
DBNAME = "sage"
from voucher.rules.engine import (
apply_voucher,
batch_apply_vouchers,
expire_vouchers,
get_available_vouchers,
issue_voucher,
validate_voucher,
)
from voucher.rules.registry import get_all_rule_types
MODULE_NAME = 'voucher'
MODULE_VERSION = '2.0.0'
# datetime 字段sqlor 返回 python datetime框架 json.dumps 无 default=str 会崩)
_DT_FIELDS = ('valid_from', 'valid_to', 'issued_at', 'used_at', 'created_at', 'updated_at')
def _jsonable_instance(item):
"""实例行 → JSON 安全datetime 转 str金额转 float"""
for k in _DT_FIELDS:
if item.get(k) is not None:
item[k] = str(item[k])
for k in ('face_value', 'actual_deducted'):
if item.get(k) is not None:
item[k] = float(item[k])
return item
# 注册送券活动的模板编码init/data.json 种子模板,部署后管理员可在界面调整)
WELCOME_TEMPLATE_CODE = 'WELCOME400'
def _get_dbname():
return DBNAME
"""库名一律由宿主决定get_module_dbname不硬编码。"""
env = ServerEnv()
fn = getattr(env, 'get_module_dbname', None)
if callable(fn):
name = fn(MODULE_NAME)
if name:
return name
return 'pipeline'
# ========== Template CRUD ==========
async def create_voucher_template(data):
sor = DBPools().sqlorContext(_get_dbname())
data['id'] = getID()
data['issued_count'] = 0
if 'status' not in data:
data['status'] = 'draft'
sor.I('voucher_template', data)
return {'status': 'success', 'data': {'id': data['id']}}
def _get_db():
db = DBPools()
if not getattr(db, 'databases', None):
config = getConfig()
db.databases = config.databases
return db
async def update_voucher_template(conditions, data):
sor = DBPools().sqlorContext(_get_dbname())
sor.U('voucher_template', conditions, data)
return {'status': 'success'}
async def delete_voucher_template(conditions):
sor = DBPools().sqlorContext(_get_dbname())
# 级联删除关联规则
sor.D('voucher_rule', {'template_id': conditions.get('id', '')})
sor.D('voucher_template', conditions)
return {'status': 'success'}
# ========== Rule CRUD ==========
async def create_voucher_rule(data):
sor = DBPools().sqlorContext(_get_dbname())
data['id'] = getID()
if 'enabled' not in data:
data['enabled'] = 1
if 'sort_order' not in data:
data['sort_order'] = 0
sor.I('voucher_rule', data)
return {'status': 'success', 'data': {'id': data['id']}}
async def update_voucher_rule(conditions, data):
sor = DBPools().sqlorContext(_get_dbname())
sor.U('voucher_rule', conditions, data)
return {'status': 'success'}
async def delete_voucher_rule(conditions):
sor = DBPools().sqlorContext(_get_dbname())
sor.D('voucher_rule', conditions)
return {'status': 'success'}
# ========== Instance CRUD ==========
async def create_voucher_instance(data):
from datetime import datetime, timedelta
sor = DBPools().sqlorContext(_get_dbname())
data['id'] = getID()
data['status'] = 'unused'
# 从模板获取面值
template = sor.R('voucher_template', {'id': data.get('template_id', '')}).first()
if template:
data['face_value'] = template.face_value if hasattr(template, 'face_value') else template['face_value']
valid_days = template.valid_days if hasattr(template, 'valid_days') else template.get('valid_days', 30)
else:
valid_days = data.get('valid_days', 30)
# 设置有效期
now = datetime.now()
data['valid_from'] = data.get('valid_from', now.strftime('%Y-%m-%d %H:%M:%S'))
data['valid_to'] = data.get('valid_to', (now + timedelta(days=valid_days)).strftime('%Y-%m-%d %H:%M:%S'))
data['issued_at'] = now.strftime('%Y-%m-%d %H:%M:%S')
# 生成唯一券码
if 'code' not in data or not data['code']:
import hashlib
raw = f"{data['id']}-{data.get('customer_id', '')}-{now.timestamp()}"
data['code'] = 'VCH-' + hashlib.md5(raw.encode()).hexdigest()[:12].upper()
sor.I('voucher_instance', data)
# 更新模板已发放量
if template:
tid = data.get('template_id', '')
sor.db_execute(f"UPDATE voucher_template SET issued_count = issued_count + 1 WHERE id = '{tid}'")
return {'status': 'success', 'data': {'id': data['id'], 'code': data['code']}}
async def update_voucher_instance(conditions, data):
sor = DBPools().sqlorContext(_get_dbname())
sor.U('voucher_instance', conditions, data)
return {'status': 'success'}
async def delete_voucher_instance(conditions):
sor = DBPools().sqlorContext(_get_dbname())
sor.D('voucher_usage_log', {'instance_id': conditions.get('id', '')})
sor.D('voucher_instance', conditions)
return {'status': 'success'}
# ========== Usage Log CRUD ==========
async def create_voucher_usage_log(data):
sor = DBPools().sqlorContext(_get_dbname())
data['id'] = getID()
sor.I('voucher_usage_log', data)
return {'status': 'success', 'data': {'id': data['id']}}
async def update_voucher_usage_log(conditions, data):
sor = DBPools().sqlorContext(_get_dbname())
sor.U('voucher_usage_log', conditions, data)
return {'status': 'success'}
async def delete_voucher_usage_log(conditions):
sor = DBPools().sqlorContext(_get_dbname())
sor.D('voucher_usage_log', conditions)
return {'status': 'success'}
# ========== Options API (dropdown) ==========
async def get_voucher_template_options():
sor = DBPools().sqlorContext(_get_dbname())
rows = sor.R('voucher_template', {}, {'sort': 'name'})
options = []
for r in rows:
tid = r.id if hasattr(r, 'id') else r['id']
name = r.name if hasattr(r, 'name') else r['name']
options.append({'value': tid, 'text': name})
return {'status': 'success', 'data': {'options': options}}
async def get_voucher_instance_options():
sor = DBPools().sqlorContext(_get_dbname())
rows = sor.R('voucher_instance', {}, {'sort': 'created_at desc'})
options = []
for r in rows:
vid = r.id if hasattr(r, 'id') else r['id']
code = r.code if hasattr(r, 'code') else r['code']
options.append({'value': vid, 'text': code})
return {'status': 'success', 'data': {'options': options}}
# ========== Voucher Engine API ==========
async def apply_voucher_api(customer_id, order_id, voucher_ids, context):
"""外部调用:使用代金券"""
from voucher.rules.engine import batch_apply_vouchers
sor = DBPools().sqlorContext(_get_dbname())
result = batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context)
return {'status': 'success', 'data': result}
# ========== 客户侧 API ==========
async def get_available_vouchers_api(customer_id, context=None):
"""外部调用:查询可用代金券(含模板名称)"""
from voucher.rules.engine import get_available_vouchers
sor = DBPools().sqlorContext(_get_dbname())
vouchers = get_available_vouchers(sor, customer_id, context)
# 预加载模板名称
template_names = {}
for v in vouchers:
tid = v.template_id if hasattr(v, 'template_id') else v.get('template_id', '')
if tid and tid not in template_names:
t = sor.R('voucher_template', {'id': tid}).first()
if t:
template_names[tid] = t.name if hasattr(t, 'name') else t.get('name', '')
items = []
for v in vouchers:
if hasattr(v, '__dict__'):
item = v.__dict__
else:
"""查询客户可用代金券(含模板名称;顺带把过期券置 expired"""
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
try:
await expire_vouchers(sor)
except Exception as e:
debug('get_available_vouchers_api: expire sweep skipped: {}'.format(e))
vouchers = await get_available_vouchers(sor, customer_id, context)
template_names = {}
for v in vouchers:
tid = v.template_id or ''
if tid and tid not in template_names:
t = await sor.R('voucher_template', {'id': tid})
template_names[tid] = (t[0].name if t else '') or ''
items = []
for v in vouchers:
item = dict(v)
tid = item.get('template_id', '')
item['template_name'] = template_names.get(tid, '')
items.append(item)
return {'status': 'success', 'data': items}
item['template_name'] = template_names.get(v.template_id or '', '')
items.append(_jsonable_instance(item))
return {'status': 'success', 'data': items, 'total': len(items)}
async def get_registered_rule_types():
"""返回已注册的规则类型列表"""
from voucher.rules.registry import get_all_rule_types
async def apply_voucher_api(customer_id, order_id, voucher_ids, context):
"""使用代金券(单张传一个 id 的列表即可)。"""
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
result = await batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context)
return {'status': 'success', 'data': result}
async def validate_voucher_api(instance_id, context):
"""只校验不核销(下单前预检)。"""
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
ok, deductible, err = await validate_voucher(sor, instance_id, context or {})
return {'status': 'success', 'data': {
'ok': ok, 'deductible': float(deductible), 'error': err}}
async def my_vouchers_api(request, params_kw):
"""客户「我的代金券」列表(本机构全部券,含已用/过期/作废Tabular 契约 {total, rows})。"""
env = request._run_ns
org_id = (await env.get_userorgid()) or ''
if not org_id:
return {'total': 0, 'rows': []}
status = ((params_kw or {}).get('status') or '').strip()
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
try:
await expire_vouchers(sor)
except Exception as e:
debug('my_vouchers_api: expire sweep skipped: {}'.format(e))
where = 'WHERE a.customer_id=${cid}$'
ns = {'cid': org_id}
if status:
where += ' AND a.status=${st}$'
ns['st'] = status
sql = ("SELECT a.id, a.code, a.status, a.face_value, a.actual_deducted, "
"a.valid_from, a.valid_to, a.issued_at, a.used_at, a.source, a.remark, "
"b.name as template_name "
"FROM voucher_instance a "
"LEFT JOIN voucher_template b ON a.template_id=b.id "
+ where + " ORDER BY a.issued_at DESC")
recs = await sor.sqlExe(sql, ns)
rows = [_jsonable_instance(dict(r)) for r in (recs or [])]
return {'total': len(rows), 'rows': rows}
async def invalidate_voucher_api(request, params_kw):
"""作废一张未使用的券管理端操作unused → void已用/已过期/已作废拒绝)。"""
env = request._run_ns
user_id = await env.get_user()
data = dict(params_kw or {})
vid = (data.get('id') or '').strip()
reason = (data.get('reason') or '').strip()
if not vid:
return {'success': False, 'message': '缺少 id'}
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 条件 UPDATE 原子作废:只有 unused 才可作废(并发防双花同款守卫)
remark_sql = "UPDATE voucher_instance SET status='void', updated_at=${now}$"
ns = {'now': now, 'vid': vid}
if reason:
remark_sql += ", remark=CONCAT(COALESCE(remark,''), ${rmark}$)"
ns['rmark'] = ' [作废:{}]'.format(reason[:200])
remark_sql += (" WHERE id=${vid}$ AND status='unused'")
affected = await sor.execute(remark_sql, ns)
if not affected:
recs = await sor.R('voucher_instance', {'id': vid})
if not recs:
return {'success': False, 'message': '券不存在'}
return {'success': False,
'message': '券当前状态为 {},只有未使用的券可作废'.format(recs[0].status)}
info('voucher: {} voided by {} ({})'.format(vid, user_id, reason or 'no reason'))
return {'success': True, 'message': 'ok'}
# ========== 管理侧 API ==========
async def create_voucher_template(request, params_kw):
"""建模板org_id 隔离 + code 唯一)。"""
env = request._run_ns
user_id = await env.get_user()
org_id = (await env.get_userorgid()) or '0'
data = dict(params_kw)
name = (data.get('name') or '').strip()
if not name:
return {'success': False, 'message': '模板名称必填'}
face_value = float(data.get('face_value') or 0)
if face_value <= 0:
return {'success': False, 'message': '面值必须大于 0'}
code = (data.get('code') or '').strip().upper() or ('VT-' + getID()[:12].upper())
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
exist = await sor.R('voucher_template', {'code': code})
if exist:
return {'success': False, 'message': '模板编码 {} 已存在'.format(code)}
tid = getID()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
await sor.C('voucher_template', {
'id': tid,
'name': name,
'code': code,
'face_value': face_value,
'total_count': int(data.get('total_count') or 0),
'issued_count': 0,
'valid_days': int(data.get('valid_days') or 30),
'status': data.get('status') or 'draft',
'remark': data.get('remark') or '',
'org_id': org_id,
'created_by': user_id or '',
'created_at': now,
'updated_at': now,
})
return {'success': True, 'message': 'ok', 'id': tid, 'code': code}
async def update_voucher_template(request, params_kw):
env = request._run_ns
org_id = (await env.get_userorgid()) or '0'
data = dict(params_kw)
tid = data.pop('id', None)
if not tid:
return {'success': False, 'message': '缺少 id'}
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
recs = await sor.R('voucher_template', {'id': tid})
if not recs:
return {'success': False, 'message': '模板不存在'}
if (recs[0].org_id or '0') != org_id and org_id != '0':
return {'success': False, 'message': '无权操作该模板'}
new_code = (data.get('code') or '').strip().upper()
if new_code and new_code != (recs[0].code or ''):
return {'success': False, 'message': '模板编码不可修改(已发券按编码追溯)'}
allowed = {}
for k in ('name', 'face_value', 'total_count', 'valid_days', 'status', 'remark'):
if k in data and data[k] is not None:
allowed[k] = data[k]
if not allowed:
return {'success': False, 'message': '无可更新字段'}
allowed['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
allowed['id'] = tid
await sor.U('voucher_template', allowed)
return {'success': True, 'message': 'ok'}
async def delete_voucher_template(request, params_kw):
"""删模板:级联删规则;已发出的实例保留(客户已持有的券不作废,
但模板停用/删除后 validate 会拒绝使用停用代替删除更安全"""
env = request._run_ns
org_id = (await env.get_userorgid()) or '0'
data = dict(params_kw)
tid = data.get('id')
if not tid:
return {'success': False, 'message': '缺少 id'}
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
recs = await sor.R('voucher_template', {'id': tid})
if not recs:
return {'success': False, 'message': '模板不存在'}
if (recs[0].org_id or '0') != org_id and org_id != '0':
return {'success': False, 'message': '无权操作该模板'}
issued = int(recs[0].issued_count or 0)
if issued > 0:
return {'success': False,
'message': '模板已发放 {} 张券,不能删除;请改用「停用」'.format(issued)}
await sor.execute(
'DELETE FROM voucher_rule WHERE template_id=${tid}$', {'tid': tid})
await sor.D('voucher_template', {'id': tid})
return {'success': True, 'message': 'ok'}
async def create_voucher_rule(request, params_kw):
"""给模板加规则rule_config 必须是合法 JSON"""
import json as _json
data = dict(params_kw)
template_id = data.get('template_id')
rule_type = data.get('rule_type')
if not template_id or not rule_type:
return {'success': False, 'message': 'template_id 与 rule_type 必填'}
if rule_type not in [r['rule_type'] for r in get_all_rule_types()]:
return {'success': False, 'message': '未知规则类型: {}'.format(rule_type)}
cfg_raw = data.get('rule_config') or '{}'
if isinstance(cfg_raw, dict):
cfg_str = _json.dumps(cfg_raw, ensure_ascii=False)
else:
try:
_json.loads(cfg_raw)
except (ValueError, TypeError):
return {'success': False, 'message': 'rule_config 不是合法 JSON'}
cfg_str = cfg_raw
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
t = await sor.R('voucher_template', {'id': template_id})
if not t:
return {'success': False, 'message': '模板不存在'}
rid = getID()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
await sor.C('voucher_rule', {
'id': rid,
'template_id': template_id,
'rule_type': rule_type,
'rule_config': cfg_str,
'enabled': str(data.get('enabled', '1')),
'sort_order': int(data.get('sort_order') or 0),
'remark': data.get('remark') or '',
'created_at': now,
'updated_at': now,
})
return {'success': True, 'message': 'ok', 'id': rid}
async def update_voucher_rule(request, params_kw):
data = dict(params_kw)
rid = data.pop('id', None)
if not rid:
return {'success': False, 'message': '缺少 id'}
allowed = {'id': rid}
for k in ('rule_type', 'rule_config', 'enabled', 'sort_order', 'remark'):
if k in data and data[k] is not None:
allowed[k] = data[k]
if len(allowed) <= 1:
return {'success': False, 'message': '无可更新字段'}
allowed['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
await sor.U('voucher_rule', allowed)
return {'success': True, 'message': 'ok'}
async def delete_voucher_rule(request, params_kw):
rid = (dict(params_kw)).get('id')
if not rid:
return {'success': False, 'message': '缺少 id'}
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
await sor.D('voucher_rule', {'id': rid})
return {'success': True, 'message': 'ok'}
async def issue_voucher_api(request, params_kw):
"""管理端手动发券template_id/template_code 二选一 + customer_id。"""
env = request._run_ns
user_id = await env.get_user()
data = dict(params_kw)
customer_id = data.get('customer_id')
if not customer_id:
return {'success': False, 'message': 'customer_id 必填'}
template_id = data.get('template_id')
if not template_id and data.get('template_code'):
db0 = _get_db()
async with db0.sqlorContext(_get_dbname()) as sor:
recs = await sor.R('voucher_template', {'code': data['template_code']})
template_id = recs[0].id if recs else None
if not template_id:
return {'success': False, 'message': '模板不存在'}
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
result = await issue_voucher(
sor, template_id, customer_id,
source=data.get('source') or 'manual',
issued_by=user_id or '',
remark=data.get('remark') or '')
return result
async def get_voucher_template_options(request, params_kw):
"""模板下拉选项启用中的模板code 组件契约:纯 [{value,text}] 数组)。"""
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
recs = await sor.R('voucher_template', {'status': 'active', 'sort': 'name'})
return [{'value': r.id, 'text': '{}{}元)'.format(r.name, r.face_value)}
for r in (recs or [])]
async def update_voucher_instance(request, params_kw):
"""实例修正(管理端):仅未使用的券可改归属客户/备注。
状态不走这里使用由核销 API作废由 invalidate_voucher_api
防止界面直改 status 绕过原子守卫造成账实不符
"""
data = dict(params_kw or {})
vid = (data.get('id') or '').strip()
if not vid:
return {'success': False, 'message': '缺少 id'}
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
recs = await sor.R('voucher_instance', {'id': vid})
if not recs:
return {'success': False, 'message': '券不存在'}
if (recs[0].status or '') != 'unused':
return {'success': False,
'message': '券状态为 {},只有未使用的券可修改'.format(recs[0].status)}
# 显式拒绝危险字段变更(表单 submit_changed 只送变更字段,误改要报错而非静默丢弃)
new_tid = (data.get('template_id') or '').strip()
if new_tid and new_tid != (recs[0].template_id or ''):
return {'success': False, 'message': '券所属模板不可修改(面值/规则随模板)'}
new_src = (data.get('source') or '').strip()
if new_src and new_src != (recs[0].source or ''):
return {'success': False, 'message': '发放来源不可修改(审计字段)'}
allowed = {'id': vid}
for k in ('customer_id', 'remark'):
v = data.get(k)
if v is not None and str(v).strip() != '':
allowed[k] = v
if len(allowed) <= 1:
return {'success': False, 'message': '无可更新字段'}
allowed['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
await sor.U('voucher_instance', allowed)
return {'success': True, 'message': 'ok'}
async def delete_voucher_instance(request, params_kw):
"""删除实例(管理端纠错):已使用的券有流水关联,禁删(审计完整性)。"""
data = dict(params_kw or {})
vid = (data.get('id') or '').strip()
if not vid:
return {'success': False, 'message': '缺少 id'}
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
recs = await sor.R('voucher_instance', {'id': vid})
if not recs:
return {'success': False, 'message': '券不存在'}
if (recs[0].status or '') == 'used':
return {'success': False, 'message': '券已使用(有流水关联),不能删除'}
await sor.D('voucher_instance', {'id': vid})
# 发行额度回吐(未使用的券删除后名额释放)
await sor.execute(
"UPDATE voucher_template SET issued_count=GREATEST(issued_count-1,0) "
"WHERE id=${tid}$", {'tid': recs[0].template_id})
return {'success': True, 'message': 'ok'}
async def get_registered_rule_types_api(request=None, params_kw=None):
"""已注册规则类型清单(管理界面配规则用)。"""
return {'status': 'success', 'data': get_all_rule_types()}
# ========== 注册送券挂钩 ==========
async def on_organization_created(data):
"""organization 表 C:after 事件 → 给新注册机构发欢迎券。
- 挂钩点在 sqlor 事件层pricing/rbac 同款先例不改 rbac 基准模块
- N 由欢迎模板 total_count 原子占额控制超发自动拒绝
- 任何异常都只记日志EventDispatcher continue_on_error绝不阻断注册
"""
ns = data if isinstance(data, dict) else {}
org_id = ns.get('id') or ''
if not org_id or org_id == '0':
return
try:
db = _get_db()
async with db.sqlorContext(_get_dbname()) as sor:
recs = await sor.R('voucher_template', {'code': WELCOME_TEMPLATE_CODE})
if not recs:
debug('voucher: welcome template {} not found, skip'.format(WELCOME_TEMPLATE_CODE))
return
tpl = recs[0]
if (tpl.status or '') != 'active':
return
# 幂等:同一机构不重复送(重复触发事件/重建机构时防刷)
exist = await sor.sqlExe(
"SELECT id FROM voucher_instance WHERE customer_id=${cid}$ "
"AND template_id=${tid}$ AND source='register'",
{'cid': org_id, 'tid': tpl.id})
if exist:
debug('voucher: org {} already got welcome voucher'.format(org_id))
return
result = await issue_voucher(
sor, tpl.id, org_id, source='register',
issued_by='system', remark='新客户注册赠券')
if result.get('success'):
info('voucher: welcome voucher {} issued to org {}'.format(
result.get('code'), org_id))
else:
debug('voucher: welcome issue skipped for {}: {}'.format(
org_id, result.get('message')))
except Exception as e:
exception('voucher: on_organization_created failed (non-blocking): {}'.format(e))
def _bind_org_event():
"""bind organization:c:afterDBPools 是 EventDispatcher 单例)。"""
try:
dbname = _get_dbname()
db = _get_db()
db.bind('{}:organization:c:after'.format(dbname), on_organization_created)
debug('voucher: organization:c:after bound on db {}'.format(dbname))
except Exception as e:
# 宿主没有事件机制/库未配置时不阻断加载
exception('voucher: bind organization event failed: {}'.format(e))
# ========== Module Load ==========
def load_voucher():
"""注册所有函数到 ServerEnv"""
"""注册所有函数到 ServerEnv + 绑定注册送券事件。"""
env = ServerEnv()
# Template CRUD
# 客户侧
env.get_available_vouchers_api = get_available_vouchers_api
env.apply_voucher_api = apply_voucher_api
env.validate_voucher_api = validate_voucher_api
env.my_vouchers_api = my_vouchers_api
# 管理侧
env.create_voucher_template = create_voucher_template
env.update_voucher_template = update_voucher_template
env.delete_voucher_template = delete_voucher_template
# Rule CRUD
env.create_voucher_rule = create_voucher_rule
env.update_voucher_rule = update_voucher_rule
env.delete_voucher_rule = delete_voucher_rule
# Instance CRUD
env.create_voucher_instance = create_voucher_instance
env.issue_voucher_api = issue_voucher_api
env.invalidate_voucher_api = invalidate_voucher_api
env.update_voucher_instance = update_voucher_instance
env.delete_voucher_instance = delete_voucher_instance
# Usage Log CRUD
env.create_voucher_usage_log = create_voucher_usage_log
env.update_voucher_usage_log = update_voucher_usage_log
env.delete_voucher_usage_log = delete_voucher_usage_log
# Options
env.get_voucher_template_options = get_voucher_template_options
env.get_voucher_instance_options = get_voucher_instance_options
env.get_registered_rule_types = get_registered_rule_types_api
# Engine API
env.apply_voucher_api = apply_voucher_api
env.get_available_vouchers_api = get_available_vouchers_api
env.get_registered_rule_types = get_registered_rule_types
# Import validators to trigger @register_rule decorators
import voucher.rules.validators # noqa: F401
# 引擎级(供其他模块 import 后直接调,如 product_management 结算联动)
env.voucher_issue = issue_voucher
env.voucher_apply = apply_voucher
env.voucher_batch_apply = batch_apply_vouchers
env.voucher_get_available = get_available_vouchers
_bind_org_event()
info('voucher module loaded (v{})'.format(MODULE_VERSION))
return True

View File

@ -0,0 +1,4 @@
from .registry import register_rule, get_all_rule_types, get_rule_validator, RULE_REGISTRY
from . import validators # noqa: F401 触发 @register_rule 注册
__all__ = ['register_rule', 'get_all_rule_types', 'get_rule_validator', 'RULE_REGISTRY']

294
voucher/rules/engine.py Normal file
View File

@ -0,0 +1,294 @@
"""代金券规则引擎sqlor 风格,全异步)。
核心原则
- 一次性使用券用后直接作废不找零无余额残留
- 规则可配置模板下挂 voucher_rule sort_order 依次执行 validator
- 并发安全核销用条件 UPDATEstatus='unused' 守卫+ affected_rows 判定
同一张券并发双花时只有一个请求成功
所有函数接收 sor已处于 async with DBPools().sqlorContext(dbname) 上下文
不自己开库由调用方init.py / dspy决定事务边界
"""
import json
from datetime import datetime
from decimal import Decimal, InvalidOperation
from appPublic.log import debug, exception
from appPublic.uniqueID import getID
from .registry import RULE_REGISTRY
_TIME_FMT = '%Y-%m-%d %H:%M:%S'
def _dec(v, default='0'):
try:
return Decimal(str(v))
except (InvalidOperation, ValueError, TypeError):
return Decimal(default)
def _now():
return datetime.now()
def _to_dt(v):
"""DB datetime 字段 → python datetime兼容字符串"""
if v is None or v == '':
return None
if isinstance(v, datetime):
return v
try:
return datetime.strptime(str(v)[:19], _TIME_FMT)
except (ValueError, TypeError):
return None
def _parse_rule_config(raw):
"""rule_config 容错解析:合法 JSON → dict空 → {};非法 → None视为规则损坏"""
if raw is None or raw == '':
return {}
if isinstance(raw, dict):
return raw
try:
cfg = json.loads(raw)
return cfg if isinstance(cfg, dict) else None
except (json.JSONDecodeError, TypeError):
return None
async def validate_voucher(sor, instance_id, context):
"""校验代金券是否可用。
Returns:
(ok: bool, deductible: Decimal, err: str|None)
"""
recs = await sor.R('voucher_instance', {'id': instance_id})
if not recs:
return False, Decimal('0'), '代金券不存在'
inst = recs[0]
if (inst.status or '') != 'unused':
return False, Decimal('0'), '代金券已使用或失效'
now = _now()
vf = _to_dt(inst.valid_from)
vt = _to_dt(inst.valid_to)
if vf and now < vf:
return False, Decimal('0'), '代金券尚未生效'
if vt and now > vt:
return False, Decimal('0'), '代金券已过期'
# 模板停用则券不可用(发放后管理员下架模板的兜底)
t_recs = await sor.R('voucher_template', {'id': inst.template_id})
if t_recs and (t_recs[0].status or '') != 'active':
return False, Decimal('0'), '代金券所属模板已停用'
# 加载模板启用规则sort_order 升序)
ns = {'template_id': inst.template_id, 'enabled': '1', 'sort': 'sort_order'}
rules = await sor.R('voucher_rule', ns)
ctx = {
'instance': inst,
'used_count': int(inst.get('used_count', 0) or 0) if isinstance(inst, dict) else 0,
'valid_from': vf,
'valid_to': vt,
'face_value': inst.face_value,
}
ctx.update(context or {})
for rule in rules or []:
rule_type = rule.rule_type
validator = RULE_REGISTRY.get(rule_type)
if validator is None:
debug('voucher: unknown rule_type {} skipped'.format(rule_type))
continue
config = _parse_rule_config(rule.rule_config)
if config is None:
# 规则配置损坏:拒绝使用而不是静默放行(防绕过限制)
return False, Decimal('0'), '规则({})配置损坏,请联系管理员'.format(rule_type)
ok, err = validator(config, ctx)
if not ok:
return False, Decimal('0'), err or '不满足使用条件'
face_value = _dec(inst.face_value)
request_amount = _dec((context or {}).get('request_amount', 0))
deductible = min(face_value, request_amount)
return True, deductible, None
async def apply_voucher(sor, instance_id, customer_id, order_id, context):
"""使用一张代金券(一次性,不找零)。
并发安全先条件 UPDATE 占券status='unused' 守卫affected_rows=0
说明已被并发请求用掉/失效直接失败占券成功后再写流水
Returns:
(ok: bool, deducted: Decimal, err: str|None)
"""
context = context or {}
ok, deductible, err = await validate_voucher(sor, instance_id, context)
if not ok:
return False, Decimal('0'), err
now = _now().strftime(_TIME_FMT)
# 条件 UPDATE 原子占券:只有 status='unused' 且属于该客户时才生效
upd = ("UPDATE voucher_instance SET status='used', "
"actual_deducted=${ded}$, used_at=${now}$, order_id=${oid}$, "
"updated_at=${now}$ "
"WHERE id=${vid}$ AND status='unused' AND customer_id=${cid}$")
affected = await sor.execute(upd, {
'ded': float(deductible), 'now': now, 'oid': order_id or '',
'vid': instance_id, 'cid': customer_id or '',
})
if not affected:
return False, Decimal('0'), '代金券已被使用或不属于该客户'
# 占券成功 → 重读实例(拿 template_id/face_value 写流水)
recs = await sor.R('voucher_instance', {'id': instance_id})
inst = recs[0] if recs else None
await sor.C('voucher_usage_log', {
'id': getID(),
'instance_id': instance_id,
'template_id': (inst.template_id if inst else '') or '',
'customer_id': customer_id or '',
'order_id': order_id or '',
'face_value': float(_dec(inst.face_value if inst else 0)),
'deducted_amount': float(deductible),
'product_type': context.get('product_type') or '',
'product_name': context.get('product_name') or '',
'used_at': now,
'used_by': context.get('used_by') or '',
})
return True, deductible, None
async def batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context):
"""批量用券:按传入顺序依次尝试,消费金额抵扣完即停。
Returns:
{'total_deducted': float, 'remaining': float, 'details': [...]}
"""
context = dict(context or {})
total = Decimal('0')
remaining = _dec(context.get('request_amount', 0))
details = []
for vid in voucher_ids or []:
if remaining <= 0:
break
ctx = dict(context)
ctx['request_amount'] = float(remaining)
ok, deducted, err = await apply_voucher(sor, vid, customer_id, order_id, ctx)
if ok:
total += deducted
remaining -= deducted
details.append({'voucher_id': vid, 'deducted': float(deducted), 'error': None})
else:
details.append({'voucher_id': vid, 'deducted': 0, 'error': err})
return {
'total_deducted': float(total),
'remaining': float(remaining),
'details': details,
}
async def get_available_vouchers(sor, customer_id, context=None):
"""查询客户可用代金券(带 context 时逐张预校验过滤)。"""
ns = {'customer_id': customer_id, 'status': 'unused', 'sort': 'valid_to'}
recs = await sor.R('voucher_instance', ns)
vouchers = list(recs or [])
if not context:
return vouchers
available = []
for v in vouchers:
ok, _, _ = await validate_voucher(sor, v.id, context)
if ok:
available.append(v)
return available
async def issue_voucher(sor, template_id, customer_id, source='manual',
issued_by='', remark='', valid_days=None):
"""按模板给客户发一张券(原子占发行额度)。
发行量控制total_count>0 时用条件 UPDATE 占额
issued_count<total_count 守卫并发超发时占额失败即拒发
total_count=0 表示不限量
Returns:
{'success': bool, 'message': str, 'instance_id': str, 'code': str}
"""
t_recs = await sor.R('voucher_template', {'id': template_id})
if not t_recs:
return {'success': False, 'message': '模板不存在'}
tpl = t_recs[0]
if (tpl.status or '') != 'active':
return {'success': False, 'message': '模板未启用status={}'.format(tpl.status)}
total_count = int(tpl.total_count or 0)
now_dt = _now()
# ── 原子占发行额度 ──
if total_count > 0:
upd = ("UPDATE voucher_template SET issued_count=issued_count+1, "
"updated_at=${now}$ "
"WHERE id=${tid}$ AND status='active' "
"AND (total_count=0 OR issued_count<total_count)")
affected = await sor.execute(upd, {
'now': now_dt.strftime(_TIME_FMT), 'tid': template_id})
if not affected:
return {'success': False, 'message': '发行量已发完或模板已停用'}
else:
await sor.execute(
"UPDATE voucher_template SET issued_count=issued_count+1, "
"updated_at=${now}$ WHERE id=${tid}$",
{'now': now_dt.strftime(_TIME_FMT), 'tid': template_id})
days = int(valid_days if valid_days is not None else (tpl.valid_days or 30))
from datetime import timedelta
valid_from = now_dt
valid_to = now_dt + timedelta(days=days)
vid = getID()
code = 'VCH-' + vid.replace('-', '').replace('_', '')[:16].upper()
try:
await sor.C('voucher_instance', {
'id': vid,
'template_id': template_id,
'customer_id': customer_id,
'code': code,
'status': 'unused',
'face_value': float(_dec(tpl.face_value)),
'valid_from': valid_from.strftime(_TIME_FMT),
'valid_to': valid_to.strftime(_TIME_FMT),
'issued_at': now_dt.strftime(_TIME_FMT),
'source': source or 'manual',
'issued_by': issued_by or '',
'remark': remark or '',
})
except Exception as e:
exception('issue_voucher: insert instance failed: {}'.format(e))
# 回滚占额
await sor.execute(
"UPDATE voucher_template SET issued_count=GREATEST(issued_count-1,0) "
"WHERE id=${tid}$", {'tid': template_id})
return {'success': False, 'message': '发券失败: {}'.format(e)}
return {'success': True, 'message': 'ok', 'instance_id': vid, 'code': code}
async def expire_vouchers(sor):
"""把已过期但仍 unused 的券批量置 expired定时/查询前兜底)。
Returns: 置过期张数
"""
now = _now().strftime(_TIME_FMT)
affected = await sor.execute(
"UPDATE voucher_instance SET status='expired', updated_at=${now}$ "
"WHERE status='unused' AND valid_to < ${now}$",
{'now': now})
return affected or 0

33
voucher/rules/registry.py Normal file
View File

@ -0,0 +1,33 @@
"""规则注册机制:新增规则只需写一个 validator 函数 + @register_rule 装饰器。
validator 签名(config: dict, context: dict) -> (bool, error_msg or None)
validator 必须是纯函数不触库无副作用引擎负责取数与编排
"""
RULE_REGISTRY = {}
RULE_DESCRIPTIONS = {}
def register_rule(rule_type, description=''):
"""装饰器注册规则类型description 供管理界面/接口展示)。"""
def decorator(func):
RULE_REGISTRY[rule_type] = func
desc = description
if not desc and func.__doc__:
desc = func.__doc__.strip().splitlines()[0]
RULE_DESCRIPTIONS[rule_type] = desc
return func
return decorator
def get_all_rule_types():
"""返回所有已注册规则类型(含说明)。"""
return [
{'rule_type': k, 'description': RULE_DESCRIPTIONS.get(k, '')}
for k in RULE_REGISTRY
]
def get_rule_validator(rule_type):
"""根据类型获取 validator 函数,未注册返回 None。"""
return RULE_REGISTRY.get(rule_type)

122
voucher/rules/validators.py Normal file
View File

@ -0,0 +1,122 @@
"""内置规则校验器:每种规则一个纯函数,返回 (bool, error_msg)。
config = voucher_rule.rule_config JSON 解析后的 dict
context = 引擎构建的执行上下文request_amount/product_type/product_name/
user_level/face_value/valid_from/valid_to
rule_type 命名注意appcodes_kv.id = {parentid}_{k} 32 字符
parentid='vchr_rule_type' 14 字符所以类型名保持短名
"""
import json
from decimal import Decimal, InvalidOperation
from .registry import register_rule
def _dec(v, default='0'):
try:
return Decimal(str(v))
except (InvalidOperation, ValueError, TypeError):
return Decimal(default)
def _as_list(v):
"""config 数组字段容错JSON array / 逗号分隔字符串 / 单值 → list。"""
if not v:
return []
if isinstance(v, (list, tuple)):
return [str(x) for x in v]
if isinstance(v, str):
try:
parsed = json.loads(v)
if isinstance(parsed, list):
return [str(x) for x in parsed]
except (json.JSONDecodeError, TypeError):
pass
return [s.strip() for s in v.split(',') if s.strip()]
return [str(v)]
@register_rule('min_amount', '最低消费门槛')
def check_min_amount(config, context):
"""消费金额须达到最低门槛才可用券。config: {"min_value": 100}"""
min_val = _dec(config.get('min_value', 0))
if min_val <= 0:
return True, None
request_amount = _dec(context.get('request_amount', 0))
if request_amount < min_val:
return False, '未达最低消费 {}'.format(min_val)
return True, None
@register_rule('max_amount', '最高消费限制')
def check_max_amount(config, context):
"""消费金额超过上限则不可用券。config: {"max_value": 1000}"""
max_val = _dec(config.get('max_value', 0))
if max_val <= 0:
return True, None
request_amount = _dec(context.get('request_amount', 0))
if request_amount > max_val:
return False, '超过最高消费 {}'.format(max_val)
return True, None
@register_rule('product_type', '限定产品类型')
def check_product_type(config, context):
"""仅限指定产品类型可用。config: {"product_types": ["pipeline"]}"""
allowed = _as_list(config.get('product_types'))
if not allowed:
return True, None
current = str(context.get('product_type') or '')
if current not in allowed:
return False, '仅限 {} 类型产品使用'.format('/'.join(allowed))
return True, None
@register_rule('product', '限定特定产品')
def check_specific_product(config, context):
"""仅限指定产品(编码/名称可用。config: {"products": ["PP_PIPE_DEV"]}"""
allowed = _as_list(config.get('products'))
if not allowed:
return True, None
current = str(context.get('product_name') or '')
if current not in allowed:
return False, '仅限 {} 使用'.format('/'.join(allowed))
return True, None
@register_rule('exclude_product', '排除特定产品')
def check_exclude_product(config, context):
"""指定产品不可用此券。config: {"products": ["xxx"]}"""
excluded = _as_list(config.get('products'))
if not excluded:
return True, None
current = str(context.get('product_name') or '')
if current and current in excluded:
return False, '{} 不可使用此代金券'.format(current)
return True, None
@register_rule('max_usage_count', '最大使用次数')
def check_max_usage_count(config, context):
"""实例累计使用次数上限。config: {"max_count": 1}"""
max_count = int(config.get('max_count', 0) or 0)
if max_count <= 0:
return True, None
used_count = int(context.get('used_count', 0) or 0)
if used_count >= max_count:
return False, '已达最大使用次数 {}'.format(max_count)
return True, None
@register_rule('user_level', '用户等级限制')
def check_user_level(config, context):
"""客户等级须达到下限。config: {"min_level": 2}"""
required = int(config.get('min_level', 0) or 0)
if required <= 0:
return True, None
level = int(context.get('user_level', 0) or 0)
if level < required:
return False, '{} 级以上用户'.format(required)
return True, None

View File

@ -1,17 +1,21 @@
import json
customer_id = params_kw.get('customer_id', '')
order_id = params_kw.get('order_id', '')
voucher_ids = params_kw.get('voucher_ids', [])
# apply_voucher.dspy — 核销代金券(一次性,不找零;并发安全条件 UPDATE
customer_id = (params_kw.get('customer_id') or '').strip()
if not customer_id:
customer_id = await get_userorgid() or ''
order_id = (params_kw.get('order_id') or '').strip()
voucher_ids = params_kw.get('voucher_ids') or []
if isinstance(voucher_ids, str):
voucher_ids = json.loads(voucher_ids)
context = {
'request_amount': params_kw.get('request_amount', 0),
'product_type': params_kw.get('product_type', ''),
'product_name': params_kw.get('product_name', ''),
'user_level': params_kw.get('user_level', 0),
}
result = await apply_voucher_api(customer_id, order_id, voucher_ids, context)
return json.dumps(result)
try:
voucher_ids = json.loads(voucher_ids)
except (ValueError, TypeError):
voucher_ids = [s.strip() for s in voucher_ids.split(',') if s.strip()]
context = {}
for k in ('product_type', 'product_name'):
if params_kw.get(k):
context[k] = params_kw.get(k)
try:
context['request_amount'] = float(params_kw.get('request_amount') or 0)
except (ValueError, TypeError):
context['request_amount'] = 0
context['used_by'] = await get_user() or ''
return await apply_voucher_api(customer_id, order_id, voucher_ids, context)

View File

@ -1,13 +1,14 @@
import json
customer_id = params_kw.get('customer_id', '')
# get_available.dspy — 内部查询可用券(带规则预校验过滤;供结算方/调试)
customer_id = (params_kw.get('customer_id') or '').strip()
if not customer_id:
customer_id = await get_userorgid() or ''
context = {}
if params_kw.get('product_type'):
context['product_type'] = params_kw.get('product_type')
if params_kw.get('product_name'):
context['product_name'] = params_kw.get('product_name')
for k in ('product_type', 'product_name'):
if params_kw.get(k):
context[k] = params_kw.get(k)
if params_kw.get('request_amount'):
context['request_amount'] = params_kw.get('request_amount')
result = await get_available_vouchers_api(customer_id, context if context else None)
return json.dumps(result)
try:
context['request_amount'] = float(params_kw.get('request_amount'))
except (ValueError, TypeError):
pass
return await get_available_vouchers_api(customer_id, context if context else None)

View File

@ -1,9 +0,0 @@
data = {}
for key in ['template_id', 'customer_id', 'code', 'source', 'remark', 'valid_from', 'valid_to']:
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
if val is not None:
data[key] = val
result = await create_voucher_instance(data)
return result

View File

@ -1,4 +1,5 @@
conditions = {'id': params_kw.get('id', '')}
result = await delete_voucher_instance(conditions)
return result
# voucher_instance_delete.dspy — 实例删除(已使用禁删保审计;未使用删除回吐发行额度)
r = await delete_voucher_instance(request, params_kw)
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -1,4 +0,0 @@
import json
result = await get_voucher_instance_options()
return json.dumps(result)

View File

@ -1,10 +1,5 @@
conditions = {'id': params_kw.get('id', '')}
data = {}
for key in ['template_id', 'customer_id', 'code', 'status', 'source', 'remark']:
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
if val is not None:
data[key] = val
result = await update_voucher_instance(conditions, data)
return result
# voucher_instance_update.dspy — 实例修正(仅未使用的券可改客户/备注;状态变更走发券/核销/作废 API
r = await update_voucher_instance(request, params_kw)
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -0,0 +1,5 @@
# voucher_invalidate.dspy — 作废券unused → void 原子条件 UPDATE已用/过期/已作废拒绝)
r = await invalidate_voucher_api(request, params_kw)
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "已作废", "message": '券已作废', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -0,0 +1,5 @@
# voucher_issue.dspy — 管理端手动发券(走 issue_voucher 引擎:原子占发行额度/生成券码/计算有效期)
r = await issue_voucher_api(request, params_kw)
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "发券成功", "message": '券码 ' + str(r.get('code') or ''), "timeout": 5, "cwidth": 18, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -0,0 +1,6 @@
# my_vouchers.dspy — 客户「我的代金券」列表本机构隔离Tabular 契约 {total, rows}
user_id = await get_user()
if not user_id:
return {"widgettype": "Error",
"options": {"title": "Authorization Error", "message": "Please login", "timeout": 3}}
return await my_vouchers_api(request, params_kw)

View File

@ -1,9 +1,5 @@
data = {}
for key in ['template_id', 'rule_type', 'rule_config', 'enabled', 'sort_order', 'remark']:
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
if val is not None:
data[key] = val
result = await create_voucher_rule(data)
return result
# voucher_rule_create.dspy — 规则新增rule_config 合法性由 helper 校验)
r = await create_voucher_rule(request, params_kw)
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -1,4 +1,5 @@
conditions = {'id': params_kw.get('id', '')}
result = await delete_voucher_rule(conditions)
return result
# voucher_rule_delete.dspy — 规则删除
r = await delete_voucher_rule(request, params_kw)
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -1,10 +1,5 @@
conditions = {'id': params_kw.get('id', '')}
data = {}
for key in ['template_id', 'rule_type', 'rule_config', 'enabled', 'sort_order', 'remark']:
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
if val is not None:
data[key] = val
result = await update_voucher_rule(conditions, data)
return result
# voucher_rule_update.dspy — 规则更新
r = await update_voucher_rule(request, params_kw)
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -1,4 +1,2 @@
import json
result = await get_registered_rule_types()
return json.dumps(result)
# rule_types.dspy — 已注册规则类型清单(管理界面配规则参考)
return await get_registered_rule_types(request, params_kw)

View File

@ -1,10 +1,5 @@
params = params_kw if hasattr(params_kw, '__getitem__') else {}
data = {}
for key in ['name', 'code', 'face_value', 'total_count', 'valid_days', 'status', 'remark']:
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
if val is not None:
data[key] = val
result = await create_voucher_template(data)
return result
# voucher_template_create.dspy — 模板新增(走模块 helperorg 隔离+code 唯一)
r = await create_voucher_template(request, params_kw)
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -1,4 +1,5 @@
conditions = {'id': params_kw.get('id', '')}
result = await delete_voucher_template(conditions)
return result
# voucher_template_delete.dspy — 模板删除(已发放券的模板拒绝,引导用「停用」)
r = await delete_voucher_template(request, params_kw)
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -1,4 +1,6 @@
import json
result = await get_voucher_template_options()
return json.dumps(result)
# voucher_template_options.dspy — 启用模板下拉code 组件契约:纯 [{value,text}] 数组)
try:
return await get_voucher_template_options(request, params_kw)
except Exception as e:
debug('voucher_template_options error: ' + str(e))
return []

View File

@ -1,10 +1,5 @@
conditions = {'id': params_kw.get('id', '')}
data = {}
for key in ['name', 'code', 'face_value', 'total_count', 'valid_days', 'status', 'remark', 'updater']:
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
if val is not None:
data[key] = val
result = await update_voucher_template(conditions, data)
return result
# voucher_template_update.dspy — 模板更新
r = await update_voucher_template(request, params_kw)
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -1,9 +0,0 @@
data = {}
for key in ['instance_id', 'customer_id', 'order_id', 'face_value', 'deducted_amount', 'used_at', 'used_by', 'product_type', 'product_name']:
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
if val is not None:
data[key] = val
result = await create_voucher_usage_log(data)
return result

View File

@ -1,4 +0,0 @@
conditions = {'id': params_kw.get('id', '')}
result = await delete_voucher_usage_log(conditions)
return result

View File

@ -1,10 +0,0 @@
conditions = {'id': params_kw.get('id', '')}
data = {}
for key in ['instance_id', 'customer_id', 'order_id', 'face_value', 'deducted_amount', 'used_by', 'product_type', 'product_name']:
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
if val is not None:
data[key] = val
result = await update_voucher_usage_log(conditions, data)
return result

View File

@ -1,9 +1,8 @@
import json
# 获取当前登录用户的组织ID作为客户ID
# v1/available.dspy — 客户自助查询可用券HTTP API登录态按机构隔离
customer_id = await get_userorgid()
if not customer_id:
return {'status': 'error', 'message': 'not logged in', 'data': [], 'total': 0}
# 构建查询上下文(可选过滤条件)
context = {}
if params_kw.get('product_type'):
context['product_type'] = params_kw.get('product_type')
@ -15,25 +14,15 @@ if params_kw.get('request_amount'):
except (ValueError, TypeError):
pass
# 查询可用代金券
result = await get_available_vouchers_api(customer_id, context if context else None)
# 格式化输出
if result.get('status') == 'success':
vouchers = []
for v in result.get('data', []):
vouchers.append({
'id': v.get('id'),
'code': v.get('code'),
'face_value': float(v.get('face_value', 0)),
'valid_from': str(v.get('valid_from', '')),
'valid_to': str(v.get('valid_to', '')),
'template_name': v.get('template_name', ''),
})
return json.dumps({
'status': 'success',
'data': vouchers,
'total': len(vouchers)
vouchers = []
for v in (result.get('data') or []):
vouchers.append({
'id': v.get('id'),
'code': v.get('code'),
'face_value': float(v.get('face_value') or 0),
'valid_from': str(v.get('valid_from') or ''),
'valid_to': str(v.get('valid_to') or ''),
'template_name': v.get('template_name') or '',
})
else:
return json.dumps(result)
return {'status': 'success', 'data': vouchers, 'total': len(vouchers)}

View File

@ -1,48 +1,159 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px"},
"options": {
"width": "100%",
"height": "100%",
"padding": "20px"
},
"subwidgets": [
{"widgettype": "Title3", "options": {"text": "代金券管理"}},
{"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"},
"subwidgets": [
{"widgettype": "VBox",
"options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"},
"binds": [{
"wid": "self", "event": "click", "actiontype": "urlwidget",
"target": "app.voucher_content",
"options": {"url": "{{entire_url('/voucher/voucher_template_list/index.ui')}}"},
"mode": "replace"
}],
"subwidgets": [
{"widgettype": "Text", "options": {"text": "模板管理", "fontSize": "18px", "bold": true}},
{"widgettype": "Text", "options": {"text": "管理代金券模板和规则配置", "fontSize": "14px", "color": "#666"}}
]},
{"widgettype": "VBox",
"options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"},
"binds": [{
"wid": "self", "event": "click", "actiontype": "urlwidget",
"target": "app.voucher_content",
"options": {"url": "{{entire_url('/voucher/voucher_instance_list/index.ui')}}"},
"mode": "replace"
}],
"subwidgets": [
{"widgettype": "Text", "options": {"text": "券实例管理", "fontSize": "18px", "bold": true}},
{"widgettype": "Text", "options": {"text": "查看和发放代金券", "fontSize": "14px", "color": "#666"}}
]},
{"widgettype": "VBox",
"options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"},
"binds": [{
"wid": "self", "event": "click", "actiontype": "urlwidget",
"target": "app.voucher_content",
"options": {"url": "{{entire_url('/voucher/voucher_usage_log_list/index.ui')}}"},
"mode": "replace"
}],
"subwidgets": [
{"widgettype": "Text", "options": {"text": "使用流水", "fontSize": "18px", "bold": true}},
{"widgettype": "Text", "options": {"text": "查看代金券使用记录", "fontSize": "14px", "color": "#666"}}
]}
]},
{"widgettype": "VBox", "id": "voucher_content",
"options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
{
"widgettype": "Title3",
"options": {
"otext": "代金券管理",
"text": "代金券管理",
"i18n": true
}
},
{
"widgettype": "ResponsableBox",
"options": {
"gap": "16px",
"minWidth": "250px"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"css": "card",
"padding": "20px",
"cursor": "pointer",
"cwidth": 20
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "voucher_content",
"options": {
"url": "{{entire_url('/voucher/voucher_template_list/index.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Title4",
"options": {
"otext": "模板管理",
"text": "模板管理",
"i18n": true
}
},
{
"widgettype": "Text",
"options": {
"otext": "管理代金券模板和规则配置",
"text": "管理代金券模板和规则配置",
"i18n": true,
"cfontsize": 0.9,
"color": "#666"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"css": "card",
"padding": "20px",
"cursor": "pointer",
"cwidth": 20
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "voucher_content",
"options": {
"url": "{{entire_url('/voucher/voucher_instance_list/index.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Title4",
"options": {
"otext": "券实例管理",
"text": "券实例管理",
"i18n": true
}
},
{
"widgettype": "Text",
"options": {
"otext": "查看和发放代金券",
"text": "查看和发放代金券",
"i18n": true,
"cfontsize": 0.9,
"color": "#666"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"css": "card",
"padding": "20px",
"cursor": "pointer",
"cwidth": 20
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "voucher_content",
"options": {
"url": "{{entire_url('/voucher/voucher_usage_log_list/index.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Title4",
"options": {
"otext": "使用流水",
"text": "使用流水",
"i18n": true
}
},
{
"widgettype": "Text",
"options": {
"otext": "查看代金券使用记录",
"text": "查看代金券使用记录",
"i18n": true,
"cfontsize": 0.9,
"color": "#666"
}
}
]
}
]
},
{
"widgettype": "VBox",
"id": "voucher_content",
"options": {
"width": "100%",
"flex": "1",
"marginTop": "20px"
}
}
]
}

View File

@ -0,0 +1,43 @@
{
"widgettype": "Form",
"id": "voucher_invalidate_form",
"options": {
"cols": 1,
"title": "作废代金券",
"padding": "16px",
"submit_label": "确认作废",
"submit_url": "{{entire_url('/voucher/api/instance/voucher_invalidate.dspy')}}",
"method": "POST",
"fields": [
{
"name": "id",
"label": "实例ID",
"uitype": "hide",
"value": "{{params_kw.get('id') or ''}}"
},
{
"name": "code_display",
"label": "券码",
"uitype": "str",
"nonuse": true,
"value": "{{params_kw.get('code') or ''}}"
},
{
"name": "reason",
"label": "作废原因",
"uitype": "str",
"required": true,
"placeholder": "必填,将追加到券备注(审计留痕)"
}
]
},
"binds": [
{
"wid": "self",
"event": "submited",
"actiontype": "script",
"target": "self",
"script": "var resp=event.params; var d=null; try{d=await resp.json();}catch(e){d=null;} if(d&&d.widgettype){await bricks.widgetBuild(d,bricks.Body);} if(d&&d.widgettype==='Message'){ var t=bricks.getWidgetById('voucher_instance_tbl',bricks.app); if(t){await t.render({});} }"
}
]
}

65
wwwroot/issue_form.ui Normal file
View File

@ -0,0 +1,65 @@
{
"widgettype": "Form",
"id": "voucher_issue_form",
"options": {
"cols": 1,
"title": "发放代金券",
"padding": "16px",
"submit_url": "{{entire_url('/voucher/api/instance/voucher_issue.dspy')}}",
"method": "POST",
"fields": [
{
"name": "template_id",
"label": "模板ID",
"uitype": "hide",
"value": "{{params_kw.get('template_id') or params_kw.get('id') or ''}}"
},
{
"name": "customer_id",
"label": "客户机构",
"uitype": "code",
"required": true,
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}",
"params": {
"dbname": "{{get_module_dbname('voucher')}}",
"table": "organization",
"tblvalue": "id",
"tbltext": "orgname",
"valueField": "customer_id",
"textField": "customer_id_text"
}
},
{
"name": "source",
"label": "发放来源",
"uitype": "code",
"value": "manual",
"data": [
{
"value": "manual",
"text": "手动发放"
},
{
"value": "promo",
"text": "促销活动"
}
]
},
{
"name": "remark",
"label": "备注",
"uitype": "str",
"placeholder": "可选"
}
]
},
"binds": [
{
"wid": "self",
"event": "submited",
"actiontype": "script",
"target": "self",
"script": "var resp=event.params; var d=null; try{d=await resp.json();}catch(e){d=null;} if(d&&d.widgettype){await bricks.widgetBuild(d,bricks.Body);} if(d&&d.widgettype==='Message'){ var t1=bricks.getWidgetById('voucher_instance_tbl',bricks.app); if(t1){await t1.render({});} var t2=bricks.getWidgetById('voucher_template_tbl',bricks.app); if(t2){await t2.render({});} }"
}
]
}

View File

@ -1,25 +0,0 @@
{
"items": [
{
"name": "voucher_template_list",
"label": "模板管理",
"icon": "fa fa-ticket",
"url": "{{entire_url('/voucher/voucher_template_list/index.ui')}}",
"target": "app.sage_main_content"
},
{
"name": "voucher_instance_list",
"label": "券实例",
"icon": "fa fa-list",
"url": "{{entire_url('/voucher/voucher_instance_list/index.ui')}}",
"target": "app.sage_main_content"
},
{
"name": "voucher_usage_log_list",
"label": "使用流水",
"icon": "fa fa-history",
"url": "{{entire_url('/voucher/voucher_usage_log_list/index.ui')}}",
"target": "app.sage_main_content"
}
]
}

62
wwwroot/my/index.ui Normal file
View File

@ -0,0 +1,62 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "8px", "gap": "8px"},
"subwidgets": [
{
"widgettype": "InlineForm",
"id": "my_voucher_filter",
"options": {
"css": "card", "padding": "8px", "submit_label": "查询",
"fields": [
{"name": "status", "label": "状态", "uitype": "code", "cwidth": 10,
"data": [
{"value": "", "text": "全部"},
{"value": "unused", "text": "未使用"},
{"value": "used", "text": "已使用"},
{"value": "expired", "text": "已过期"},
{"value": "void", "text": "已作废"}
]}
]
},
"binds": [{
"wid": "self", "event": "submit",
"actiontype": "script", "target": "my_voucher_table",
"script": "var tbl = bricks.getWidgetById('my_voucher_table', bricks.app.root); if(tbl) await tbl.render(params);"
}]
},
{
"widgettype": "Tabular",
"id": "my_voucher_table",
"options": {
"title": "我的代金券",
"width": "100%", "css": "card",
"data_url": "{{entire_url('/voucher/api/my_vouchers.dspy')}}",
"data_method": "GET", "page_rows": 20,
"row_options": {
"browserfields": {
"exclouded": ["id"],
"alters": {
"status": {"uitype": "code", "data": [
{"value": "unused", "text": "未使用"}, {"value": "used", "text": "已使用"},
{"value": "expired", "text": "已过期"}, {"value": "void", "text": "已作废"}]},
"source": {"uitype": "code", "data": [
{"value": "manual", "text": "手动发放"}, {"value": "register", "text": "注册赠券"},
{"value": "promo", "text": "促销活动"}]}
}
},
"fields": [
{"name": "code", "title": "券码", "type": "str", "cwidth": 16},
{"name": "template_name", "title": "模板", "type": "str", "cwidth": 14},
{"name": "face_value", "title": "面值(元)", "type": "double", "cwidth": 8},
{"name": "status", "title": "状态", "type": "str", "cwidth": 8},
{"name": "actual_deducted", "title": "已抵扣(元)", "type": "double", "cwidth": 9},
{"name": "valid_from", "title": "生效时间", "type": "datetime", "cwidth": 13},
{"name": "valid_to", "title": "过期时间", "type": "datetime", "cwidth": 13},
{"name": "source", "title": "来源", "type": "str", "cwidth": 8},
{"name": "remark", "title": "备注", "type": "str", "cwidth": 16}
]
}
}
}
]
}