feat: 代金券模块初始实现
- 4张表: voucher_template, voucher_rule, voucher_instance, voucher_usage_log - 可配置规则引擎: registry + validators + engine - 8种内置规则: min_amount, max_amount, applicable_product_type, applicable_product, exclude_product, max_usage_count, valid_period, user_level - CRUD定义 + API接口 + 前端页面 - SQL建表脚本 + RBAC权限配置 - 一次性使用,不找零
This commit is contained in:
parent
424fb91d0e
commit
2c56aa904a
233
README.md
233
README.md
@ -1,2 +1,233 @@
|
|||||||
# voucher
|
# voucher — 代金券模块
|
||||||
|
|
||||||
|
独立代金券管理模块,支持**可配置规则引擎**,一次性使用,不找零。
|
||||||
|
|
||||||
|
## 核心架构
|
||||||
|
|
||||||
|
```
|
||||||
|
模板(类别) → 定义规则集
|
||||||
|
实例(代金券)→ 属于某个模板,继承规则
|
||||||
|
使用流水 → 记录每次消费抵扣
|
||||||
|
```
|
||||||
|
|
||||||
|
### 表结构
|
||||||
|
| 表 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| voucher_template | 代金券模板(面值、有效期、发行量、状态) |
|
||||||
|
| voucher_rule | 规则定义(模板关联,可增删启用禁用) |
|
||||||
|
| voucher_instance | 券实例(一次性使用,状态:unused/used/expired) |
|
||||||
|
| voucher_usage_log | 使用流水(订单、抵扣金额、产品信息) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 可配置规则引擎
|
||||||
|
|
||||||
|
规则通过 `@register_rule` 装饰器注册到 `RULE_REGISTRY`,新增规则只需写 validator 函数,无需修改引擎代码。
|
||||||
|
|
||||||
|
### 内置规则类型
|
||||||
|
|
||||||
|
| rule_type | 说明 | rule_config 示例 |
|
||||||
|
|-----------|------|------------------|
|
||||||
|
| `min_amount` | 最低消费门槛 | `{"min_value": 100}` |
|
||||||
|
| `max_amount` | 最高消费限制 | `{"max_value": 1000}` |
|
||||||
|
| `applicable_product_type` | 限定产品类型(llm/image/video/audio) | `{"product_types": ["llm", "image"]}` |
|
||||||
|
| `applicable_product` | 限定特定产品(具体模型名) | `{"products": ["gpt-4", "claude-3"]}` |
|
||||||
|
| `exclude_product` | 排除特定产品 | `{"products": ["gpt-4"]}` |
|
||||||
|
| `max_usage_count` | 最大使用次数 | `{"max_count": 1}` |
|
||||||
|
| `valid_period` | 有效期检查 | `{}` (由实例 valid_from/valid_to 处理) |
|
||||||
|
| `user_level` | 用户等级限制 | `{"min_level": 2}` |
|
||||||
|
|
||||||
|
### 规则执行流程
|
||||||
|
1. 查询券实例状态(unused + 未过期)
|
||||||
|
2. 加载模板关联的所有启用规则(按 sort_order 排序)
|
||||||
|
3. 依次执行 validator,任一失败即拒绝
|
||||||
|
4. 全部通过 → 计算抵扣金额 = min(面值, 消费金额)
|
||||||
|
5. 记录流水 + 标记券为已使用(一次性作废)
|
||||||
|
|
||||||
|
### 新增规则步骤
|
||||||
|
```python
|
||||||
|
# rules/validators.py
|
||||||
|
@register_rule('new_rule_type')
|
||||||
|
def check_new_rule(config, context):
|
||||||
|
# config: 从 rule_config JSON 解析
|
||||||
|
# context: 包含 request_amount, product_type, product_name, user_level 等
|
||||||
|
if not some_condition:
|
||||||
|
return False, "不满足条件"
|
||||||
|
return True, None
|
||||||
|
```
|
||||||
|
然后在模板管理界面添加 `rule_type: "new_rule_type"` 的规则记录即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 与其他模块交互
|
||||||
|
|
||||||
|
### llmage 模块(消费时使用代金券)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# llmage 的计费逻辑中调用 voucher 引擎
|
||||||
|
from voucher.rules.engine import apply_voucher, get_available_vouchers
|
||||||
|
|
||||||
|
# 查询客户可用代金券
|
||||||
|
vouchers = get_available_vouchers(sor, customer_id, context={
|
||||||
|
'product_type': 'llm', # 从 llm.catelog 获取
|
||||||
|
'product_name': 'gpt-4', # 具体模型名
|
||||||
|
'request_amount': amount,
|
||||||
|
'user_level': customer.level
|
||||||
|
})
|
||||||
|
|
||||||
|
# 使用代金券抵扣
|
||||||
|
ok, deducted, err = apply_voucher(sor, instance_id, customer_id, order_id, context={
|
||||||
|
'request_amount': amount,
|
||||||
|
'product_type': 'llm',
|
||||||
|
'product_name': 'gpt-4'
|
||||||
|
})
|
||||||
|
|
||||||
|
# 批量使用多张券
|
||||||
|
from voucher.rules.engine import batch_apply_vouchers
|
||||||
|
result = batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context)
|
||||||
|
# result: {total_deducted: 150.0, remaining: 50.0, details: [...]}
|
||||||
|
# remaining > 0 时从余额扣减
|
||||||
|
```
|
||||||
|
|
||||||
|
### accounting 模块(余额扣减联动)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# accounting 消费流程中先尝试代金券,剩余从余额扣
|
||||||
|
total_amount = calculate_consumption(customer_id, period)
|
||||||
|
|
||||||
|
# Step 1: 尝试代金券抵扣
|
||||||
|
result = batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context)
|
||||||
|
|
||||||
|
# Step 2: 剩余金额从余额扣减
|
||||||
|
if result['remaining'] > 0:
|
||||||
|
accounting.deduct_balance(customer_id, result['remaining'], order_id)
|
||||||
|
|
||||||
|
# Step 3: 记录完整账单
|
||||||
|
accounting.record_order(order_id, total_amount,
|
||||||
|
voucher_deducted=result['total_deducted'],
|
||||||
|
balance_deducted=result['remaining'])
|
||||||
|
```
|
||||||
|
|
||||||
|
### dapi 模块(客户自助查询)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 客户通过 API 查询自己的可用代金券
|
||||||
|
# dapi 端点调用 voucher 引擎
|
||||||
|
available = get_available_vouchers(sor, customer_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 集成点总结
|
||||||
|
|
||||||
|
| 调用方 | 场景 | 调用函数 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| llmage | 推理/生成时抵扣 | `apply_voucher()` |
|
||||||
|
| accounting | 月度账单结算 | `batch_apply_vouchers()` |
|
||||||
|
| dapi | 客户查询可用券 | `get_available_vouchers()` |
|
||||||
|
| 任意模块 | 新增规则类型 | `@register_rule` + 模板管理界面 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
voucher/
|
||||||
|
├── voucher/ # Python 包
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── init.py # 模块初始化 + ServerEnv 注册
|
||||||
|
├── rules/ # 规则引擎(纯 Python)
|
||||||
|
│ ├── registry.py # @register_rule 装饰器
|
||||||
|
│ ├── validators.py # 内置规则校验器
|
||||||
|
│ └── engine.py # 校验/使用/批量使用
|
||||||
|
├── wwwroot/ # 前端
|
||||||
|
│ ├── index.ui # 入口页(卡片导航)
|
||||||
|
│ ├── menu.json # 菜单定义
|
||||||
|
│ └── api/ # API 端点
|
||||||
|
│ ├── template/ # 模板 CRUD
|
||||||
|
│ ├── rule/ # 规则 CRUD
|
||||||
|
│ ├── instance/ # 实例 CRUD
|
||||||
|
│ ├── usage/ # 流水 CRUD
|
||||||
|
│ ├── apply_voucher.dspy # 使用代金券
|
||||||
|
│ ├── get_available.dspy # 查询可用券
|
||||||
|
│ └── rule_types.dspy # 获取规则类型列表
|
||||||
|
├── models/ # 表定义 JSON
|
||||||
|
├── json/ # CRUD 定义 JSON
|
||||||
|
├── sql/
|
||||||
|
│ └── tables.sql # 建表 + 编码初始化
|
||||||
|
├── scripts/
|
||||||
|
│ └── load_path.py # RBAC 权限注册
|
||||||
|
├── pyproject.toml
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署步骤
|
||||||
|
|
||||||
|
### 1. 代码部署
|
||||||
|
```bash
|
||||||
|
cd ~/repos/voucher && git pull
|
||||||
|
cd ~/repos/sage/pkgs && ln -sf ~/repos/voucher voucher
|
||||||
|
cd voucher && ~/repos/sage/py3/bin/pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 数据库建表
|
||||||
|
```bash
|
||||||
|
mysql -u root -p sage < ~/repos/voucher/sql/tables.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Sage 集成
|
||||||
|
```python
|
||||||
|
# sage/app/sage.py
|
||||||
|
from voucher.init import load_voucher
|
||||||
|
# ... in init():
|
||||||
|
load_voucher()
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# sage/build.sh 安装循环
|
||||||
|
for m in ... voucher
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 菜单入口
|
||||||
|
```json
|
||||||
|
// sage/wwwroot/global_menu.ui items 数组
|
||||||
|
,{
|
||||||
|
"name": "voucher",
|
||||||
|
"label": "代金券",
|
||||||
|
"icon": "fa fa-ticket",
|
||||||
|
"url": "{{entire_url('/voucher/index.ui')}}",
|
||||||
|
"target": "app.sage_main_content"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. RBAC 权限
|
||||||
|
```bash
|
||||||
|
cd ~/repos/sage && ./py3/bin/python ~/repos/voucher/scripts/load_path.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 重启
|
||||||
|
```bash
|
||||||
|
cd ~/repos/sage && ./stop.sh && ./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 创建模板 + 配置规则
|
||||||
|
1. 模板管理 → 新增 → 名称"新用户满减券",面值 50,有效期 30 天
|
||||||
|
2. 点击模板 → 规则配置 → 添加规则:
|
||||||
|
- 规则类型: `min_amount`,配置: `{"min_value": 100}`
|
||||||
|
- 规则类型: `applicable_product_type`,配置: `{"product_types": ["llm"]}`
|
||||||
|
|
||||||
|
### 发放代金券
|
||||||
|
1. 券实例管理 → 新增 → 选择模板,填写客户 ID
|
||||||
|
2. 系统自动生成券码,设置有效期
|
||||||
|
|
||||||
|
### 消费时抵扣
|
||||||
|
```python
|
||||||
|
# llmage 推理完成后
|
||||||
|
context = {'request_amount': 120.0, 'product_type': 'llm', 'product_name': 'gpt-4'}
|
||||||
|
result = batch_apply_vouchers(sor, customer_id, order_id, [vid1, vid2], context)
|
||||||
|
# → 抵扣 50(满100可用),剩余 70 从余额扣
|
||||||
|
```
|
||||||
|
|||||||
12
conf/config.json
Normal file
12
conf/config.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"module": "voucher",
|
||||||
|
"databases": {
|
||||||
|
"sage": {
|
||||||
|
"host": "${DB_HOST}",
|
||||||
|
"port": "${DB_PORT}",
|
||||||
|
"user": "${DB_USER}",
|
||||||
|
"passwd": "${DB_PASSWD}",
|
||||||
|
"db": "sage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
json/voucher_instance_list.json
Normal file
49
json/voucher_instance_list.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"tblname": "voucher_instance",
|
||||||
|
"alias": "voucher_instance_list",
|
||||||
|
"title": "代金券实例管理",
|
||||||
|
"params": {
|
||||||
|
"sortby": ["created_at desc"],
|
||||||
|
"data_filter": {
|
||||||
|
"AND": [
|
||||||
|
{"field": "customer_id", "op": "=", "var": "customer_id"},
|
||||||
|
{"field": "status", "op": "=", "var": "status"},
|
||||||
|
{"field": "template_id", "op": "=", "var": "template_id"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserfields": {
|
||||||
|
"exclouded": [],
|
||||||
|
"alters": {
|
||||||
|
"template_id": {
|
||||||
|
"uitype": "code",
|
||||||
|
"dataurl": "{{entire_url('../api/template/voucher_template_options.dspy')}}",
|
||||||
|
"data_field": "options",
|
||||||
|
"textField": "text",
|
||||||
|
"valueField": "value"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"uitype": "code",
|
||||||
|
"data": [
|
||||||
|
{"value": "unused", "text": "未使用"},
|
||||||
|
{"value": "used", "text": "已使用"},
|
||||||
|
{"value": "expired", "text": "已过期"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"editexclouded": ["used_at", "order_id", "actual_deducted"],
|
||||||
|
"editable": {
|
||||||
|
"new_data_url": "{{entire_url('../api/instance/voucher_instance_create.dspy')}}",
|
||||||
|
"update_data_url": "{{entire_url('../api/instance/voucher_instance_update.dspy')}}",
|
||||||
|
"delete_data_url": "{{entire_url('../api/instance/voucher_instance_delete.dspy')}}"
|
||||||
|
},
|
||||||
|
"subtables": [
|
||||||
|
{
|
||||||
|
"field": "id",
|
||||||
|
"title": "使用记录",
|
||||||
|
"url": "{{entire_url('../voucher_usage_log_list/')}}",
|
||||||
|
"subtable": "voucher_usage_log"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
51
json/voucher_rule_list.json
Normal file
51
json/voucher_rule_list.json
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"tblname": "voucher_rule",
|
||||||
|
"alias": "voucher_rule_list",
|
||||||
|
"title": "代金券规则管理",
|
||||||
|
"params": {
|
||||||
|
"sortby": ["sort_order"],
|
||||||
|
"data_filter": {
|
||||||
|
"AND": [
|
||||||
|
{"field": "template_id", "op": "=", "var": "template_id"},
|
||||||
|
{"field": "rule_type", "op": "=", "var": "rule_type"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserfields": {
|
||||||
|
"exclouded": [],
|
||||||
|
"alters": {
|
||||||
|
"template_id": {
|
||||||
|
"uitype": "code",
|
||||||
|
"dataurl": "{{entire_url('../api/template/voucher_template_options.dspy')}}",
|
||||||
|
"data_field": "options",
|
||||||
|
"textField": "text",
|
||||||
|
"valueField": "value"
|
||||||
|
},
|
||||||
|
"rule_type": {
|
||||||
|
"uitype": "code",
|
||||||
|
"data": [
|
||||||
|
{"value": "min_amount", "text": "最低消费"},
|
||||||
|
{"value": "max_amount", "text": "最高消费"},
|
||||||
|
{"value": "applicable_product_type", "text": "限定产品类型"},
|
||||||
|
{"value": "applicable_product", "text": "限定特定产品"},
|
||||||
|
{"value": "exclude_product", "text": "排除特定产品"},
|
||||||
|
{"value": "max_usage_count", "text": "最大使用次数"},
|
||||||
|
{"value": "valid_period", "text": "有效期"},
|
||||||
|
{"value": "user_level", "text": "用户等级"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"uitype": "code",
|
||||||
|
"data": [
|
||||||
|
{"value": "1", "text": "启用"},
|
||||||
|
{"value": "0", "text": "停用"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"editable": {
|
||||||
|
"new_data_url": "{{entire_url('../api/rule/voucher_rule_create.dspy')}}",
|
||||||
|
"update_data_url": "{{entire_url('../api/rule/voucher_rule_update.dspy')}}",
|
||||||
|
"delete_data_url": "{{entire_url('../api/rule/voucher_rule_delete.dspy')}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
json/voucher_template_list.json
Normal file
44
json/voucher_template_list.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"tblname": "voucher_template",
|
||||||
|
"alias": "voucher_template_list",
|
||||||
|
"title": "代金券模板管理",
|
||||||
|
"params": {
|
||||||
|
"sortby": ["created_at desc"],
|
||||||
|
"browserfields": {
|
||||||
|
"exclouded": ["creator", "updater"],
|
||||||
|
"alters": {
|
||||||
|
"status": {
|
||||||
|
"uitype": "code",
|
||||||
|
"data": [
|
||||||
|
{"value": "draft", "text": "草稿"},
|
||||||
|
{"value": "active", "text": "启用"},
|
||||||
|
{"value": "inactive", "text": "停用"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"face_value": {
|
||||||
|
"uitype": "code",
|
||||||
|
"data": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data_filter": {
|
||||||
|
"AND": [
|
||||||
|
{"field": "name", "op": "LIKE", "var": "name"},
|
||||||
|
{"field": "status", "op": "=", "var": "status"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"editable": {
|
||||||
|
"new_data_url": "{{entire_url('../api/template/voucher_template_create.dspy')}}",
|
||||||
|
"update_data_url": "{{entire_url('../api/template/voucher_template_update.dspy')}}",
|
||||||
|
"delete_data_url": "{{entire_url('../api/template/voucher_template_delete.dspy')}}"
|
||||||
|
},
|
||||||
|
"subtables": [
|
||||||
|
{
|
||||||
|
"field": "id",
|
||||||
|
"title": "规则配置",
|
||||||
|
"url": "{{entire_url('../voucher_rule_list/')}}",
|
||||||
|
"subtable": "voucher_rule"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
32
json/voucher_usage_log_list.json
Normal file
32
json/voucher_usage_log_list.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"tblname": "voucher_usage_log",
|
||||||
|
"alias": "voucher_usage_log_list",
|
||||||
|
"title": "代金券使用流水",
|
||||||
|
"params": {
|
||||||
|
"sortby": ["used_at desc"],
|
||||||
|
"data_filter": {
|
||||||
|
"AND": [
|
||||||
|
{"field": "instance_id", "op": "=", "var": "instance_id"},
|
||||||
|
{"field": "customer_id", "op": "=", "var": "customer_id"},
|
||||||
|
{"field": "order_id", "op": "=", "var": "order_id"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserfields": {
|
||||||
|
"exclouded": [],
|
||||||
|
"alters": {
|
||||||
|
"instance_id": {
|
||||||
|
"uitype": "code",
|
||||||
|
"dataurl": "{{entire_url('../api/instance/voucher_instance_options.dspy')}}",
|
||||||
|
"data_field": "options",
|
||||||
|
"textField": "text",
|
||||||
|
"valueField": "value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"editable": {
|
||||||
|
"new_data_url": "{{entire_url('../api/usage/voucher_usage_log_create.dspy')}}",
|
||||||
|
"update_data_url": "{{entire_url('../api/usage/voucher_usage_log_update.dspy')}}",
|
||||||
|
"delete_data_url": "{{entire_url('../api/usage/voucher_usage_log_delete.dspy')}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
models/voucher_instance.json
Normal file
50
models/voucher_instance.json
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"summary": [
|
||||||
|
{
|
||||||
|
"name": "voucher_instance",
|
||||||
|
"title": "代金券实例",
|
||||||
|
"primary": ["id"],
|
||||||
|
"catelog": "entity"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "template_id", "title": "模板ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "customer_id", "title": "客户ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "code", "title": "唯一券码", "type": "str", "length": 64, "nullable": "no"},
|
||||||
|
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "unused"},
|
||||||
|
{"name": "face_value", "title": "面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"},
|
||||||
|
{"name": "actual_deducted", "title": "实际抵扣金额", "type": "double", "length": 10, "dec": 2, "nullable": "yes"},
|
||||||
|
{"name": "valid_from", "title": "生效时间", "type": "datetime", "nullable": "no"},
|
||||||
|
{"name": "valid_to", "title": "过期时间", "type": "datetime", "nullable": "no"},
|
||||||
|
{"name": "issued_at", "title": "发放时间", "type": "datetime", "nullable": "no"},
|
||||||
|
{"name": "used_at", "title": "使用时间", "type": "datetime", "nullable": "yes"},
|
||||||
|
{"name": "order_id", "title": "使用的订单ID", "type": "str", "length": 64, "nullable": "yes"},
|
||||||
|
{"name": "source", "title": "来源", "type": "str", "length": 32, "nullable": "yes"},
|
||||||
|
{"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"},
|
||||||
|
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
|
||||||
|
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
{"name": "idx_instance_code", "idxtype": "unique", "idxfields": ["code"]},
|
||||||
|
{"name": "idx_instance_customer", "idxtype": "index", "idxfields": ["customer_id"]},
|
||||||
|
{"name": "idx_instance_template", "idxtype": "index", "idxfields": ["template_id"]},
|
||||||
|
{"name": "idx_instance_status", "idxtype": "index", "idxfields": ["status"]},
|
||||||
|
{"name": "idx_instance_valid_to", "idxtype": "index", "idxfields": ["valid_to"]}
|
||||||
|
],
|
||||||
|
"codes": [
|
||||||
|
{
|
||||||
|
"field": "template_id",
|
||||||
|
"table": "voucher_template",
|
||||||
|
"valuefield": "id",
|
||||||
|
"textfield": "name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "status",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='voucher_instance_status'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
47
models/voucher_rule.json
Normal file
47
models/voucher_rule.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"summary": [
|
||||||
|
{
|
||||||
|
"name": "voucher_rule",
|
||||||
|
"title": "代金券规则定义",
|
||||||
|
"primary": ["id"],
|
||||||
|
"catelog": "entity"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "template_id", "title": "模板ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "rule_type", "title": "规则类型", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "rule_config", "title": "规则配置(JSON)", "type": "text", "nullable": "no"},
|
||||||
|
{"name": "enabled", "title": "是否启用", "type": "short", "nullable": "no", "default": "1"},
|
||||||
|
{"name": "sort_order", "title": "执行顺序", "type": "int", "nullable": "no", "default": "0"},
|
||||||
|
{"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"},
|
||||||
|
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
|
||||||
|
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
{"name": "idx_rule_template", "idxtype": "index", "idxfields": ["template_id"]},
|
||||||
|
{"name": "idx_rule_enabled", "idxtype": "index", "idxfields": ["enabled"]}
|
||||||
|
],
|
||||||
|
"codes": [
|
||||||
|
{
|
||||||
|
"field": "template_id",
|
||||||
|
"table": "voucher_template",
|
||||||
|
"valuefield": "id",
|
||||||
|
"textfield": "name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "rule_type",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='voucher_rule_type'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "enabled",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='yes_no'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
38
models/voucher_template.json
Normal file
38
models/voucher_template.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"summary": [
|
||||||
|
{
|
||||||
|
"name": "voucher_template",
|
||||||
|
"title": "代金券模板",
|
||||||
|
"primary": ["id"],
|
||||||
|
"catelog": "entity"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "name", "title": "模板名称", "type": "str", "length": 64, "nullable": "no"},
|
||||||
|
{"name": "code", "title": "模板编码", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "face_value", "title": "面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"},
|
||||||
|
{"name": "total_count", "title": "总发行量", "type": "int", "nullable": "no", "default": "0"},
|
||||||
|
{"name": "issued_count", "title": "已发放量", "type": "int", "nullable": "no", "default": "0"},
|
||||||
|
{"name": "valid_days", "title": "有效期天数", "type": "int", "nullable": "no", "default": "30"},
|
||||||
|
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "draft"},
|
||||||
|
{"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"},
|
||||||
|
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
|
||||||
|
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"},
|
||||||
|
{"name": "creator", "title": "创建人", "type": "str", "length": 32, "nullable": "yes"},
|
||||||
|
{"name": "updater", "title": "更新人", "type": "str", "length": 32, "nullable": "yes"}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
{"name": "idx_template_code", "idxtype": "unique", "idxfields": ["code"]},
|
||||||
|
{"name": "idx_template_status", "idxtype": "index", "idxfields": ["status"]}
|
||||||
|
],
|
||||||
|
"codes": [
|
||||||
|
{
|
||||||
|
"field": "status",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='voucher_template_status'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
35
models/voucher_usage_log.json
Normal file
35
models/voucher_usage_log.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"summary": [
|
||||||
|
{
|
||||||
|
"name": "voucher_usage_log",
|
||||||
|
"title": "代金券使用流水",
|
||||||
|
"primary": ["id"],
|
||||||
|
"catelog": "entity"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "instance_id", "title": "券实例ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "customer_id", "title": "客户ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "order_id", "title": "订单/消费ID", "type": "str", "length": 64, "nullable": "no"},
|
||||||
|
{"name": "face_value", "title": "券面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"},
|
||||||
|
{"name": "deducted_amount", "title": "实际抵扣金额", "type": "double", "length": 10, "dec": 2, "nullable": "no"},
|
||||||
|
{"name": "used_at", "title": "使用时间", "type": "datetime", "nullable": "no"},
|
||||||
|
{"name": "used_by", "title": "操作人", "type": "str", "length": 32, "nullable": "yes"},
|
||||||
|
{"name": "product_type", "title": "产品类型", "type": "str", "length": 32, "nullable": "yes"},
|
||||||
|
{"name": "product_name", "title": "产品名称", "type": "str", "length": 64, "nullable": "yes"}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
{"name": "idx_log_instance", "idxtype": "index", "idxfields": ["instance_id"]},
|
||||||
|
{"name": "idx_log_customer", "idxtype": "index", "idxfields": ["customer_id"]},
|
||||||
|
{"name": "idx_log_order", "idxtype": "index", "idxfields": ["order_id"]}
|
||||||
|
],
|
||||||
|
"codes": [
|
||||||
|
{
|
||||||
|
"field": "instance_id",
|
||||||
|
"table": "voucher_instance",
|
||||||
|
"valuefield": "id",
|
||||||
|
"textfield": "code"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
17
pyproject.toml
Normal file
17
pyproject.toml
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=45", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "voucher"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "代金券模块:模板管理、可配置规则引擎、一次性使用代金券"
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
dependencies = [
|
||||||
|
"sqlor",
|
||||||
|
"bricks_for_python",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["voucher*"]
|
||||||
1
rules/__init__.py
Normal file
1
rules/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# voucher rules engine
|
||||||
198
rules/engine.py
Normal file
198
rules/engine.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
"""代金券规则引擎:动态加载规则、校验、使用"""
|
||||||
|
|
||||||
|
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
|
||||||
21
rules/registry.py
Normal file
21
rules/registry.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"""规则注册机制:新增规则只需写一个 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)
|
||||||
100
rules/validators.py
Normal file
100
rules/validators.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"""内置规则校验器,每种规则一个函数,返回 (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
|
||||||
75
scripts/load_path.py
Normal file
75
scripts/load_path.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""voucher 模块 RBAC 权限注册"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def find_sage_root():
|
||||||
|
for candidate in [
|
||||||
|
os.path.expanduser("~/repos/sage"),
|
||||||
|
os.path.expanduser("~/sage"),
|
||||||
|
]:
|
||||||
|
if os.path.isdir(os.path.join(candidate, "wwwroot")):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_perm(sage_root, role, path):
|
||||||
|
script = os.path.join(sage_root, "set_role_perm.py")
|
||||||
|
cmd = [os.path.join(sage_root, "py3/bin/python"), script, role, path]
|
||||||
|
print(f" {role} {path}")
|
||||||
|
subprocess.run(cmd, cwd=sage_root, capture_output=True)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
sage_root = find_sage_root()
|
||||||
|
if not sage_root:
|
||||||
|
print("ERROR: Sage root not found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Registering voucher RBAC permissions...")
|
||||||
|
|
||||||
|
# any (公开)
|
||||||
|
any_paths = [
|
||||||
|
"/voucher/menu.ui",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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/apply_voucher.dspy",
|
||||||
|
"/voucher/api/get_available.dspy",
|
||||||
|
"/voucher/api/rule_types.dspy",
|
||||||
|
]
|
||||||
|
|
||||||
|
for p in any_paths:
|
||||||
|
set_perm(sage_root, "any", p)
|
||||||
|
|
||||||
|
for p in logined_paths:
|
||||||
|
set_perm(sage_root, "logined", p)
|
||||||
|
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
107
sql/tables.sql
Normal file
107
sql/tables.sql
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- 代金券模块建表脚本
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 代金券模板表
|
||||||
|
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);
|
||||||
1
voucher/__init__.py
Normal file
1
voucher/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# voucher module
|
||||||
231
voucher/init.py
Normal file
231
voucher/init.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
"""voucher 模块初始化"""
|
||||||
|
|
||||||
|
from ahserver.serverenv import ServerEnv
|
||||||
|
from appPublic.uniqueID import getID
|
||||||
|
|
||||||
|
MODULE_NAME = "voucher"
|
||||||
|
MODULE_VERSION = "1.0.0"
|
||||||
|
DBNAME = "sage"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dbname():
|
||||||
|
return DBNAME
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Template CRUD ==========
|
||||||
|
|
||||||
|
async def create_voucher_template(data):
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
data['id'] = getID()
|
||||||
|
data['issued_count'] = 0
|
||||||
|
if 'status' not in data:
|
||||||
|
data['status'] = 'draft'
|
||||||
|
sor.I('voucher_template', data)
|
||||||
|
return {'status': 'success', 'data': {'id': data['id']}}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_voucher_template(conditions, data):
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
sor.U('voucher_template', conditions, data)
|
||||||
|
return {'status': 'success'}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_voucher_template(conditions):
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
# 级联删除关联规则
|
||||||
|
sor.D('voucher_rule', {'template_id': conditions.get('id', '')})
|
||||||
|
sor.D('voucher_template', conditions)
|
||||||
|
return {'status': 'success'}
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Rule CRUD ==========
|
||||||
|
|
||||||
|
async def create_voucher_rule(data):
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
data['id'] = getID()
|
||||||
|
if 'enabled' not in data:
|
||||||
|
data['enabled'] = 1
|
||||||
|
if 'sort_order' not in data:
|
||||||
|
data['sort_order'] = 0
|
||||||
|
sor.I('voucher_rule', data)
|
||||||
|
return {'status': 'success', 'data': {'id': data['id']}}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_voucher_rule(conditions, data):
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
sor.U('voucher_rule', conditions, data)
|
||||||
|
return {'status': 'success'}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_voucher_rule(conditions):
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
sor.D('voucher_rule', conditions)
|
||||||
|
return {'status': 'success'}
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Instance CRUD ==========
|
||||||
|
|
||||||
|
async def create_voucher_instance(data):
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
|
||||||
|
data['id'] = getID()
|
||||||
|
data['status'] = 'unused'
|
||||||
|
|
||||||
|
# 从模板获取面值
|
||||||
|
template = sor.R('voucher_template', {'id': data.get('template_id', '')}).first()
|
||||||
|
if template:
|
||||||
|
data['face_value'] = template.face_value if hasattr(template, 'face_value') else template['face_value']
|
||||||
|
valid_days = template.valid_days if hasattr(template, 'valid_days') else template.get('valid_days', 30)
|
||||||
|
else:
|
||||||
|
valid_days = data.get('valid_days', 30)
|
||||||
|
|
||||||
|
# 设置有效期
|
||||||
|
now = datetime.now()
|
||||||
|
data['valid_from'] = data.get('valid_from', now.strftime('%Y-%m-%d %H:%M:%S'))
|
||||||
|
data['valid_to'] = data.get('valid_to', (now + timedelta(days=valid_days)).strftime('%Y-%m-%d %H:%M:%S'))
|
||||||
|
data['issued_at'] = now.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
# 生成唯一券码
|
||||||
|
if 'code' not in data or not data['code']:
|
||||||
|
import hashlib
|
||||||
|
raw = f"{data['id']}-{data.get('customer_id', '')}-{now.timestamp()}"
|
||||||
|
data['code'] = 'VCH-' + hashlib.md5(raw.encode()).hexdigest()[:12].upper()
|
||||||
|
|
||||||
|
sor.I('voucher_instance', data)
|
||||||
|
|
||||||
|
# 更新模板已发放量
|
||||||
|
if template:
|
||||||
|
tid = data.get('template_id', '')
|
||||||
|
sor.db_execute(f"UPDATE voucher_template SET issued_count = issued_count + 1 WHERE id = '{tid}'")
|
||||||
|
|
||||||
|
return {'status': 'success', 'data': {'id': data['id'], 'code': data['code']}}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_voucher_instance(conditions, data):
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
sor.U('voucher_instance', conditions, data)
|
||||||
|
return {'status': 'success'}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_voucher_instance(conditions):
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
sor.D('voucher_usage_log', {'instance_id': conditions.get('id', '')})
|
||||||
|
sor.D('voucher_instance', conditions)
|
||||||
|
return {'status': 'success'}
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Usage Log CRUD ==========
|
||||||
|
|
||||||
|
async def create_voucher_usage_log(data):
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
data['id'] = getID()
|
||||||
|
sor.I('voucher_usage_log', data)
|
||||||
|
return {'status': 'success', 'data': {'id': data['id']}}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_voucher_usage_log(conditions, data):
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
sor.U('voucher_usage_log', conditions, data)
|
||||||
|
return {'status': 'success'}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_voucher_usage_log(conditions):
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
sor.D('voucher_usage_log', conditions)
|
||||||
|
return {'status': 'success'}
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Options API (dropdown) ==========
|
||||||
|
|
||||||
|
async def get_voucher_template_options():
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
rows = sor.R('voucher_template', {}, {'sort': 'name'})
|
||||||
|
options = []
|
||||||
|
for r in rows:
|
||||||
|
tid = r.id if hasattr(r, 'id') else r['id']
|
||||||
|
name = r.name if hasattr(r, 'name') else r['name']
|
||||||
|
options.append({'value': tid, 'text': name})
|
||||||
|
return {'status': 'success', 'data': {'options': options}}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_voucher_instance_options():
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
rows = sor.R('voucher_instance', {}, {'sort': 'created_at desc'})
|
||||||
|
options = []
|
||||||
|
for r in rows:
|
||||||
|
vid = r.id if hasattr(r, 'id') else r['id']
|
||||||
|
code = r.code if hasattr(r, 'code') else r['code']
|
||||||
|
options.append({'value': vid, 'text': code})
|
||||||
|
return {'status': 'success', 'data': {'options': options}}
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Voucher Engine API ==========
|
||||||
|
|
||||||
|
async def apply_voucher_api(customer_id, order_id, voucher_ids, context):
|
||||||
|
"""外部调用:使用代金券"""
|
||||||
|
from voucher.rules.engine import batch_apply_vouchers
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
result = batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context)
|
||||||
|
return {'status': 'success', 'data': result}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_available_vouchers_api(customer_id, context=None):
|
||||||
|
"""外部调用:查询可用代金券"""
|
||||||
|
from voucher.rules.engine import get_available_vouchers
|
||||||
|
sor = DBPools().sqlorContext(_get_dbname())
|
||||||
|
vouchers = get_available_vouchers(sor, customer_id, context)
|
||||||
|
items = []
|
||||||
|
for v in vouchers:
|
||||||
|
if hasattr(v, '__dict__'):
|
||||||
|
items.append(v.__dict__)
|
||||||
|
else:
|
||||||
|
items.append(v)
|
||||||
|
return {'status': 'success', 'data': items}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_registered_rule_types():
|
||||||
|
"""返回已注册的规则类型列表"""
|
||||||
|
from voucher.rules.registry import get_all_rule_types
|
||||||
|
return {'status': 'success', 'data': get_all_rule_types()}
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Module Load ==========
|
||||||
|
|
||||||
|
def load_voucher():
|
||||||
|
"""注册所有函数到 ServerEnv"""
|
||||||
|
env = ServerEnv()
|
||||||
|
|
||||||
|
# Template CRUD
|
||||||
|
env.create_voucher_template = create_voucher_template
|
||||||
|
env.update_voucher_template = update_voucher_template
|
||||||
|
env.delete_voucher_template = delete_voucher_template
|
||||||
|
|
||||||
|
# Rule CRUD
|
||||||
|
env.create_voucher_rule = create_voucher_rule
|
||||||
|
env.update_voucher_rule = update_voucher_rule
|
||||||
|
env.delete_voucher_rule = delete_voucher_rule
|
||||||
|
|
||||||
|
# Instance CRUD
|
||||||
|
env.create_voucher_instance = create_voucher_instance
|
||||||
|
env.update_voucher_instance = update_voucher_instance
|
||||||
|
env.delete_voucher_instance = delete_voucher_instance
|
||||||
|
|
||||||
|
# Usage Log CRUD
|
||||||
|
env.create_voucher_usage_log = create_voucher_usage_log
|
||||||
|
env.update_voucher_usage_log = update_voucher_usage_log
|
||||||
|
env.delete_voucher_usage_log = delete_voucher_usage_log
|
||||||
|
|
||||||
|
# Options
|
||||||
|
env.get_voucher_template_options = get_voucher_template_options
|
||||||
|
env.get_voucher_instance_options = get_voucher_instance_options
|
||||||
|
|
||||||
|
# Engine API
|
||||||
|
env.apply_voucher_api = apply_voucher_api
|
||||||
|
env.get_available_vouchers_api = get_available_vouchers_api
|
||||||
|
env.get_registered_rule_types = get_registered_rule_types
|
||||||
|
|
||||||
|
# Import validators to trigger @register_rule decorators
|
||||||
|
import voucher.rules.validators # noqa: F401
|
||||||
|
|
||||||
|
return True
|
||||||
17
wwwroot/api/apply_voucher.dspy
Normal file
17
wwwroot/api/apply_voucher.dspy
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
order_id = params_kw.get('order_id', '')
|
||||||
|
voucher_ids = params_kw.get('voucher_ids', [])
|
||||||
|
if isinstance(voucher_ids, str):
|
||||||
|
voucher_ids = json.loads(voucher_ids)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'request_amount': params_kw.get('request_amount', 0),
|
||||||
|
'product_type': params_kw.get('product_type', ''),
|
||||||
|
'product_name': params_kw.get('product_name', ''),
|
||||||
|
'user_level': params_kw.get('user_level', 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await apply_voucher_api(customer_id, order_id, voucher_ids, context)
|
||||||
|
return json.dumps(result)
|
||||||
13
wwwroot/api/get_available.dspy
Normal file
13
wwwroot/api/get_available.dspy
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
context = {}
|
||||||
|
if params_kw.get('product_type'):
|
||||||
|
context['product_type'] = params_kw.get('product_type')
|
||||||
|
if params_kw.get('product_name'):
|
||||||
|
context['product_name'] = params_kw.get('product_name')
|
||||||
|
if params_kw.get('request_amount'):
|
||||||
|
context['request_amount'] = params_kw.get('request_amount')
|
||||||
|
|
||||||
|
result = await get_available_vouchers_api(customer_id, context if context else None)
|
||||||
|
return json.dumps(result)
|
||||||
10
wwwroot/api/instance/voucher_instance_create.dspy
Normal file
10
wwwroot/api/instance/voucher_instance_create.dspy
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
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 json.dumps(result)
|
||||||
5
wwwroot/api/instance/voucher_instance_delete.dspy
Normal file
5
wwwroot/api/instance/voucher_instance_delete.dspy
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
conditions = {'id': params_kw.get('id', '')}
|
||||||
|
result = await delete_voucher_instance(conditions)
|
||||||
|
return json.dumps(result)
|
||||||
4
wwwroot/api/instance/voucher_instance_options.dspy
Normal file
4
wwwroot/api/instance/voucher_instance_options.dspy
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
result = await get_voucher_instance_options()
|
||||||
|
return json.dumps(result)
|
||||||
11
wwwroot/api/instance/voucher_instance_update.dspy
Normal file
11
wwwroot/api/instance/voucher_instance_update.dspy
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
conditions = {'id': params_kw.get('id', '')}
|
||||||
|
data = {}
|
||||||
|
for key in ['template_id', 'customer_id', 'code', 'status', 'source', 'remark']:
|
||||||
|
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
|
||||||
|
if val is not None:
|
||||||
|
data[key] = val
|
||||||
|
|
||||||
|
result = await update_voucher_instance(conditions, data)
|
||||||
|
return json.dumps(result)
|
||||||
10
wwwroot/api/rule/voucher_rule_create.dspy
Normal file
10
wwwroot/api/rule/voucher_rule_create.dspy
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
for key in ['template_id', 'rule_type', 'rule_config', 'enabled', 'sort_order', 'remark']:
|
||||||
|
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
|
||||||
|
if val is not None:
|
||||||
|
data[key] = val
|
||||||
|
|
||||||
|
result = await create_voucher_rule(data)
|
||||||
|
return json.dumps(result)
|
||||||
5
wwwroot/api/rule/voucher_rule_delete.dspy
Normal file
5
wwwroot/api/rule/voucher_rule_delete.dspy
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
conditions = {'id': params_kw.get('id', '')}
|
||||||
|
result = await delete_voucher_rule(conditions)
|
||||||
|
return json.dumps(result)
|
||||||
11
wwwroot/api/rule/voucher_rule_update.dspy
Normal file
11
wwwroot/api/rule/voucher_rule_update.dspy
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
conditions = {'id': params_kw.get('id', '')}
|
||||||
|
data = {}
|
||||||
|
for key in ['template_id', 'rule_type', 'rule_config', 'enabled', 'sort_order', 'remark']:
|
||||||
|
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
|
||||||
|
if val is not None:
|
||||||
|
data[key] = val
|
||||||
|
|
||||||
|
result = await update_voucher_rule(conditions, data)
|
||||||
|
return json.dumps(result)
|
||||||
4
wwwroot/api/rule_types.dspy
Normal file
4
wwwroot/api/rule_types.dspy
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
result = await get_registered_rule_types()
|
||||||
|
return json.dumps(result)
|
||||||
11
wwwroot/api/template/voucher_template_create.dspy
Normal file
11
wwwroot/api/template/voucher_template_create.dspy
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
params = params_kw if hasattr(params_kw, '__getitem__') else {}
|
||||||
|
data = {}
|
||||||
|
for key in ['name', 'code', 'face_value', 'total_count', 'valid_days', 'status', 'remark']:
|
||||||
|
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
|
||||||
|
if val is not None:
|
||||||
|
data[key] = val
|
||||||
|
|
||||||
|
result = await create_voucher_template(data)
|
||||||
|
return json.dumps(result)
|
||||||
5
wwwroot/api/template/voucher_template_delete.dspy
Normal file
5
wwwroot/api/template/voucher_template_delete.dspy
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
conditions = {'id': params_kw.get('id', '')}
|
||||||
|
result = await delete_voucher_template(conditions)
|
||||||
|
return json.dumps(result)
|
||||||
4
wwwroot/api/template/voucher_template_options.dspy
Normal file
4
wwwroot/api/template/voucher_template_options.dspy
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
result = await get_voucher_template_options()
|
||||||
|
return json.dumps(result)
|
||||||
11
wwwroot/api/template/voucher_template_update.dspy
Normal file
11
wwwroot/api/template/voucher_template_update.dspy
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
conditions = {'id': params_kw.get('id', '')}
|
||||||
|
data = {}
|
||||||
|
for key in ['name', 'code', 'face_value', 'total_count', 'valid_days', 'status', 'remark', 'updater']:
|
||||||
|
val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None
|
||||||
|
if val is not None:
|
||||||
|
data[key] = val
|
||||||
|
|
||||||
|
result = await update_voucher_template(conditions, data)
|
||||||
|
return json.dumps(result)
|
||||||
10
wwwroot/api/usage/voucher_usage_log_create.dspy
Normal file
10
wwwroot/api/usage/voucher_usage_log_create.dspy
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
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 json.dumps(result)
|
||||||
5
wwwroot/api/usage/voucher_usage_log_delete.dspy
Normal file
5
wwwroot/api/usage/voucher_usage_log_delete.dspy
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
conditions = {'id': params_kw.get('id', '')}
|
||||||
|
result = await delete_voucher_usage_log(conditions)
|
||||||
|
return json.dumps(result)
|
||||||
11
wwwroot/api/usage/voucher_usage_log_update.dspy
Normal file
11
wwwroot/api/usage/voucher_usage_log_update.dspy
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
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 json.dumps(result)
|
||||||
48
wwwroot/index.ui
Normal file
48
wwwroot/index.ui
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"widgettype": "VBox",
|
||||||
|
"options": {"width": "100%", "height": "100%", "padding": "20px"},
|
||||||
|
"subwidgets": [
|
||||||
|
{"widgettype": "Title3", "options": {"text": "代金券管理"}},
|
||||||
|
{"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"},
|
||||||
|
"subwidgets": [
|
||||||
|
{"widgettype": "VBox",
|
||||||
|
"options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"},
|
||||||
|
"binds": [{
|
||||||
|
"wid": "self", "event": "click", "actiontype": "urlwidget",
|
||||||
|
"target": "app.voucher_content",
|
||||||
|
"options": {"url": "{{entire_url('/voucher/voucher_template_list/index.ui')}}"},
|
||||||
|
"mode": "replace"
|
||||||
|
}],
|
||||||
|
"subwidgets": [
|
||||||
|
{"widgettype": "Text", "options": {"text": "模板管理", "fontSize": "18px", "bold": true}},
|
||||||
|
{"widgettype": "Text", "options": {"text": "管理代金券模板和规则配置", "fontSize": "14px", "color": "#666"}}
|
||||||
|
]},
|
||||||
|
{"widgettype": "VBox",
|
||||||
|
"options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"},
|
||||||
|
"binds": [{
|
||||||
|
"wid": "self", "event": "click", "actiontype": "urlwidget",
|
||||||
|
"target": "app.voucher_content",
|
||||||
|
"options": {"url": "{{entire_url('/voucher/voucher_instance_list/index.ui')}}"},
|
||||||
|
"mode": "replace"
|
||||||
|
}],
|
||||||
|
"subwidgets": [
|
||||||
|
{"widgettype": "Text", "options": {"text": "券实例管理", "fontSize": "18px", "bold": true}},
|
||||||
|
{"widgettype": "Text", "options": {"text": "查看和发放代金券", "fontSize": "14px", "color": "#666"}}
|
||||||
|
]},
|
||||||
|
{"widgettype": "VBox",
|
||||||
|
"options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"},
|
||||||
|
"binds": [{
|
||||||
|
"wid": "self", "event": "click", "actiontype": "urlwidget",
|
||||||
|
"target": "app.voucher_content",
|
||||||
|
"options": {"url": "{{entire_url('/voucher/voucher_usage_log_list/index.ui')}}"},
|
||||||
|
"mode": "replace"
|
||||||
|
}],
|
||||||
|
"subwidgets": [
|
||||||
|
{"widgettype": "Text", "options": {"text": "使用流水", "fontSize": "18px", "bold": true}},
|
||||||
|
{"widgettype": "Text", "options": {"text": "查看代金券使用记录", "fontSize": "14px", "color": "#666"}}
|
||||||
|
]}
|
||||||
|
]},
|
||||||
|
{"widgettype": "VBox", "id": "voucher_content",
|
||||||
|
"options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
25
wwwroot/menu.json
Normal file
25
wwwroot/menu.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user