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.0(2026-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 | 代金券模板(面值、有效期、发行量、状态) | | 一次性使用 | 核销用条件 UPDATE(`status='unused'` 守卫)+ affected_rows 判定,并发双花只有一个成功 |
| voucher_rule | 规则定义(模板关联,可增删启用禁用) | | 发行量控制 | `issued_count<total_count` 原子占额;total_count=0 不限量;未使用券删除回吐名额 |
| voucher_instance | 券实例(一次性使用,状态:unused/used/expired) | | 规则可配置 | `@register_rule` 注册 validator 纯函数,模板下挂 voucher_rule 行即生效 |
| voucher_usage_log | 使用流水(订单、抵扣金额、产品信息) | | 作废 | `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 示例 | ### 管理侧(logined;菜单「代金券管理」,工单三角色可见)
|-----------|------|------------------|
| `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}` |
### 规则执行流程 | 页面 | 路径 | 说明 |
1. 查询券实例状态(unused + 未过期) |---|---|---|
2. 加载模板关联的所有启用规则(按 sort_order 排序) | 入口 | `/voucher/index.ui` | 卡片导航:模板管理/券实例管理/使用流水 |
3. 依次执行 validator,任一失败即拒绝 | 模板管理 | `/voucher/voucher_template_list/index.ui` | CRUD + 工具栏「发券」弹窗 + 子表「规则配置」 |
4. 全部通过 → 计算抵扣金额 = min(面值, 消费金额) | 券实例 | `/voucher/voucher_instance_list/index.ui` | 新增=发券引擎(禁裸插入)+ 工具栏「作废」弹窗 + 子表「使用记录」 |
5. 记录流水 + 标记券为已使用(一次性作废) | 使用流水 | `/voucher/voucher_usage_log_list/index.ui` | 只读(noedit) |
### 新增规则步骤 管理 API(`wwwroot/api/`,全部薄封装 → init.py ServerEnv 函数):
```python - template/:create/update/delete/options(options 返回纯 `[{value,text}]` 数组)
# rules/validators.py - rule/:create/update/delete;`rule_types.dspy` 返回已注册类型清单
@register_rule('new_rule_type') - instance/:`voucher_issue.dspy`(发券)、update(仅 unused 可改客户/备注)、delete(used 禁删)、`voucher_invalidate.dspy`(作废)
def check_new_rule(config, context): - `apply_voucher.dspy`(核销)、`get_available.dspy`(内部查可用)
# 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"` 的规则记录即可。
--- ### 客户侧(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 ```python
# llmage 的计费逻辑中调用 voucher 引擎 # 结算联动(product_management/accounting 等):
from voucher.rules.engine import apply_voucher, get_available_vouchers result = await env.voucher_batch_apply(sor, customer_id, order_id, voucher_ids, context)
# → {'total_deducted': 150.0, 'remaining': 50.0, 'details': [...]}
# 查询客户可用代金券 # remaining > 0 时从余额扣
vouchers = get_available_vouchers(sor, customer_id, context={ # context: {request_amount(必填), product_type, product_name, user_level, used_by}
'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 时从余额扣减
``` ```
### accounting 模块(余额扣减联动) 注册函数全集(`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`。
```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'])
```
### 客户自助查询 API(voucher 模块提供)
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` + 模板管理界面 |
---
## 目录结构 ## 目录结构
``` ```
voucher/ voucher/
├── voucher/ # Python 包 ├── voucher/ # Python 包
│ ├── __init__.py │ ├── init.py # ServerEnv 注册 + 注册送券挂钩 + load_voucher()
│ └── init.py # 模块初始化 + ServerEnv 注册 │ └── rules/ # registry(@register_rule) + validators(7种) + engine(全异步)
├── rules/ # 规则引擎(纯 Python) ├── init/data.json # 字典种子(模板状态/实例状态/规则类型/来源/启用标志)
│ ├── registry.py # @register_rule 装饰器 ├── models/ # 4 张表定义(建表唯一事实源,json2ddl 渲染)
│ ├── validators.py # 内置规则校验器 ├── json/ # 4 个 CRUD 定义(xls2ui 生成 wwwroot/voucher_*_list/,生成物不入库)
│ └── engine.py # 校验/使用/批量使用 ├── wwwroot/
├── wwwroot/ # 前端 │ ├── index.ui # 管理入口(卡片导航)
│ ├── index.ui # 入口页(卡片导航) │ ├── my/index.ui # 客户「我的代金券」
│ ├── menu.json # 菜单定义 │ ├── issue_form.ui # 发券弹窗表单
│ └── api/ # API 端点 │ ├── invalidate_form.ui # 作废弹窗表单
│ ├── template/ # 模板 CRUD │ └── api/ # 薄封装 dspy(零 import,委托 init.py 注册函数)
│ ├── 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 # 建表 + 编码初始化
├── scripts/ ├── scripts/
│ └── load_path.py # RBAC 权限注册 │ ├── load_path.py # RBAC 注册(logined 全路径逐个精确配)
├── pyproject.toml │ └── seed_welcome_voucher.py # WELCOME400 种子(幂等)
└── README.md ├── 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 ```bash
cd ~/repos/voucher && git pull cd <APP_ROOT>
cd ~/repos/sage/pkgs && ln -sf ~/repos/voucher voucher ./py3/bin/python pkgs/voucher/scripts/seed_welcome_voucher.py # WELCOME400 种子
cd voucher && ~/repos/sage/py3/bin/pip install -e .
``` ```
### 2. 数据库建表 ## Pitfalls
```bash
mysql -u root -p sage < ~/repos/voucher/sql/tables.sql
```
### 3. Sage 集成 - **实例「新增」必须走发券 API**(voucher_issue.dspy → issue_voucher 引擎):裸插入会绕过原子占额/券码生成/有效期计算。CRUD json 的 `editable.new_data_url` 已指向它。
```python - **状态字段禁止界面直改**:使用走核销 API、作废走 invalidate API(都有条件 UPDATE 原子守卫);update_voucher_instance 只允许改 customer_id/remark 且仅 unused 券。
# sage/app/sage.py - 一次性使用:不追踪 remaining_value/partial 状态,抵扣 = min(面值, 消费金额),不找零。
from voucher.init import load_voucher - rule_config 数组字段用 JSON array(validators 兼容逗号分隔串,但写入必须是 JSON)。
# ... in init(): - rule_type 用短名(product_type 而非 applicable_product_type)——appcodes_kv.id = `{parentid}_{k}` ≤32 字符。
load_voucher() - 模板删除:已发放(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()` 后再 widgetBuild(form.js dispatch 原始 resp)。
```bash ## 仓库
# sage/build.sh 安装循环
for m in ... voucher
```
### 4. 菜单入口 - 远端:git@git.opencomputing.cn:yumoqing/voucher.git(main)
```json - 宿主接入提交:pipeline-app(build.sh/pipeline_app.py/import_init.py/index.ui)
// 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 从余额扣
```

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 Cancel=Cancel
Conform: Confirm Conform=Confirm
Discard: Discard Discard=Discard
Reset: Reset Reset=Reset
Submit: Submit Submit=Submit
主键ID: Primary Key ID 代金券=Voucher
产品名称: Product Name 代金券管理=Voucher Management
产品类型: Product Type 模板管理=Template Management
代金券使用流水: Voucher Usage Records 券实例管理=Voucher Instance Management
代金券实例: Voucher Instance 使用流水=Usage Records
代金券模板: Voucher Template 管理代金券模板和规则配置=Manage voucher templates and rule configuration
代金券规则定义: Voucher Rule Definition 查看和发放代金券=View and issue vouchers
使用时间: Usage Time 查看代金券使用记录=View voucher usage records
使用的订单ID: Order ID Used 我的代金券=My Vouchers
创建人: Creator 发放代金券=Issue Voucher
创建时间: Created At 作废=Void
券实例ID: Voucher Instance ID 确认作废=Confirm Void
券面值: Face Value 作废代金券=Void Voucher
发放时间: Issue Time 已作废=Voided
唯一券码: Unique Voucher Code 作废原因=Void Reason
备注: Remark 必填,将追加到券备注(审计留痕)=Required; will be appended to voucher remark (audit trail)
实际抵扣金额: Actual Deduction Amount 按此模板发券=Issue from this template
客户ID: Customer ID 代金券模板=Voucher Template
已发放量: Issued Quantity 代金券实例=Voucher Instance
总发行量: Total Issued 代金券规则=Voucher Rule
执行顺序: Execution Order 代金券使用流水=Voucher Usage Log
操作人: Operator 主键ID=Primary Key ID
是否启用: Enabled 模板名称=Template Name
更新人: Updater 模板编码=Template Code
更新时间: Updated At 模板ID=Template ID
有效期天数: Validity Days 模板=Template
来源: Source 面值=Face Value
模板ID: Template ID 面值(元)=Face Value (CNY)
模板名称: Template Name 已抵扣(元)=Deducted (CNY)
模板编码: Template Code 总发行量(0不限)=Total Issue Count (0=unlimited)
状态: Status 已发放量=Issued Count
生效时间: Effective Time 有效期天数=Valid Days
规则类型: Rule Type 状态=Status
规则配置(JSON): Rule Config (JSON) 草稿=Draft
订单/消费ID: Order/Consumption ID 启用=Active
过期时间: Expiry Time 停用=Inactive
面值: Face Value 备注=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: キャンセル Cancel=キャンセル
Conform: 確認 Conform=確認
Discard: 破棄 Discard=破棄
Reset: リセット Reset=リセット
Submit: 送信 Submit=送信
主键ID: 主キーID 代金券=クーポン
产品名称: 製品名 代金券管理=クーポン管理
产品类型: 製品タイプ 模板管理=テンプレート管理
代金券使用流水: バウチャー使用履歴 券实例管理=クーポンインスタンス管理
代金券实例: バウチャーインスタンス 使用流水=使用履歴
代金券模板: バウチャーテンプレート 管理代金券模板和规则配置=クーポンテンプレートとルール設定を管理
代金券规则定义: バウチャールール定義 查看和发放代金券=クーポンの確認と発行
使用时间: 使用日時 查看代金券使用记录=クーポン使用記録の確認
使用的订单ID: 使用注文ID 我的代金券=マイクーポン
创建人: 作成者 发放代金券=クーポン発行
创建时间: 作成日時 作废=無効化
券实例ID: バウチャーインスタンスID 确认作废=無効化の確認
券面值: 券面額 作废代金券=クーポン無効化
发放时间: 発行日時 已作废=無効化済み
唯一券码: ユニークバウチャーコード 作废原因=無効化理由
备注: 備考 必填,将追加到券备注(审计留痕)=必須入力。クーポン備考に追記されます(監査証跡)
实际抵扣金额: 実質控除額 按此模板发券=このテンプレートで発行
客户ID: 顧客ID 代金券模板=クーポンテンプレート
已发放量: 発行済み数量 代金券实例=クーポンインスタンス
总发行量: 総発行量 代金券规则=クーポンルール
执行顺序: 実行順序 代金券使用流水=クーポン使用履歴
操作人: 操作者 主键ID=主キーID
是否启用: 有効化 模板名称=テンプレート名
更新人: 更新者 模板编码=テンプレートコード
更新时间: 更新日時 模板ID=テンプレートID
有效期天数: 有効日数 模板=テンプレート
来源: ソース 面值=額面
模板ID: テンプレートID 面值(元)=額面(元)
模板名称: テンプレート名 已抵扣(元)=控除額(元)
模板编码: テンプレートコード 总发行量(0不限)=総発行枚数(0=無制限)
状态: ステータス 已发放量=発行済み枚数
生效时间: 発効日時 有效期天数=有効日数
规则类型: ルールタイプ 状态=ステータス
规则配置(JSON): ルール設定(JSON) 草稿=下書き
订单/消费ID: 注文/消費ID 启用=有効
过期时间: 有効期限 停用=無効
面值: 額面 备注=備考
所属机构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
是否启用: 활성화 여부 模板名称=템플릿 이름
更新人: 업데이트 담당자 模板编码=템플릿 코드
更新时间: 업데이트 시간 模板ID=템플릿 ID
有效期天数: 유효 기간(일) 模板=템플릿
来源: 출처 面值=액면가
模板ID: 템플릿 ID 面值(元)=액면가(위안)
模板名称: 템플릿 이름 已抵扣(元)=차감액(위안)
模板编码: 템플릿 코드 总发行量(0不限)=총 발행량(0=무제한)
状态: 상태 已发放量=발행 수량
生效时间: 효력 시작 시간 有效期天数=유효 일수
规则类型: 규칙 유형 状态=상태
规则配置(JSON): 규칙 구성(JSON) 草稿=초안
订单/消费ID: 주문/소비 ID 启用=활성
过期时间: 만료 시간 停用=비활성
面值: 액면가 备注=비고
所属机构ID=소속 기관 ID
创建人=생성자
创建时间=생성 시간
更新时间=수정 시간
规则类型=규칙 유형
规则配置JSON=규칙 구성 JSON
是否启用=활성 여부
执行顺序=실행 순서
客户机构ID=고객 기관 ID
客户机构=고객 기관
券码=상품권 코드
未使用=미사용
已使用=사용됨
已过期=만료됨
实际抵扣金额=실제 차감 금액
生效时间=발효 시간
过期时间=만료 시간
发放时间=발급 시간
使用时间=사용 시간
使用订单ID=주문 ID
订单ID=주문 ID
发放来源=발급 출처
手动发放=수동 발급
注册赠券=가입 증정
促销活动=판촉 활동
发放人=발급자
券实例ID=인스턴스 ID
券实例=상품권 인스턴스
券面值=액면가
抵扣金额=차감 금액
产品类型=제품 유형
产品名称=제품 이름
操作人=운영자
使用记录=사용 기록
规则配置=규칙 구성
最低消费=최소 소비
最高消费=최대 소비
限定产品类型=제품 유형 제한
限定特定产品=특정 제품 제한
排除特定产品=특정 제품 제외
最大使用次数=최대 사용 횟수
用户等级=사용자 등급
发券=발급
客户机构ID必填=고객 기관은 필수입니다
可选=선택
查询=조회
全部=전체

View File

@ -1,42 +1,89 @@
Cancel: Cancel Cancel=Cancel
Conform: Conform Conform=Conform
Discard: Discard Discard=Discard
Reset: Reset Reset=Reset
Submit: Submit Submit=Submit
主键ID: 主键ID 代金券=代金券
产品名称: 产品名称 代金券管理=代金券管理
产品类型: 产品类型 模板管理=模板管理
代金券使用流水: 代金券使用流水 券实例管理=券实例管理
代金券实例: 代金券实例 使用流水=使用流水
代金券模板: 代金券模板 管理代金券模板和规则配置=管理代金券模板和规则配置
代金券规则定义: 代金券规则定义 查看和发放代金券=查看和发放代金券
使用时间: 使用时间 查看代金券使用记录=查看代金券使用记录
使用的订单ID: 使用的订单ID 我的代金券=我的代金券
创建人: 创建人 发放代金券=发放代金券
创建时间: 创建时间 作废=作废
券实例ID: 券实例ID 确认作废=确认作废
券面值: 券面值 作废代金券=作废代金券
发放时间: 发放时间 已作废=已作废
唯一券码: 唯一券码 作废原因=作废原因
备注: 备注 必填,将追加到券备注(审计留痕)=必填,将追加到券备注(审计留痕)
实际抵扣金额: 实际抵扣金额 按此模板发券=按此模板发券
客户ID: 客户ID 代金券模板=代金券模板
已发放量: 已发放量 代金券实例=代金券实例
总发行量: 总发行量 代金券规则=代金券规则
执行顺序: 执行顺序 代金券使用流水=代金券使用流水
操作人: 操作人 主键ID=主键ID
是否启用: 是否启用 模板名称=模板名称
更新人: 更新人 模板编码=模板编码
更新时间: 更新时间 模板ID=模板ID
有效期天数: 有效期天数 模板=模板
来源: 来源 面值=面值
模板ID: 模板ID 面值(元)=面值(元)
模板名称: 模板名称 已抵扣(元)=已抵扣(元)
模板编码: 模板编码 总发行量(0不限)=总发行量(0不限)
状态: 状态 已发放量=已发放量
生效时间: 生效时间 有效期天数=有效期天数
规则类型: 规则类型 状态=状态
规则配置(JSON): 规则配置(JSON) 草稿=草稿
订单/消费ID: 订单/消费ID 启用=启用
过期时间: 过期时间 停用=停用
面值: 面值 备注=备注
所属机构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", "tblname": "voucher_instance",
"alias": "voucher_instance_list", "alias": "voucher_instance_list",
"title": "代金券实例管理", "title": "代金券实例",
"params": { "params": {
"sortby": ["created_at desc"], "sortby": [
"data_filter": { "issued_at desc"
"AND": [ ],
{"field": "customer_id", "op": "=", "var": "customer_id"},
{"field": "status", "op": "=", "var": "status"},
{"field": "template_id", "op": "=", "var": "template_id"}
]
},
"browserfields": { "browserfields": {
"exclouded": [], "exclouded": [
"id",
"template_id"
],
"alters": { "alters": {
"template_id": {
"uitype": "code",
"dataurl": "{{entire_url('../api/template/voucher_template_options.dspy')}}",
"data_field": "options",
"textField": "text",
"valueField": "value"
},
"status": { "status": {
"uitype": "code", "uitype": "code",
"data": [ "data": [
{"value": "unused", "text": "未使用"}, {
{"value": "used", "text": "已使用"}, "value": "unused",
{"value": "expired", "text": "已过期"} "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": { "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')}}", "update_data_url": "{{entire_url('../api/instance/voucher_instance_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/instance/voucher_instance_delete.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": [ "subtables": [
{ {
"field": "id", "field": "instance_id",
"title": "使用记录", "title": "使用记录",
"url": "{{entire_url('../voucher_usage_log_list/')}}", "url": "{{entire_url('../voucher_usage_log_list')}}",
"subtable": "voucher_usage_log" "subtable": "voucher_usage_log"
} }
] ]

View File

@ -1,47 +1,91 @@
{ {
"tblname": "voucher_rule", "tblname": "voucher_rule",
"alias": "voucher_rule_list", "alias": "voucher_rule_list",
"title": "代金券规则管理", "title": "代金券规则",
"params": { "params": {
"sortby": ["sort_order"], "sortby": [
"data_filter": { "sort_order"
"AND": [ ],
{"field": "template_id", "op": "=", "var": "template_id"},
{"field": "rule_type", "op": "=", "var": "rule_type"}
]
},
"browserfields": { "browserfields": {
"exclouded": [], "exclouded": [
"id"
],
"alters": { "alters": {
"template_id": {
"uitype": "code",
"dataurl": "{{entire_url('../api/template/voucher_template_options.dspy')}}",
"data_field": "options",
"textField": "text",
"valueField": "value"
},
"rule_type": { "rule_type": {
"uitype": "code", "uitype": "code",
"data": [ "data": [
{"value": "min_amount", "text": "最低消费"}, {
{"value": "max_amount", "text": "最高消费"}, "value": "min_amount",
{"value": "applicable_product_type", "text": "限定产品类型"}, "text": "最低消费"
{"value": "applicable_product", "text": "限定特定产品"}, },
{"value": "exclude_product", "text": "排除特定产品"}, {
{"value": "max_usage_count", "text": "最大使用次数"}, "value": "max_amount",
{"value": "valid_period", "text": "有效期"}, "text": "最高消费"
{"value": "user_level", "text": "用户等级"} },
{
"value": "product_type",
"text": "限定产品类型"
},
{
"value": "product",
"text": "限定特定产品"
},
{
"value": "exclude_product",
"text": "排除特定产品"
},
{
"value": "max_usage_count",
"text": "最大使用次数"
},
{
"value": "user_level",
"text": "用户等级"
}
] ]
}, },
"enabled": { "enabled": {
"uitype": "code", "uitype": "code",
"data": [ "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": { "editable": {
"new_data_url": "{{entire_url('../api/rule/voucher_rule_create.dspy')}}", "new_data_url": "{{entire_url('../api/rule/voucher_rule_create.dspy')}}",
"update_data_url": "{{entire_url('../api/rule/voucher_rule_update.dspy')}}", "update_data_url": "{{entire_url('../api/rule/voucher_rule_update.dspy')}}",

View File

@ -1,42 +1,105 @@
{ {
"tblname": "voucher_template", "tblname": "voucher_template",
"alias": "voucher_template_list", "alias": "voucher_template_list",
"title": "代金券模板管理", "title": "代金券模板",
"params": { "params": {
"sortby": ["created_at desc"], "sortby": [
"created_at desc"
],
"browserfields": { "browserfields": {
"exclouded": ["creator", "updater"], "exclouded": [
"id",
"org_id",
"created_by"
],
"alters": { "alters": {
"status": { "status": {
"uitype": "code", "uitype": "code",
"data": [ "data": [
{"value": "draft", "text": "草稿"}, {
{"value": "active", "text": "启用"}, "value": "draft",
{"value": "inactive", "text": "停用"} "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": { "data_filter": {
"AND": [ "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": { "editable": {
"new_data_url": "{{entire_url('../api/template/voucher_template_create.dspy')}}", "new_data_url": "{{entire_url('../api/template/voucher_template_create.dspy')}}",
"update_data_url": "{{entire_url('../api/template/voucher_template_update.dspy')}}", "update_data_url": "{{entire_url('../api/template/voucher_template_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/template/voucher_template_delete.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": [ "subtables": [
{ {
"field": "id", "field": "template_id",
"title": "规则配置", "title": "规则配置",
"url": "{{entire_url('../voucher_rule_list/')}}", "url": "{{entire_url('../voucher_rule_list')}}",
"subtable": "voucher_rule" "subtable": "voucher_rule"
} }
] ]

View File

@ -3,30 +3,40 @@
"alias": "voucher_usage_log_list", "alias": "voucher_usage_log_list",
"title": "代金券使用流水", "title": "代金券使用流水",
"params": { "params": {
"sortby": ["used_at desc"], "sortby": [
"used_at desc"
],
"noedit": true,
"browserfields": {
"exclouded": [
"id",
"template_id"
],
"alters": {}
},
"data_filter": { "data_filter": {
"AND": [ "AND": [
{"field": "instance_id", "op": "=", "var": "instance_id"}, {
{"field": "customer_id", "op": "=", "var": "customer_id"}, "field": "instance_id",
{"field": "order_id", "op": "=", "var": "order_id"} "op": "=",
"var": "instance_id"
},
{
"field": "customer_id",
"op": "=",
"var": "customer_id"
},
{
"field": "order_id",
"op": "LIKE",
"var": "order_id"
}
] ]
}, },
"browserfields": { "filter_labels": {
"exclouded": [], "instance_id": "券实例",
"alters": { "customer_id": "客户机构",
"instance_id": { "order_id": "订单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')}}"
} }
} }
} }

View File

@ -10,27 +10,27 @@
"fields": [ "fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "template_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": "customer_id", "title": "客户机构ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "code", "title": "唯一券码", "type": "str", "length": 64, "nullable": "no"}, {"name": "code", "title": "券码", "type": "str", "length": 64, "nullable": "no"},
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "unused"}, {"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "unused"},
{"name": "face_value", "title": "面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"}, {"name": "face_value", "title": "面值", "type": "double", "length": 18, "dec": 2, "nullable": "no"},
{"name": "actual_deducted", "title": "实际抵扣金额", "type": "double", "length": 10, "dec": 2, "nullable": "yes"}, {"name": "actual_deducted", "title": "实际抵扣金额", "type": "double", "length": 18, "dec": 2, "nullable": "yes"},
{"name": "valid_from", "title": "生效时间", "type": "datetime", "nullable": "no"}, {"name": "valid_from", "title": "生效时间", "type": "datetime", "nullable": "no"},
{"name": "valid_to", "title": "过期时间", "type": "datetime", "nullable": "no"}, {"name": "valid_to", "title": "过期时间", "type": "datetime", "nullable": "no"},
{"name": "issued_at", "title": "发放时间", "type": "datetime", "nullable": "no"}, {"name": "issued_at", "title": "发放时间", "type": "datetime", "nullable": "no"},
{"name": "used_at", "title": "使用时间", "type": "datetime", "nullable": "yes"}, {"name": "used_at", "title": "使用时间", "type": "datetime", "nullable": "yes"},
{"name": "order_id", "title": "使用的订单ID", "type": "str", "length": 64, "nullable": "yes"}, {"name": "order_id", "title": "使用订单ID", "type": "str", "length": 64, "nullable": "yes"},
{"name": "source", "title": "来源", "type": "str", "length": 32, "nullable": "yes"}, {"name": "source", "title": "发放来源", "type": "str", "length": 32, "nullable": "no", "default": "manual"},
{"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"}, {"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": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
], ],
"indexes": [ "indexes": [
{"name": "idx_instance_code", "idxtype": "unique", "idxfields": ["code"]}, {"name": "idx_vi_code", "idxtype": "unique", "idxfields": ["code"]},
{"name": "idx_instance_customer", "idxtype": "index", "idxfields": ["customer_id"]}, {"name": "idx_vi_customer", "idxtype": "index", "idxfields": ["customer_id", "status"]},
{"name": "idx_instance_template", "idxtype": "index", "idxfields": ["template_id"]}, {"name": "idx_vi_template", "idxtype": "index", "idxfields": ["template_id"]},
{"name": "idx_instance_status", "idxtype": "index", "idxfields": ["status"]}, {"name": "idx_vi_valid_to", "idxtype": "index", "idxfields": ["valid_to"]}
{"name": "idx_instance_valid_to", "idxtype": "index", "idxfields": ["valid_to"]}
], ],
"codes": [ "codes": [
{ {
@ -39,12 +39,25 @@
"valuefield": "id", "valuefield": "id",
"textfield": "name" "textfield": "name"
}, },
{
"field": "customer_id",
"table": "organization",
"valuefield": "id",
"textfield": "orgname"
},
{ {
"field": "status", "field": "status",
"table": "appcodes_kv", "table": "appcodes_kv",
"valuefield": "k", "valuefield": "k",
"textfield": "v", "textfield": "v",
"cond": "parentid='voucher_instance_status'" "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": [ "summary": [
{ {
"name": "voucher_rule", "name": "voucher_rule",
"title": "代金券规则定义", "title": "代金券规则",
"primary": ["id"], "primary": ["id"],
"catelog": "entity" "catelog": "entity"
} }
@ -10,17 +10,16 @@
"fields": [ "fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "template_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_type", "title": "规则类型", "type": "str", "length": 64, "nullable": "no"},
{"name": "rule_config", "title": "规则配置(JSON)", "type": "text", "nullable": "no"}, {"name": "rule_config", "title": "规则配置JSON", "type": "text", "nullable": "yes"},
{"name": "enabled", "title": "是否启用", "type": "short", "nullable": "no", "default": "1"}, {"name": "enabled", "title": "是否启用", "type": "char", "length": 1, "nullable": "no", "default": "1"},
{"name": "sort_order", "title": "执行顺序", "type": "int", "nullable": "no", "default": "0"}, {"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": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
], ],
"indexes": [ "indexes": [
{"name": "idx_rule_template", "idxtype": "index", "idxfields": ["template_id"]}, {"name": "idx_vr_template", "idxtype": "index", "idxfields": ["template_id"]}
{"name": "idx_rule_enabled", "idxtype": "index", "idxfields": ["enabled"]}
], ],
"codes": [ "codes": [
{ {
@ -34,14 +33,14 @@
"table": "appcodes_kv", "table": "appcodes_kv",
"valuefield": "k", "valuefield": "k",
"textfield": "v", "textfield": "v",
"cond": "parentid='voucher_rule_type'" "cond": "parentid='vchr_rule_type'"
}, },
{ {
"field": "enabled", "field": "enabled",
"table": "appcodes_kv", "table": "appcodes_kv",
"valuefield": "k", "valuefield": "k",
"textfield": "v", "textfield": "v",
"cond": "parentid='yes_no'" "cond": "parentid='vchr_enabled'"
} }
] ]
} }

View File

@ -9,22 +9,22 @@
], ],
"fields": [ "fields": [
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "name", "title": "模板名称", "type": "str", "length": 64, "nullable": "no"}, {"name": "name", "title": "模板名称", "type": "str", "length": 128, "nullable": "no"},
{"name": "code", "title": "模板编码", "type": "str", "length": 32, "nullable": "no"}, {"name": "code", "title": "模板编码", "type": "str", "length": 64, "nullable": "no"},
{"name": "face_value", "title": "面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"}, {"name": "face_value", "title": "面值", "type": "double", "length": 18, "dec": 2, "nullable": "no"},
{"name": "total_count", "title": "总发行量", "type": "int", "nullable": "no", "default": "0"}, {"name": "total_count", "title": "总发行量(0不限)", "type": "int", "nullable": "no", "default": "0"},
{"name": "issued_count", "title": "已发放量", "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": "valid_days", "title": "有效期天数", "type": "int", "nullable": "no", "default": "30"},
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "draft"}, {"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": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
{"name": "updated_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"}
], ],
"indexes": [ "indexes": [
{"name": "idx_template_code", "idxtype": "unique", "idxfields": ["code"]}, {"name": "idx_vt_code", "idxtype": "unique", "idxfields": ["code"]},
{"name": "idx_template_status", "idxtype": "index", "idxfields": ["status"]} {"name": "idx_vt_status", "idxtype": "index", "idxfields": ["status"]}
], ],
"codes": [ "codes": [
{ {

View File

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

View File

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

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 #!/usr/bin/env python3
"""voucher 模块 RBAC 权限注册""" """voucher 模块 RBAC 路径权限注册(pipeline-app 格式,仿 ticket/scripts/load_path.py)。
import os
从应用根执行: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 sys
import subprocess import os
import asyncio
def find_sage_root(): # 应用根:本脚本在 pkgs/voucher/scripts/ 下,上溯三级
for candidate in [ APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../.."))
os.path.expanduser("~/repos/sage"), sys.path.insert(0, os.path.join(APP_ROOT, "py3", "lib", "python3.10", "site-packages"))
os.path.expanduser("~/sage"), sys.path.insert(0, APP_ROOT)
]:
if os.path.isdir(os.path.join(candidate, "wwwroot")):
return candidate
return None
def set_perm(sage_root, role, path): from sqlor.dbpools import DBPools
script = os.path.join(sage_root, "set_role_perm.py") from appPublic.jsonConfig import getConfig
cmd = [os.path.join(sage_root, "py3/bin/python"), script, role, path] from appPublic.uniqueID import getID
print(f" {role} {path}")
subprocess.run(cmd, cwd=sage_root, capture_output=True)
def main(): MOD = "voucher"
sage_root = find_sage_root()
if not sage_root:
print("ERROR: Sage root not found")
sys.exit(1)
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 (公开) PATHS_LOGINED = [
any_paths = [ # 客户侧
"/voucher/menu.ui", 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: async def main():
set_perm(sage_root, "any", p) 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__": 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 ahserver.serverenv import ServerEnv
from appPublic.jsonConfig import getConfig
from appPublic.log import debug, exception, info
from appPublic.uniqueID import getID from appPublic.uniqueID import getID
from sqlor.dbpools import DBPools
MODULE_NAME = "voucher" from voucher.rules.engine import (
MODULE_VERSION = "1.0.0" apply_voucher,
DBNAME = "sage" 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(): 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 ========== def _get_db():
db = DBPools()
async def create_voucher_template(data): if not getattr(db, 'databases', None):
sor = DBPools().sqlorContext(_get_dbname()) config = getConfig()
data['id'] = getID() db.databases = config.databases
data['issued_count'] = 0 return db
if 'status' not in data:
data['status'] = 'draft'
sor.I('voucher_template', data)
return {'status': 'success', 'data': {'id': data['id']}}
async def update_voucher_template(conditions, data): # ========== 客户侧 API ==========
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}
async def get_available_vouchers_api(customer_id, context=None): async def get_available_vouchers_api(customer_id, context=None):
"""外部调用:查询可用代金券(含模板名称)""" """查询客户可用代金券(含模板名称;顺带把过期券置 expired)。"""
from voucher.rules.engine import get_available_vouchers db = _get_db()
sor = DBPools().sqlorContext(_get_dbname()) async with db.sqlorContext(_get_dbname()) as sor:
vouchers = get_available_vouchers(sor, customer_id, context) try:
# 预加载模板名称 await expire_vouchers(sor)
template_names = {} except Exception as e:
for v in vouchers: debug('get_available_vouchers_api: expire sweep skipped: {}'.format(e))
tid = v.template_id if hasattr(v, 'template_id') else v.get('template_id', '') vouchers = await get_available_vouchers(sor, customer_id, context)
if tid and tid not in template_names: template_names = {}
t = sor.R('voucher_template', {'id': tid}).first() for v in vouchers:
if t: tid = v.template_id or ''
template_names[tid] = t.name if hasattr(t, 'name') else t.get('name', '') if tid and tid not in template_names:
items = [] t = await sor.R('voucher_template', {'id': tid})
for v in vouchers: template_names[tid] = (t[0].name if t else '') or ''
if hasattr(v, '__dict__'): items = []
item = v.__dict__ for v in vouchers:
else:
item = dict(v) item = dict(v)
tid = item.get('template_id', '') item['template_name'] = template_names.get(v.template_id or '', '')
item['template_name'] = template_names.get(tid, '') items.append(_jsonable_instance(item))
items.append(item) return {'status': 'success', 'data': items, 'total': len(items)}
return {'status': 'success', 'data': items}
async def get_registered_rule_types(): async def apply_voucher_api(customer_id, order_id, voucher_ids, context):
"""返回已注册的规则类型列表""" """使用代金券(单张传一个 id 的列表即可)。"""
from voucher.rules.registry import get_all_rule_types 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()} 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:after(DBPools 是 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 ========== # ========== Module Load ==========
def load_voucher(): def load_voucher():
"""注册所有函数到 ServerEnv""" """注册所有函数到 ServerEnv + 绑定注册送券事件。"""
env = 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.create_voucher_template = create_voucher_template
env.update_voucher_template = update_voucher_template env.update_voucher_template = update_voucher_template
env.delete_voucher_template = delete_voucher_template env.delete_voucher_template = delete_voucher_template
# Rule CRUD
env.create_voucher_rule = create_voucher_rule env.create_voucher_rule = create_voucher_rule
env.update_voucher_rule = update_voucher_rule env.update_voucher_rule = update_voucher_rule
env.delete_voucher_rule = delete_voucher_rule env.delete_voucher_rule = delete_voucher_rule
env.issue_voucher_api = issue_voucher_api
# Instance CRUD env.invalidate_voucher_api = invalidate_voucher_api
env.create_voucher_instance = create_voucher_instance
env.update_voucher_instance = update_voucher_instance env.update_voucher_instance = update_voucher_instance
env.delete_voucher_instance = delete_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_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 # 引擎级(供其他模块 import 后直接调,如 product_management 结算联动)
env.apply_voucher_api = apply_voucher_api env.voucher_issue = issue_voucher
env.get_available_vouchers_api = get_available_vouchers_api env.voucher_apply = apply_voucher
env.get_registered_rule_types = get_registered_rule_types env.voucher_batch_apply = batch_apply_vouchers
env.voucher_get_available = get_available_vouchers
# Import validators to trigger @register_rule decorators
import voucher.rules.validators # noqa: F401
_bind_org_event()
info('voucher module loaded (v{})'.format(MODULE_VERSION))
return True 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
- 并发安全:核销用条件 UPDATE(status='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 # apply_voucher.dspy — 核销代金券(一次性,不找零;并发安全条件 UPDATE)
customer_id = (params_kw.get('customer_id') or '').strip()
customer_id = params_kw.get('customer_id', '') if not customer_id:
order_id = params_kw.get('order_id', '') customer_id = await get_userorgid() or ''
voucher_ids = params_kw.get('voucher_ids', []) order_id = (params_kw.get('order_id') or '').strip()
voucher_ids = params_kw.get('voucher_ids') or []
if isinstance(voucher_ids, str): if isinstance(voucher_ids, str):
voucher_ids = json.loads(voucher_ids) try:
voucher_ids = json.loads(voucher_ids)
context = { except (ValueError, TypeError):
'request_amount': params_kw.get('request_amount', 0), voucher_ids = [s.strip() for s in voucher_ids.split(',') if s.strip()]
'product_type': params_kw.get('product_type', ''), context = {}
'product_name': params_kw.get('product_name', ''), for k in ('product_type', 'product_name'):
'user_level': params_kw.get('user_level', 0), if params_kw.get(k):
} context[k] = params_kw.get(k)
try:
result = await apply_voucher_api(customer_id, order_id, voucher_ids, context) context['request_amount'] = float(params_kw.get('request_amount') or 0)
return json.dumps(result) 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 # get_available.dspy — 内部查询可用券(带规则预校验过滤;供结算方/调试)
customer_id = (params_kw.get('customer_id') or '').strip()
customer_id = params_kw.get('customer_id', '') if not customer_id:
customer_id = await get_userorgid() or ''
context = {} context = {}
if params_kw.get('product_type'): for k in ('product_type', 'product_name'):
context['product_type'] = params_kw.get('product_type') if params_kw.get(k):
if params_kw.get('product_name'): context[k] = params_kw.get(k)
context['product_name'] = params_kw.get('product_name')
if params_kw.get('request_amount'): if params_kw.get('request_amount'):
context['request_amount'] = params_kw.get('request_amount') try:
context['request_amount'] = float(params_kw.get('request_amount'))
result = await get_available_vouchers_api(customer_id, context if context else None) except (ValueError, TypeError):
return json.dumps(result) 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 @@
# voucher_instance_delete.dspy — 实例删除(已使用禁删保审计;未使用删除回吐发行额度)
conditions = {'id': params_kw.get('id', '')} r = await delete_voucher_instance(request, params_kw)
result = await delete_voucher_instance(conditions) if r.get('success'):
return result 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 @@
# voucher_instance_update.dspy — 实例修正(仅未使用的券可改客户/备注;状态变更走发券/核销/作废 API)
conditions = {'id': params_kw.get('id', '')} r = await update_voucher_instance(request, params_kw)
data = {} if r.get('success'):
for key in ['template_id', 'customer_id', 'code', 'status', 'source', 'remark']: return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}
if val is not None:
data[key] = val
result = await update_voucher_instance(conditions, data)
return result

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
# voucher_template_delete.dspy — 模板删除(已发放券的模板拒绝,引导用「停用」)
conditions = {'id': params_kw.get('id', '')} r = await delete_voucher_template(request, params_kw)
result = await delete_voucher_template(conditions) if r.get('success'):
return result 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 # voucher_template_options.dspy — 启用模板下拉(code 组件契约:纯 [{value,text}] 数组)
try:
result = await get_voucher_template_options() return await get_voucher_template_options(request, params_kw)
return json.dumps(result) except Exception as e:
debug('voucher_template_options error: ' + str(e))
return []

View File

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

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

View File

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