diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8aa0952 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 81b149f..a2b1715 100644 --- a/README.md +++ b/README.md @@ -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 | 代金券模板(面值、有效期、发行量、状态) | -| voucher_rule | 规则定义(模板关联,可增删启用禁用) | -| voucher_instance | 券实例(一次性使用,状态:unused/used/expired) | -| voucher_usage_log | 使用流水(订单、抵扣金额、产品信息) | +| 一次性使用 | 核销用条件 UPDATE(`status='unused'` 守卫)+ affected_rows 判定,并发双花只有一个成功 | +| 发行量控制 | `issued_count 0 时从余额扣减 +# 结算联动(product_management/accounting 等): +result = await env.voucher_batch_apply(sor, customer_id, order_id, voucher_ids, context) +# → {'total_deducted': 150.0, 'remaining': 50.0, 'details': [...]} +# remaining > 0 时从余额扣 +# context: {request_amount(必填), product_type, product_name, user_level, used_by} ``` -### accounting 模块(余额扣减联动) - -```python -# accounting 消费流程中先尝试代金券,剩余从余额扣 -total_amount = calculate_consumption(customer_id, period) - -# Step 1: 尝试代金券抵扣 -result = batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context) - -# Step 2: 剩余金额从余额扣减 -if result['remaining'] > 0: - accounting.deduct_balance(customer_id, result['remaining'], order_id) - -# Step 3: 记录完整账单 -accounting.record_order(order_id, total_amount, - voucher_deducted=result['total_deducted'], - balance_deducted=result['remaining']) -``` - -### 客户自助查询 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 " \ - https://ai.atvoe.com/voucher/api/v1/available.dspy - -# 按产品类型过滤 -curl -H "Authorization: Bearer " \ - "https://ai.atvoe.com/voucher/api/v1/available.dspy?product_type=llm&request_amount=100" -``` - -### 集成点总结 - -| 调用方 | 场景 | 调用方式 | -|--------|------|----------| -| llmage | 推理/生成时抵扣 | `apply_voucher()` 函数调用 | -| accounting | 月度账单结算 | `batch_apply_vouchers()` 函数调用 | -| 远端客户 | 自助查询可用券 | `GET /voucher/api/v1/available.dspy` HTTP API | -| 任意模块 | 新增规则类型 | `@register_rule` + 模板管理界面 | - ---- +注册函数全集(`load_voucher()`):`get_available_vouchers_api` / `apply_voucher_api` / `validate_voucher_api` / `my_vouchers_api` / 模板规则实例管理函数 / `issue_voucher_api` / `invalidate_voucher_api` / `get_voucher_template_options` / `get_registered_rule_types` / 引擎级 `voucher_issue` / `voucher_apply` / `voucher_batch_apply` / `voucher_get_available`。 ## 目录结构 ``` voucher/ ├── voucher/ # Python 包 -│ ├── __init__.py -│ └── init.py # 模块初始化 + ServerEnv 注册 -├── rules/ # 规则引擎(纯 Python) -│ ├── registry.py # @register_rule 装饰器 -│ ├── validators.py # 内置规则校验器 -│ └── engine.py # 校验/使用/批量使用 -├── wwwroot/ # 前端 -│ ├── index.ui # 入口页(卡片导航) -│ ├── menu.json # 菜单定义 -│ └── api/ # API 端点 -│ ├── template/ # 模板 CRUD -│ ├── rule/ # 规则 CRUD -│ ├── instance/ # 实例 CRUD -│ ├── usage/ # 流水 CRUD -│ ├── v1/ # 客户自助查询 API -│ │ └── available.dspy # 查询可用券(Bearer 认证) -│ ├── apply_voucher.dspy # 使用代金券 -│ ├── get_available.dspy # 内部查询可用券 -│ └── rule_types.dspy # 获取规则类型列表 -├── models/ # 表定义 JSON -├── json/ # CRUD 定义 JSON -├── sql/ -│ └── tables.sql # 建表 + 编码初始化 +│ ├── init.py # ServerEnv 注册 + 注册送券挂钩 + load_voucher() +│ └── rules/ # registry(@register_rule) + validators(7种) + engine(全异步) +├── init/data.json # 字典种子(模板状态/实例状态/规则类型/来源/启用标志) +├── models/ # 4 张表定义(建表唯一事实源,json2ddl 渲染) +├── json/ # 4 个 CRUD 定义(xls2ui 生成 wwwroot/voucher_*_list/,生成物不入库) +├── wwwroot/ +│ ├── index.ui # 管理入口(卡片导航) +│ ├── my/index.ui # 客户「我的代金券」 +│ ├── issue_form.ui # 发券弹窗表单 +│ ├── invalidate_form.ui # 作废弹窗表单 +│ └── api/ # 薄封装 dspy(零 import,委托 init.py 注册函数) ├── scripts/ -│ └── load_path.py # RBAC 权限注册 -├── pyproject.toml -└── README.md +│ ├── load_path.py # RBAC 注册(logined 全路径逐个精确配) +│ └── seed_welcome_voucher.py # WELCOME400 种子(幂等) +├── i18n/{zh,en,jp,ko}/msg.txt # key=value 格式(merge_i18n 消费) +└── pyproject.toml # packages = ["voucher", "voucher.rules"] ``` ---- +## 部署清单(pipeline-app 宿主,六处登记) -## 部署步骤 +1. `build.sh`:clone/pip install/xls2ui/软链四个清单加 `voucher`(已登记) +2. `app/pipeline_app.py`:import + `load_voucher()`(已登记,ticket 之后) +3. `scripts/import_init.py`:INIT_MODULES 加 voucher 行(已登记) +4. 建表:`create_tables.py` 动态扫描 pkgs/*/models 自动覆盖(无需改) +5. `wwwroot/index.ui`:客户「我的代金券」+ 管理「代金券管理」菜单(已登记,is_ticket_staff 门禁) +6. RBAC:`./load_path.sh`(自动扫 pkgs/*/scripts/load_path.py)→ `redis-cli -n 0 FLUSHDB` → 重启 -### 1. 代码部署 +部署后一次性操作: ```bash -cd ~/repos/voucher && git pull -cd ~/repos/sage/pkgs && ln -sf ~/repos/voucher voucher -cd voucher && ~/repos/sage/py3/bin/pip install -e . +cd +./py3/bin/python pkgs/voucher/scripts/seed_welcome_voucher.py # WELCOME400 种子 ``` -### 2. 数据库建表 -```bash -mysql -u root -p sage < ~/repos/voucher/sql/tables.sql -``` +## Pitfalls -### 3. Sage 集成 -```python -# sage/app/sage.py -from voucher.init import load_voucher -# ... in init(): -load_voucher() -``` +- **实例「新增」必须走发券 API**(voucher_issue.dspy → issue_voucher 引擎):裸插入会绕过原子占额/券码生成/有效期计算。CRUD json 的 `editable.new_data_url` 已指向它。 +- **状态字段禁止界面直改**:使用走核销 API、作废走 invalidate API(都有条件 UPDATE 原子守卫);update_voucher_instance 只允许改 customer_id/remark 且仅 unused 券。 +- 一次性使用:不追踪 remaining_value/partial 状态,抵扣 = min(面值, 消费金额),不找零。 +- rule_config 数组字段用 JSON array(validators 兼容逗号分隔串,但写入必须是 JSON)。 +- rule_type 用短名(product_type 而非 applicable_product_type)——appcodes_kv.id = `{parentid}_{k}` ≤32 字符。 +- 模板删除:已发放(issued_count>0)拒绝,引导用「停用」;删除级联删规则,已发出的券保留但 validate 拒绝(模板非 active)。 +- dspy 薄封装返回 dict(框架自动序列化),**禁 json.dumps 双重序列化**(Pitfall 38)。 +- datetime 字段回前端必须 str 化(`_jsonable_instance`),框架 json.dumps 无 default=str。 +- 弹窗表单 submited 事件的 params 是 **Response 对象**,要 `await resp.json()` 后再 widgetBuild(form.js dispatch 原始 resp)。 -```bash -# sage/build.sh 安装循环 -for m in ... voucher -``` +## 仓库 -### 4. 菜单入口 -```json -// sage/wwwroot/global_menu.ui items 数组 -,{ - "name": "voucher", - "label": "代金券", - "icon": "fa fa-ticket", - "url": "{{entire_url('/voucher/index.ui')}}", - "target": "app.sage_main_content" -} -``` - -### 5. RBAC 权限 -```bash -cd ~/repos/sage && ./py3/bin/python ~/repos/voucher/scripts/load_path.py -``` - -### 6. 重启 -```bash -cd ~/repos/sage && ./stop.sh && ./start.sh -``` - ---- - -## 使用示例 - -### 创建模板 + 配置规则 -1. 模板管理 → 新增 → 名称"新用户满减券",面值 50,有效期 30 天 -2. 点击模板 → 规则配置 → 添加规则: - - 规则类型: `min_amount`,配置: `{"min_value": 100}` - - 规则类型: `applicable_product_type`,配置: `{"product_types": ["llm"]}` - -### 发放代金券 -1. 券实例管理 → 新增 → 选择模板,填写客户 ID -2. 系统自动生成券码,设置有效期 - -### 消费时抵扣 -```python -# llmage 推理完成后 -context = {'request_amount': 120.0, 'product_type': 'llm', 'product_name': 'gpt-4'} -result = batch_apply_vouchers(sor, customer_id, order_id, [vid1, vid2], context) -# → 抵扣 50(满100可用),剩余 70 从余额扣 -``` +- 远端:git@git.opencomputing.cn:yumoqing/voucher.git(main) +- 宿主接入提交:pipeline-app(build.sh/pipeline_app.py/import_init.py/index.ui) diff --git a/conf/config.json b/conf/config.json deleted file mode 100644 index 9e8b61a..0000000 --- a/conf/config.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "module": "voucher", - "databases": { - "sage": { - "host": "${DB_HOST}", - "port": "${DB_PORT}", - "user": "${DB_USER}", - "passwd": "${DB_PASSWD}", - "db": "sage" - } - } -} diff --git a/i18n/en/msg.txt b/i18n/en/msg.txt index 1e05afd..6e638d8 100644 --- a/i18n/en/msg.txt +++ b/i18n/en/msg.txt @@ -1,42 +1,89 @@ -Cancel: Cancel -Conform: Confirm -Discard: Discard -Reset: Reset -Submit: Submit -主键ID: Primary Key ID -产品名称: Product Name -产品类型: Product Type -代金券使用流水: Voucher Usage Records -代金券实例: Voucher Instance -代金券模板: Voucher Template -代金券规则定义: Voucher Rule Definition -使用时间: Usage Time -使用的订单ID: Order ID Used -创建人: Creator -创建时间: Created At -券实例ID: Voucher Instance ID -券面值: Face Value -发放时间: Issue Time -唯一券码: Unique Voucher Code -备注: Remark -实际抵扣金额: Actual Deduction Amount -客户ID: Customer ID -已发放量: Issued Quantity -总发行量: Total Issued -执行顺序: Execution Order -操作人: Operator -是否启用: Enabled -更新人: Updater -更新时间: Updated At -有效期天数: Validity Days -来源: Source -模板ID: Template ID -模板名称: Template Name -模板编码: Template Code -状态: Status -生效时间: Effective Time -规则类型: Rule Type -规则配置(JSON): Rule Config (JSON) -订单/消费ID: Order/Consumption ID -过期时间: Expiry Time -面值: Face Value +Cancel=Cancel +Conform=Confirm +Discard=Discard +Reset=Reset +Submit=Submit +代金券=Voucher +代金券管理=Voucher Management +模板管理=Template Management +券实例管理=Voucher Instance Management +使用流水=Usage Records +管理代金券模板和规则配置=Manage voucher templates and rule configuration +查看和发放代金券=View and issue vouchers +查看代金券使用记录=View voucher usage records +我的代金券=My Vouchers +发放代金券=Issue Voucher +作废=Void +确认作废=Confirm Void +作废代金券=Void Voucher +已作废=Voided +作废原因=Void Reason +必填,将追加到券备注(审计留痕)=Required; will be appended to voucher remark (audit trail) +按此模板发券=Issue from this template +代金券模板=Voucher Template +代金券实例=Voucher Instance +代金券规则=Voucher Rule +代金券使用流水=Voucher Usage Log +主键ID=Primary Key ID +模板名称=Template Name +模板编码=Template Code +模板ID=Template ID +模板=Template +面值=Face Value +面值(元)=Face Value (CNY) +已抵扣(元)=Deducted (CNY) +总发行量(0不限)=Total Issue Count (0=unlimited) +已发放量=Issued Count +有效期天数=Valid Days +状态=Status +草稿=Draft +启用=Active +停用=Inactive +备注=Remark +所属机构ID=Organization ID +创建人=Created By +创建时间=Created At +更新时间=Updated At +规则类型=Rule Type +规则配置JSON=Rule Config JSON +是否启用=Enabled +执行顺序=Sort Order +客户机构ID=Customer Organization ID +客户机构=Customer Organization +券码=Voucher Code +未使用=Unused +已使用=Used +已过期=Expired +实际抵扣金额=Actual Deducted +生效时间=Valid From +过期时间=Valid To +发放时间=Issued At +使用时间=Used At +使用订单ID=Order ID +订单ID=Order ID +发放来源=Source +手动发放=Manual +注册赠券=Registration Gift +促销活动=Promotion +发放人=Issued By +券实例ID=Instance ID +券实例=Voucher Instance +券面值=Face Value +抵扣金额=Deducted Amount +产品类型=Product Type +产品名称=Product Name +操作人=Used By +使用记录=Usage Records +规则配置=Rule Configuration +最低消费=Minimum Amount +最高消费=Maximum Amount +限定产品类型=Product Type Limit +限定特定产品=Product Limit +排除特定产品=Product Exclusion +最大使用次数=Max Usage Count +用户等级=User Level +发券=Issue +客户机构ID必填=Customer is required +可选=Optional +查询=Search +全部=All diff --git a/i18n/jp/msg.txt b/i18n/jp/msg.txt index 14fb8ef..2992af5 100644 --- a/i18n/jp/msg.txt +++ b/i18n/jp/msg.txt @@ -1,42 +1,89 @@ -Cancel: キャンセル -Conform: 確認 -Discard: 破棄 -Reset: リセット -Submit: 送信 -主键ID: 主キーID -产品名称: 製品名 -产品类型: 製品タイプ -代金券使用流水: バウチャー使用履歴 -代金券实例: バウチャーインスタンス -代金券模板: バウチャーテンプレート -代金券规则定义: バウチャールール定義 -使用时间: 使用日時 -使用的订单ID: 使用注文ID -创建人: 作成者 -创建时间: 作成日時 -券实例ID: バウチャーインスタンスID -券面值: 券面額 -发放时间: 発行日時 -唯一券码: ユニークバウチャーコード -备注: 備考 -实际抵扣金额: 実質控除額 -客户ID: 顧客ID -已发放量: 発行済み数量 -总发行量: 総発行量 -执行顺序: 実行順序 -操作人: 操作者 -是否启用: 有効化 -更新人: 更新者 -更新时间: 更新日時 -有效期天数: 有効日数 -来源: ソース -模板ID: テンプレートID -模板名称: テンプレート名 -模板编码: テンプレートコード -状态: ステータス -生效时间: 発効日時 -规则类型: ルールタイプ -规则配置(JSON): ルール設定(JSON) -订单/消费ID: 注文/消費ID -过期时间: 有効期限 -面值: 額面 +Cancel=キャンセル +Conform=確認 +Discard=破棄 +Reset=リセット +Submit=送信 +代金券=クーポン +代金券管理=クーポン管理 +模板管理=テンプレート管理 +券实例管理=クーポンインスタンス管理 +使用流水=使用履歴 +管理代金券模板和规则配置=クーポンテンプレートとルール設定を管理 +查看和发放代金券=クーポンの確認と発行 +查看代金券使用记录=クーポン使用記録の確認 +我的代金券=マイクーポン +发放代金券=クーポン発行 +作废=無効化 +确认作废=無効化の確認 +作废代金券=クーポン無効化 +已作废=無効化済み +作废原因=無効化理由 +必填,将追加到券备注(审计留痕)=必須入力。クーポン備考に追記されます(監査証跡) +按此模板发券=このテンプレートで発行 +代金券模板=クーポンテンプレート +代金券实例=クーポンインスタンス +代金券规则=クーポンルール +代金券使用流水=クーポン使用履歴 +主键ID=主キーID +模板名称=テンプレート名 +模板编码=テンプレートコード +模板ID=テンプレートID +模板=テンプレート +面值=額面 +面值(元)=額面(元) +已抵扣(元)=控除額(元) +总发行量(0不限)=総発行枚数(0=無制限) +已发放量=発行済み枚数 +有效期天数=有効日数 +状态=ステータス +草稿=下書き +启用=有効 +停用=無効 +备注=備考 +所属机构ID=所属組織ID +创建人=作成者 +创建时间=作成日時 +更新时间=更新日時 +规则类型=ルールタイプ +规则配置JSON=ルール設定JSON +是否启用=有効フラグ +执行顺序=実行順序 +客户机构ID=顧客組織ID +客户机构=顧客組織 +券码=クーポンコード +未使用=未使用 +已使用=使用済み +已过期=期限切れ +实际抵扣金额=実控除額 +生效时间=有効開始 +过期时间=有効期限 +发放时间=発行日時 +使用时间=使用日時 +使用订单ID=注文ID +订单ID=注文ID +发放来源=発行元 +手动发放=手動発行 +注册赠券=登録プレゼント +促销活动=プロモーション +发放人=発行者 +券实例ID=インスタンスID +券实例=クーポンインスタンス +券面值=額面 +抵扣金额=控除額 +产品类型=製品タイプ +产品名称=製品名 +操作人=操作者 +使用记录=使用記録 +规则配置=ルール設定 +最低消费=最低消費額 +最高消费=最高消費額 +限定产品类型=製品タイプ制限 +限定特定产品=製品制限 +排除特定产品=製品除外 +最大使用次数=最大使用回数 +用户等级=ユーザーレベル +发券=発行 +客户机构ID必填=顧客組織は必須です +可选=任意 +查询=検索 +全部=すべて diff --git a/i18n/ko/msg.txt b/i18n/ko/msg.txt index 9b9f955..48219e1 100644 --- a/i18n/ko/msg.txt +++ b/i18n/ko/msg.txt @@ -1,42 +1,89 @@ -Cancel: 취소 -Conform: 확인 -Discard: 폐기 -Reset: 초기화 -Submit: 제출 -主键ID: 기본키 ID -产品名称: 제품 이름 -产品类型: 제품 유형 -代金券使用流水: 바우처 사용 내역 -代金券实例: 바우처 인스턴스 -代金券模板: 바우처 템플릿 -代金券规则定义: 바우처 규칙 정의 -使用时间: 사용 시간 -使用的订单ID: 사용 주문 ID -创建人: 생성자 -创建时间: 생성 시간 -券实例ID: 바우처 인스턴스 ID -券面值: 액면가 -发放时间: 발급 시간 -唯一券码: 고유 바우처 코드 -备注: 비고 -实际抵扣金额: 실제 공제 금액 -客户ID: 고객 ID -已发放量: 발급 수량 -总发行量: 총 발행량 -执行顺序: 실행 순서 -操作人: 운영자 -是否启用: 활성화 여부 -更新人: 업데이트 담당자 -更新时间: 업데이트 시간 -有效期天数: 유효 기간(일) -来源: 출처 -模板ID: 템플릿 ID -模板名称: 템플릿 이름 -模板编码: 템플릿 코드 -状态: 상태 -生效时间: 효력 시작 시간 -规则类型: 규칙 유형 -规则配置(JSON): 규칙 구성(JSON) -订单/消费ID: 주문/소비 ID -过期时间: 만료 시간 -面值: 액면가 +Cancel=취소 +Conform=확인 +Discard=폐기 +Reset=초기화 +Submit=제출 +代金券=상품권 +代金券管理=상품권 관리 +模板管理=템플릿 관리 +券实例管理=상품권 인스턴스 관리 +使用流水=사용 내역 +管理代金券模板和规则配置=상품권 템플릿 및 규칙 구성 관리 +查看和发放代金券=상품권 조회 및 발급 +查看代金券使用记录=상품권 사용 기록 조회 +我的代金券=내 상품권 +发放代金券=상품권 발급 +作废=무효화 +确认作废=무효화 확인 +作废代金券=상품권 무효화 +已作废=무효화됨 +作废原因=무효화 사유 +必填,将追加到券备注(审计留痕)=필수 입력, 상품권 비고에 추가됩니다(감사 추적) +按此模板发券=이 템플릿으로 발급 +代金券模板=상품권 템플릿 +代金券实例=상품권 인스턴스 +代金券规则=상품권 규칙 +代金券使用流水=상품권 사용 내역 +主键ID=기본키 ID +模板名称=템플릿 이름 +模板编码=템플릿 코드 +模板ID=템플릿 ID +模板=템플릿 +面值=액면가 +面值(元)=액면가(위안) +已抵扣(元)=차감액(위안) +总发行量(0不限)=총 발행량(0=무제한) +已发放量=발행 수량 +有效期天数=유효 일수 +状态=상태 +草稿=초안 +启用=활성 +停用=비활성 +备注=비고 +所属机构ID=소속 기관 ID +创建人=생성자 +创建时间=생성 시간 +更新时间=수정 시간 +规则类型=규칙 유형 +规则配置JSON=규칙 구성 JSON +是否启用=활성 여부 +执行顺序=실행 순서 +客户机构ID=고객 기관 ID +客户机构=고객 기관 +券码=상품권 코드 +未使用=미사용 +已使用=사용됨 +已过期=만료됨 +实际抵扣金额=실제 차감 금액 +生效时间=발효 시간 +过期时间=만료 시간 +发放时间=발급 시간 +使用时间=사용 시간 +使用订单ID=주문 ID +订单ID=주문 ID +发放来源=발급 출처 +手动发放=수동 발급 +注册赠券=가입 증정 +促销活动=판촉 활동 +发放人=발급자 +券实例ID=인스턴스 ID +券实例=상품권 인스턴스 +券面值=액면가 +抵扣金额=차감 금액 +产品类型=제품 유형 +产品名称=제품 이름 +操作人=운영자 +使用记录=사용 기록 +规则配置=규칙 구성 +最低消费=최소 소비 +最高消费=최대 소비 +限定产品类型=제품 유형 제한 +限定特定产品=특정 제품 제한 +排除特定产品=특정 제품 제외 +最大使用次数=최대 사용 횟수 +用户等级=사용자 등급 +发券=발급 +客户机构ID必填=고객 기관은 필수입니다 +可选=선택 +查询=조회 +全部=전체 diff --git a/i18n/zh/msg.txt b/i18n/zh/msg.txt index a72ebd4..c1975c7 100644 --- a/i18n/zh/msg.txt +++ b/i18n/zh/msg.txt @@ -1,42 +1,89 @@ -Cancel: Cancel -Conform: Conform -Discard: Discard -Reset: Reset -Submit: Submit -主键ID: 主键ID -产品名称: 产品名称 -产品类型: 产品类型 -代金券使用流水: 代金券使用流水 -代金券实例: 代金券实例 -代金券模板: 代金券模板 -代金券规则定义: 代金券规则定义 -使用时间: 使用时间 -使用的订单ID: 使用的订单ID -创建人: 创建人 -创建时间: 创建时间 -券实例ID: 券实例ID -券面值: 券面值 -发放时间: 发放时间 -唯一券码: 唯一券码 -备注: 备注 -实际抵扣金额: 实际抵扣金额 -客户ID: 客户ID -已发放量: 已发放量 -总发行量: 总发行量 -执行顺序: 执行顺序 -操作人: 操作人 -是否启用: 是否启用 -更新人: 更新人 -更新时间: 更新时间 -有效期天数: 有效期天数 -来源: 来源 -模板ID: 模板ID -模板名称: 模板名称 -模板编码: 模板编码 -状态: 状态 -生效时间: 生效时间 -规则类型: 规则类型 -规则配置(JSON): 规则配置(JSON) -订单/消费ID: 订单/消费ID -过期时间: 过期时间 -面值: 面值 +Cancel=Cancel +Conform=Conform +Discard=Discard +Reset=Reset +Submit=Submit +代金券=代金券 +代金券管理=代金券管理 +模板管理=模板管理 +券实例管理=券实例管理 +使用流水=使用流水 +管理代金券模板和规则配置=管理代金券模板和规则配置 +查看和发放代金券=查看和发放代金券 +查看代金券使用记录=查看代金券使用记录 +我的代金券=我的代金券 +发放代金券=发放代金券 +作废=作废 +确认作废=确认作废 +作废代金券=作废代金券 +已作废=已作废 +作废原因=作废原因 +必填,将追加到券备注(审计留痕)=必填,将追加到券备注(审计留痕) +按此模板发券=按此模板发券 +代金券模板=代金券模板 +代金券实例=代金券实例 +代金券规则=代金券规则 +代金券使用流水=代金券使用流水 +主键ID=主键ID +模板名称=模板名称 +模板编码=模板编码 +模板ID=模板ID +模板=模板 +面值=面值 +面值(元)=面值(元) +已抵扣(元)=已抵扣(元) +总发行量(0不限)=总发行量(0不限) +已发放量=已发放量 +有效期天数=有效期天数 +状态=状态 +草稿=草稿 +启用=启用 +停用=停用 +备注=备注 +所属机构ID=所属机构ID +创建人=创建人 +创建时间=创建时间 +更新时间=更新时间 +规则类型=规则类型 +规则配置JSON=规则配置JSON +是否启用=是否启用 +执行顺序=执行顺序 +客户机构ID=客户机构ID +客户机构=客户机构 +券码=券码 +未使用=未使用 +已使用=已使用 +已过期=已过期 +实际抵扣金额=实际抵扣金额 +生效时间=生效时间 +过期时间=过期时间 +发放时间=发放时间 +使用时间=使用时间 +使用订单ID=使用订单ID +订单ID=订单ID +发放来源=发放来源 +手动发放=手动发放 +注册赠券=注册赠券 +促销活动=促销活动 +发放人=发放人 +券实例ID=券实例ID +券实例=券实例 +券面值=券面值 +抵扣金额=抵扣金额 +产品类型=产品类型 +产品名称=产品名称 +操作人=操作人 +使用记录=使用记录 +规则配置=规则配置 +最低消费=最低消费 +最高消费=最高消费 +限定产品类型=限定产品类型 +限定特定产品=限定特定产品 +排除特定产品=排除特定产品 +最大使用次数=最大使用次数 +用户等级=用户等级 +发券=发券 +客户机构ID必填=客户机构ID必填 +可选=可选 +查询=查询 +全部=全部 diff --git a/init/data.json b/init/data.json new file mode 100644 index 0000000..098280d --- /dev/null +++ b/init/data.json @@ -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": "禁用"} + ] + } + ] +} diff --git a/json/voucher_instance_list.json b/json/voucher_instance_list.json index a6a4749..18b637e 100644 --- a/json/voucher_instance_list.json +++ b/json/voucher_instance_list.json @@ -1,47 +1,130 @@ { "tblname": "voucher_instance", "alias": "voucher_instance_list", - "title": "代金券实例管理", + "title": "代金券实例", "params": { - "sortby": ["created_at desc"], - "data_filter": { - "AND": [ - {"field": "customer_id", "op": "=", "var": "customer_id"}, - {"field": "status", "op": "=", "var": "status"}, - {"field": "template_id", "op": "=", "var": "template_id"} - ] - }, + "sortby": [ + "issued_at desc" + ], "browserfields": { - "exclouded": [], + "exclouded": [ + "id", + "template_id" + ], "alters": { - "template_id": { - "uitype": "code", - "dataurl": "{{entire_url('../api/template/voucher_template_options.dspy')}}", - "data_field": "options", - "textField": "text", - "valueField": "value" - }, "status": { "uitype": "code", "data": [ - {"value": "unused", "text": "未使用"}, - {"value": "used", "text": "已使用"}, - {"value": "expired", "text": "已过期"} + { + "value": "unused", + "text": "未使用" + }, + { + "value": "used", + "text": "已使用" + }, + { + "value": "expired", + "text": "已过期" + }, + { + "value": "void", + "text": "已作废" + } + ] + }, + "source": { + "uitype": "code", + "data": [ + { + "value": "manual", + "text": "手动发放" + }, + { + "value": "register", + "text": "注册赠券" + }, + { + "value": "promo", + "text": "促销活动" + } ] } } }, - "editexclouded": ["used_at", "order_id", "actual_deducted"], + "editexclouded": [ + "id", + "code", + "status", + "face_value", + "actual_deducted", + "valid_from", + "valid_to", + "issued_at", + "used_at", + "order_id", + "created_at", + "updated_at" + ], + "data_filter": { + "AND": [ + { + "field": "customer_id", + "op": "=", + "var": "customer_id" + }, + { + "field": "status", + "op": "=", + "var": "status" + } + ] + }, + "filter_labels": { + "customer_id": "客户机构", + "status": "状态" + }, "editable": { - "new_data_url": "{{entire_url('../api/instance/voucher_instance_create.dspy')}}", + "new_data_url": "{{entire_url('../api/instance/voucher_issue.dspy')}}", "update_data_url": "{{entire_url('../api/instance/voucher_instance_update.dspy')}}", "delete_data_url": "{{entire_url('../api/instance/voucher_instance_delete.dspy')}}" }, + "toolbar": { + "tools": [ + { + "name": "invalidate", + "label": "作废", + "selected_row": true + } + ] + }, + "binds": [ + { + "wid": "self", + "event": "invalidate", + "actiontype": "urlwidget", + "target": "PopupWindow", + "popup_options": { + "title": "作废代金券", + "width": "40%", + "height": "46%", + "archor": "cc", + "resizable": true, + "dismiss_events": [ + "cancel", + "submited" + ] + }, + "options": { + "url": "{{entire_url('/voucher/invalidate_form.ui')}}" + } + } + ], "subtables": [ { - "field": "id", + "field": "instance_id", "title": "使用记录", - "url": "{{entire_url('../voucher_usage_log_list/')}}", + "url": "{{entire_url('../voucher_usage_log_list')}}", "subtable": "voucher_usage_log" } ] diff --git a/json/voucher_rule_list.json b/json/voucher_rule_list.json index 484fdf8..76c9f70 100644 --- a/json/voucher_rule_list.json +++ b/json/voucher_rule_list.json @@ -1,47 +1,91 @@ { "tblname": "voucher_rule", "alias": "voucher_rule_list", - "title": "代金券规则管理", + "title": "代金券规则", "params": { - "sortby": ["sort_order"], - "data_filter": { - "AND": [ - {"field": "template_id", "op": "=", "var": "template_id"}, - {"field": "rule_type", "op": "=", "var": "rule_type"} - ] - }, + "sortby": [ + "sort_order" + ], "browserfields": { - "exclouded": [], + "exclouded": [ + "id" + ], "alters": { - "template_id": { - "uitype": "code", - "dataurl": "{{entire_url('../api/template/voucher_template_options.dspy')}}", - "data_field": "options", - "textField": "text", - "valueField": "value" - }, "rule_type": { "uitype": "code", "data": [ - {"value": "min_amount", "text": "最低消费"}, - {"value": "max_amount", "text": "最高消费"}, - {"value": "applicable_product_type", "text": "限定产品类型"}, - {"value": "applicable_product", "text": "限定特定产品"}, - {"value": "exclude_product", "text": "排除特定产品"}, - {"value": "max_usage_count", "text": "最大使用次数"}, - {"value": "valid_period", "text": "有效期"}, - {"value": "user_level", "text": "用户等级"} + { + "value": "min_amount", + "text": "最低消费" + }, + { + "value": "max_amount", + "text": "最高消费" + }, + { + "value": "product_type", + "text": "限定产品类型" + }, + { + "value": "product", + "text": "限定特定产品" + }, + { + "value": "exclude_product", + "text": "排除特定产品" + }, + { + "value": "max_usage_count", + "text": "最大使用次数" + }, + { + "value": "user_level", + "text": "用户等级" + } ] }, "enabled": { "uitype": "code", "data": [ - {"value": "1", "text": "启用"}, - {"value": "0", "text": "停用"} + { + "value": "1", + "text": "启用" + }, + { + "value": "0", + "text": "停用" + } ] + }, + "rule_config": { + "uitype": "text", + "placeholder": "JSON 配置,如 {\"min_value\": 100} 或 {\"product_types\": [\"pipeline\"]}" } } }, + "editexclouded": [ + "id", + "created_at", + "updated_at" + ], + "data_filter": { + "AND": [ + { + "field": "template_id", + "op": "=", + "var": "template_id" + }, + { + "field": "rule_type", + "op": "=", + "var": "rule_type" + } + ] + }, + "filter_labels": { + "template_id": "模板", + "rule_type": "规则类型" + }, "editable": { "new_data_url": "{{entire_url('../api/rule/voucher_rule_create.dspy')}}", "update_data_url": "{{entire_url('../api/rule/voucher_rule_update.dspy')}}", diff --git a/json/voucher_template_list.json b/json/voucher_template_list.json index ec43c88..e84bdb6 100644 --- a/json/voucher_template_list.json +++ b/json/voucher_template_list.json @@ -1,42 +1,105 @@ { "tblname": "voucher_template", "alias": "voucher_template_list", - "title": "代金券模板管理", + "title": "代金券模板", "params": { - "sortby": ["created_at desc"], + "sortby": [ + "created_at desc" + ], "browserfields": { - "exclouded": ["creator", "updater"], + "exclouded": [ + "id", + "org_id", + "created_by" + ], "alters": { "status": { "uitype": "code", "data": [ - {"value": "draft", "text": "草稿"}, - {"value": "active", "text": "启用"}, - {"value": "inactive", "text": "停用"} + { + "value": "draft", + "text": "草稿" + }, + { + "value": "active", + "text": "启用" + }, + { + "value": "inactive", + "text": "停用" + } ] - }, - "face_value": { - "uitype": "code", - "data": [] } } }, + "editexclouded": [ + "id", + "code", + "issued_count", + "org_id", + "created_by", + "created_at", + "updated_at" + ], "data_filter": { "AND": [ - {"field": "name", "op": "LIKE", "var": "name"}, - {"field": "status", "op": "=", "var": "status"} + { + "field": "name", + "op": "LIKE", + "var": "name" + }, + { + "field": "status", + "op": "=", + "var": "status" + } ] }, + "filter_labels": { + "name": "模板名称", + "status": "状态" + }, "editable": { "new_data_url": "{{entire_url('../api/template/voucher_template_create.dspy')}}", "update_data_url": "{{entire_url('../api/template/voucher_template_update.dspy')}}", "delete_data_url": "{{entire_url('../api/template/voucher_template_delete.dspy')}}" }, + "toolbar": { + "tools": [ + { + "name": "issue_from_tpl", + "label": "发券", + "selected_row": true + } + ] + }, + "binds": [ + { + "wid": "self", + "event": "issue_from_tpl", + "actiontype": "urlwidget", + "target": "PopupWindow", + "popup_options": { + "title": "按此模板发券", + "width": "46%", + "height": "60%", + "archor": "cc", + "resizable": true, + "dismiss_events": [ + "cancel", + "submited" + ] + }, + "options": { + "url": "{{entire_url('/voucher/issue_form.ui')}}" + } + } + ], "subtables": [ { - "field": "id", + "field": "template_id", "title": "规则配置", - "url": "{{entire_url('../voucher_rule_list/')}}", + "url": "{{entire_url('../voucher_rule_list')}}", "subtable": "voucher_rule" } ] diff --git a/json/voucher_usage_log_list.json b/json/voucher_usage_log_list.json index 650ffab..2806b0d 100644 --- a/json/voucher_usage_log_list.json +++ b/json/voucher_usage_log_list.json @@ -3,30 +3,40 @@ "alias": "voucher_usage_log_list", "title": "代金券使用流水", "params": { - "sortby": ["used_at desc"], + "sortby": [ + "used_at desc" + ], + "noedit": true, + "browserfields": { + "exclouded": [ + "id", + "template_id" + ], + "alters": {} + }, "data_filter": { "AND": [ - {"field": "instance_id", "op": "=", "var": "instance_id"}, - {"field": "customer_id", "op": "=", "var": "customer_id"}, - {"field": "order_id", "op": "=", "var": "order_id"} + { + "field": "instance_id", + "op": "=", + "var": "instance_id" + }, + { + "field": "customer_id", + "op": "=", + "var": "customer_id" + }, + { + "field": "order_id", + "op": "LIKE", + "var": "order_id" + } ] }, - "browserfields": { - "exclouded": [], - "alters": { - "instance_id": { - "uitype": "code", - "dataurl": "{{entire_url('../api/instance/voucher_instance_options.dspy')}}", - "data_field": "options", - "textField": "text", - "valueField": "value" - } - } - }, - "editable": { - "new_data_url": "{{entire_url('../api/usage/voucher_usage_log_create.dspy')}}", - "update_data_url": "{{entire_url('../api/usage/voucher_usage_log_update.dspy')}}", - "delete_data_url": "{{entire_url('../api/usage/voucher_usage_log_delete.dspy')}}" + "filter_labels": { + "instance_id": "券实例", + "customer_id": "客户机构", + "order_id": "订单ID" } } } diff --git a/models/voucher_instance.json b/models/voucher_instance.json index b4a2f7d..3bf4399 100644 --- a/models/voucher_instance.json +++ b/models/voucher_instance.json @@ -10,27 +10,27 @@ "fields": [ {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "template_id", "title": "模板ID", "type": "str", "length": 32, "nullable": "no"}, - {"name": "customer_id", "title": "客户ID", "type": "str", "length": 32, "nullable": "no"}, - {"name": "code", "title": "唯一券码", "type": "str", "length": 64, "nullable": "no"}, + {"name": "customer_id", "title": "客户机构ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "code", "title": "券码", "type": "str", "length": 64, "nullable": "no"}, {"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "unused"}, - {"name": "face_value", "title": "面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"}, - {"name": "actual_deducted", "title": "实际抵扣金额", "type": "double", "length": 10, "dec": 2, "nullable": "yes"}, + {"name": "face_value", "title": "面值", "type": "double", "length": 18, "dec": 2, "nullable": "no"}, + {"name": "actual_deducted", "title": "实际抵扣金额", "type": "double", "length": 18, "dec": 2, "nullable": "yes"}, {"name": "valid_from", "title": "生效时间", "type": "datetime", "nullable": "no"}, {"name": "valid_to", "title": "过期时间", "type": "datetime", "nullable": "no"}, {"name": "issued_at", "title": "发放时间", "type": "datetime", "nullable": "no"}, {"name": "used_at", "title": "使用时间", "type": "datetime", "nullable": "yes"}, - {"name": "order_id", "title": "使用的订单ID", "type": "str", "length": 64, "nullable": "yes"}, - {"name": "source", "title": "来源", "type": "str", "length": 32, "nullable": "yes"}, - {"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"}, + {"name": "order_id", "title": "使用订单ID", "type": "str", "length": 64, "nullable": "yes"}, + {"name": "source", "title": "发放来源", "type": "str", "length": 32, "nullable": "no", "default": "manual"}, + {"name": "issued_by", "title": "发放人", "type": "str", "length": 32, "nullable": "yes"}, + {"name": "remark", "title": "备注", "type": "str", "length": 500, "nullable": "yes"}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} ], "indexes": [ - {"name": "idx_instance_code", "idxtype": "unique", "idxfields": ["code"]}, - {"name": "idx_instance_customer", "idxtype": "index", "idxfields": ["customer_id"]}, - {"name": "idx_instance_template", "idxtype": "index", "idxfields": ["template_id"]}, - {"name": "idx_instance_status", "idxtype": "index", "idxfields": ["status"]}, - {"name": "idx_instance_valid_to", "idxtype": "index", "idxfields": ["valid_to"]} + {"name": "idx_vi_code", "idxtype": "unique", "idxfields": ["code"]}, + {"name": "idx_vi_customer", "idxtype": "index", "idxfields": ["customer_id", "status"]}, + {"name": "idx_vi_template", "idxtype": "index", "idxfields": ["template_id"]}, + {"name": "idx_vi_valid_to", "idxtype": "index", "idxfields": ["valid_to"]} ], "codes": [ { @@ -39,12 +39,25 @@ "valuefield": "id", "textfield": "name" }, + { + "field": "customer_id", + "table": "organization", + "valuefield": "id", + "textfield": "orgname" + }, { "field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='voucher_instance_status'" + }, + { + "field": "source", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='vchr_source'" } ] } diff --git a/models/voucher_rule.json b/models/voucher_rule.json index f2adb43..53662c8 100644 --- a/models/voucher_rule.json +++ b/models/voucher_rule.json @@ -2,7 +2,7 @@ "summary": [ { "name": "voucher_rule", - "title": "代金券规则定义", + "title": "代金券规则", "primary": ["id"], "catelog": "entity" } @@ -10,17 +10,16 @@ "fields": [ {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "template_id", "title": "模板ID", "type": "str", "length": 32, "nullable": "no"}, - {"name": "rule_type", "title": "规则类型", "type": "str", "length": 32, "nullable": "no"}, - {"name": "rule_config", "title": "规则配置(JSON)", "type": "text", "nullable": "no"}, - {"name": "enabled", "title": "是否启用", "type": "short", "nullable": "no", "default": "1"}, + {"name": "rule_type", "title": "规则类型", "type": "str", "length": 64, "nullable": "no"}, + {"name": "rule_config", "title": "规则配置JSON", "type": "text", "nullable": "yes"}, + {"name": "enabled", "title": "是否启用", "type": "char", "length": 1, "nullable": "no", "default": "1"}, {"name": "sort_order", "title": "执行顺序", "type": "int", "nullable": "no", "default": "0"}, - {"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"}, + {"name": "remark", "title": "备注", "type": "str", "length": 500, "nullable": "yes"}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} ], "indexes": [ - {"name": "idx_rule_template", "idxtype": "index", "idxfields": ["template_id"]}, - {"name": "idx_rule_enabled", "idxtype": "index", "idxfields": ["enabled"]} + {"name": "idx_vr_template", "idxtype": "index", "idxfields": ["template_id"]} ], "codes": [ { @@ -34,14 +33,14 @@ "table": "appcodes_kv", "valuefield": "k", "textfield": "v", - "cond": "parentid='voucher_rule_type'" + "cond": "parentid='vchr_rule_type'" }, { "field": "enabled", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", - "cond": "parentid='yes_no'" + "cond": "parentid='vchr_enabled'" } ] } diff --git a/models/voucher_template.json b/models/voucher_template.json index 4598dd5..7f59280 100644 --- a/models/voucher_template.json +++ b/models/voucher_template.json @@ -9,22 +9,22 @@ ], "fields": [ {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, - {"name": "name", "title": "模板名称", "type": "str", "length": 64, "nullable": "no"}, - {"name": "code", "title": "模板编码", "type": "str", "length": 32, "nullable": "no"}, - {"name": "face_value", "title": "面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"}, - {"name": "total_count", "title": "总发行量", "type": "int", "nullable": "no", "default": "0"}, + {"name": "name", "title": "模板名称", "type": "str", "length": 128, "nullable": "no"}, + {"name": "code", "title": "模板编码", "type": "str", "length": 64, "nullable": "no"}, + {"name": "face_value", "title": "面值", "type": "double", "length": 18, "dec": 2, "nullable": "no"}, + {"name": "total_count", "title": "总发行量(0不限)", "type": "int", "nullable": "no", "default": "0"}, {"name": "issued_count", "title": "已发放量", "type": "int", "nullable": "no", "default": "0"}, {"name": "valid_days", "title": "有效期天数", "type": "int", "nullable": "no", "default": "30"}, {"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "draft"}, - {"name": "remark", "title": "备注", "type": "str", "length": 255, "nullable": "yes"}, + {"name": "remark", "title": "备注", "type": "str", "length": 500, "nullable": "yes"}, + {"name": "org_id", "title": "所属机构ID", "type": "str", "length": 32, "nullable": "no", "default": "0"}, + {"name": "created_by", "title": "创建人", "type": "str", "length": 32, "nullable": "yes"}, {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, - {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}, - {"name": "creator", "title": "创建人", "type": "str", "length": 32, "nullable": "yes"}, - {"name": "updater", "title": "更新人", "type": "str", "length": 32, "nullable": "yes"} + {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} ], "indexes": [ - {"name": "idx_template_code", "idxtype": "unique", "idxfields": ["code"]}, - {"name": "idx_template_status", "idxtype": "index", "idxfields": ["status"]} + {"name": "idx_vt_code", "idxtype": "unique", "idxfields": ["code"]}, + {"name": "idx_vt_status", "idxtype": "index", "idxfields": ["status"]} ], "codes": [ { diff --git a/models/voucher_usage_log.json b/models/voucher_usage_log.json index 333a26f..b0dee94 100644 --- a/models/voucher_usage_log.json +++ b/models/voucher_usage_log.json @@ -4,25 +4,27 @@ "name": "voucher_usage_log", "title": "代金券使用流水", "primary": ["id"], - "catelog": "entity" + "catelog": "relation" } ], "fields": [ {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, {"name": "instance_id", "title": "券实例ID", "type": "str", "length": 32, "nullable": "no"}, - {"name": "customer_id", "title": "客户ID", "type": "str", "length": 32, "nullable": "no"}, - {"name": "order_id", "title": "订单/消费ID", "type": "str", "length": 64, "nullable": "no"}, - {"name": "face_value", "title": "券面值", "type": "double", "length": 10, "dec": 2, "nullable": "no"}, - {"name": "deducted_amount", "title": "实际抵扣金额", "type": "double", "length": 10, "dec": 2, "nullable": "no"}, + {"name": "template_id", "title": "模板ID", "type": "str", "length": 32, "nullable": "yes"}, + {"name": "customer_id", "title": "客户机构ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "order_id", "title": "订单ID", "type": "str", "length": 64, "nullable": "no", "default": ""}, + {"name": "face_value", "title": "券面值", "type": "double", "length": 18, "dec": 2, "nullable": "no"}, + {"name": "deducted_amount", "title": "实际抵扣金额", "type": "double", "length": 18, "dec": 2, "nullable": "no"}, + {"name": "product_type", "title": "产品类型", "type": "str", "length": 32, "nullable": "yes"}, + {"name": "product_name", "title": "产品名称", "type": "str", "length": 128, "nullable": "yes"}, {"name": "used_at", "title": "使用时间", "type": "datetime", "nullable": "no"}, {"name": "used_by", "title": "操作人", "type": "str", "length": 32, "nullable": "yes"}, - {"name": "product_type", "title": "产品类型", "type": "str", "length": 32, "nullable": "yes"}, - {"name": "product_name", "title": "产品名称", "type": "str", "length": 64, "nullable": "yes"} + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"} ], "indexes": [ - {"name": "idx_log_instance", "idxtype": "index", "idxfields": ["instance_id"]}, - {"name": "idx_log_customer", "idxtype": "index", "idxfields": ["customer_id"]}, - {"name": "idx_log_order", "idxtype": "index", "idxfields": ["order_id"]} + {"name": "idx_vul_instance", "idxtype": "index", "idxfields": ["instance_id"]}, + {"name": "idx_vul_customer", "idxtype": "index", "idxfields": ["customer_id"]}, + {"name": "idx_vul_order", "idxtype": "index", "idxfields": ["order_id"]} ], "codes": [ { @@ -30,6 +32,12 @@ "table": "voucher_instance", "valuefield": "id", "textfield": "code" + }, + { + "field": "customer_id", + "table": "organization", + "valuefield": "id", + "textfield": "orgname" } ] } diff --git a/pyproject.toml b/pyproject.toml index 42d1381..dc020c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,17 @@ [build-system] -requires = ["setuptools>=45", "wheel"] +requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] name = "voucher" -version = "1.0.0" -description = "代金券模块:模板管理、可配置规则引擎、一次性使用代金券" -requires-python = ">=3.8" +version = "2.0.0" +description = "代金券模块:模板/规则/实例/流水 + 可配置规则引擎 + 注册送券挂钩" +readme = "README.md" +requires-python = ">=3.10" dependencies = [ "sqlor", - "bricks_for_python", + "bricks_for_python" ] -[tool.setuptools.packages.find] -where = ["."] -include = ["voucher*"] +[tool.setuptools] +packages = ["voucher", "voucher.rules"] diff --git a/rules/__init__.py b/rules/__init__.py deleted file mode 100644 index 1b9aaca..0000000 --- a/rules/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# voucher rules engine diff --git a/rules/engine.py b/rules/engine.py deleted file mode 100644 index 2ca72e6..0000000 --- a/rules/engine.py +++ /dev/null @@ -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 diff --git a/rules/registry.py b/rules/registry.py deleted file mode 100644 index 55d93dd..0000000 --- a/rules/registry.py +++ /dev/null @@ -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) diff --git a/rules/validators.py b/rules/validators.py deleted file mode 100644 index 423e371..0000000 --- a/rules/validators.py +++ /dev/null @@ -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 diff --git a/scripts/load_path.py b/scripts/load_path.py index 8cbd646..aa3c774 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -1,76 +1,99 @@ #!/usr/bin/env python3 -"""voucher 模块 RBAC 权限注册""" -import os +"""voucher 模块 RBAC 路径权限注册(pipeline-app 格式,仿 ticket/scripts/load_path.py)。 + +从应用根执行:py3/bin/python pkgs/voucher/scripts/load_path.py +(load_path.sh 自动扫描 pkgs/*/scripts/load_path.py 并调用本脚本) + +权限设计(逐个精确配,不用统一兜底): +- logined:客户侧「我的代金券」页面 + 自助查询 API + 管理侧全部页面与 API。 + 管理类数据面由服务端 org_id 隔离(模板带 org_id 列,CRUD logined_userorgid 机制 + 不适用——平台运营代客户管券是本职,不做 org 过滤;误操作面由 helper 内校验兜底: + 模板改删校验 org、实例改/作废只动 unused 券)。 +- CRUD 生成目录(voucher_*_list/)四件套(index.ui + get/add/update/delete dspy) + 逐一注册。 +""" import sys -import subprocess +import os +import asyncio -def find_sage_root(): - for candidate in [ - os.path.expanduser("~/repos/sage"), - os.path.expanduser("~/sage"), - ]: - if os.path.isdir(os.path.join(candidate, "wwwroot")): - return candidate - return None +# 应用根:本脚本在 pkgs/voucher/scripts/ 下,上溯三级 +APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../..")) +sys.path.insert(0, os.path.join(APP_ROOT, "py3", "lib", "python3.10", "site-packages")) +sys.path.insert(0, APP_ROOT) -def set_perm(sage_root, role, path): - script = os.path.join(sage_root, "set_role_perm.py") - cmd = [os.path.join(sage_root, "py3/bin/python"), script, role, path] - print(f" {role} {path}") - subprocess.run(cmd, cwd=sage_root, capture_output=True) +from sqlor.dbpools import DBPools +from appPublic.jsonConfig import getConfig +from appPublic.uniqueID import getID -def main(): - sage_root = find_sage_root() - if not sage_root: - print("ERROR: Sage root not found") - sys.exit(1) +MOD = "voucher" - print("Registering voucher RBAC permissions...") +# CRUD 生成目录四件套(alias -> 表名) +CRUD_DIRS = { + "voucher_template_list": "voucher_template", + "voucher_rule_list": "voucher_rule", + "voucher_instance_list": "voucher_instance", + "voucher_usage_log_list": "voucher_usage_log", +} - # any (公开) - any_paths = [ - "/voucher/menu.ui", +PATHS_LOGINED = [ + # 客户侧 + f"/{MOD}/my", + f"/{MOD}/my/index.ui", + f"/{MOD}/api/my_vouchers.dspy", + f"/{MOD}/api/v1/available.dspy", + # 管理侧页面/表单 + f"/{MOD}/index.ui", + f"/{MOD}/issue_form.ui", + f"/{MOD}/invalidate_form.ui", + # 管理侧 API + f"/{MOD}/api/get_available.dspy", + f"/{MOD}/api/apply_voucher.dspy", + f"/{MOD}/api/rule_types.dspy", + f"/{MOD}/api/template/voucher_template_create.dspy", + f"/{MOD}/api/template/voucher_template_update.dspy", + f"/{MOD}/api/template/voucher_template_delete.dspy", + f"/{MOD}/api/template/voucher_template_options.dspy", + f"/{MOD}/api/rule/voucher_rule_create.dspy", + f"/{MOD}/api/rule/voucher_rule_update.dspy", + f"/{MOD}/api/rule/voucher_rule_delete.dspy", + f"/{MOD}/api/instance/voucher_issue.dspy", + f"/{MOD}/api/instance/voucher_instance_update.dspy", + f"/{MOD}/api/instance/voucher_instance_delete.dspy", + f"/{MOD}/api/instance/voucher_invalidate.dspy", +] + +# CRUD 目录(框架生成,路径必须逐个注册) +for alias, tbl in CRUD_DIRS.items(): + PATHS_LOGINED += [ + f"/{MOD}/{alias}", + f"/{MOD}/{alias}/index.ui", + f"/{MOD}/{alias}/get_{tbl}.dspy", + f"/{MOD}/{alias}/add_{tbl}.dspy", + f"/{MOD}/{alias}/update_{tbl}.dspy", + f"/{MOD}/{alias}/delete_{tbl}.dspy", ] - # logined (登录后) - logined_paths = [ - "/voucher", - "/voucher/index.ui", - "/voucher/voucher_template_list", - "/voucher/voucher_template_list/index.ui", - "/voucher/voucher_rule_list", - "/voucher/voucher_rule_list/index.ui", - "/voucher/voucher_instance_list", - "/voucher/voucher_instance_list/index.ui", - "/voucher/voucher_usage_log_list", - "/voucher/voucher_usage_log_list/index.ui", - "/voucher/api/template/voucher_template_create.dspy", - "/voucher/api/template/voucher_template_update.dspy", - "/voucher/api/template/voucher_template_delete.dspy", - "/voucher/api/template/voucher_template_options.dspy", - "/voucher/api/rule/voucher_rule_create.dspy", - "/voucher/api/rule/voucher_rule_update.dspy", - "/voucher/api/rule/voucher_rule_delete.dspy", - "/voucher/api/instance/voucher_instance_create.dspy", - "/voucher/api/instance/voucher_instance_update.dspy", - "/voucher/api/instance/voucher_instance_delete.dspy", - "/voucher/api/instance/voucher_instance_options.dspy", - "/voucher/api/usage/voucher_usage_log_create.dspy", - "/voucher/api/usage/voucher_usage_log_update.dspy", - "/voucher/api/usage/voucher_usage_log_delete.dspy", - "/voucher/api/v1/available.dspy", - "/voucher/api/apply_voucher.dspy", - "/voucher/api/get_available.dspy", - "/voucher/api/rule_types.dspy", - ] - for p in any_paths: - set_perm(sage_root, "any", p) +async def main(): + config = getConfig(APP_ROOT, NS={"workdir": APP_ROOT}) + DBPools(config.databases) + cnt = 0 + async with DBPools().sqlorContext("pipeline") as sor: + for path in PATHS_LOGINED: + recs = await sor.R("permission", {"path": path}) + if recs: + permid = recs[0].id + else: + permid = getID() + await sor.C("permission", {"id": permid, "path": path}) + rp = await sor.R("rolepermission", {"roleid": "logined", "permid": permid}) + if not rp: + await sor.C("rolepermission", { + "id": getID(), "roleid": "logined", "permid": permid}) + cnt += 1 + await sor.sqlExe("COMMIT", {}) + print(f"voucher load_path: {cnt} logined path-role entries ensured") - for p in logined_paths: - set_perm(sage_root, "logined", p) - - print("Done.") if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/scripts/seed_welcome_voucher.py b/scripts/seed_welcome_voucher.py new file mode 100644 index 0000000..8af830a --- /dev/null +++ b/scripts/seed_welcome_voucher.py @@ -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()) diff --git a/sql/tables.sql b/sql/tables.sql deleted file mode 100644 index a559fed..0000000 --- a/sql/tables.sql +++ /dev/null @@ -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); diff --git a/voucher/__init__.py b/voucher/__init__.py index 1369328..f6ec980 100644 --- a/voucher/__init__.py +++ b/voucher/__init__.py @@ -1 +1,4 @@ -# voucher module +"""voucher 代金券模块""" +from voucher.init import load_voucher + +__all__ = ['load_voucher'] diff --git a/voucher/init.py b/voucher/init.py index 6a4714f..9d1df3d 100644 --- a/voucher/init.py +++ b/voucher/init.py @@ -1,242 +1,519 @@ -"""voucher 模块初始化""" +"""voucher 代金券模块:对外 API(注册到 ServerEnv 供 dspy 裸名调用)。 + +架构:模板(voucher_template) → 规则集(voucher_rule) → 实例(voucher_instance) → 流水(voucher_usage_log) +注册挂钩:bind pipeline:organization:c:after 事件——新客户机构注册即自动送欢迎券 +(前 N 名由模板 total_count 发行量原子控制,活动结束把模板置 inactive 即可)。 +""" + +from datetime import datetime from ahserver.serverenv import ServerEnv +from appPublic.jsonConfig import getConfig +from appPublic.log import debug, exception, info from appPublic.uniqueID import getID +from sqlor.dbpools import DBPools -MODULE_NAME = "voucher" -MODULE_VERSION = "1.0.0" -DBNAME = "sage" +from voucher.rules.engine import ( + apply_voucher, + batch_apply_vouchers, + expire_vouchers, + get_available_vouchers, + issue_voucher, + validate_voucher, +) +from voucher.rules.registry import get_all_rule_types + +MODULE_NAME = 'voucher' +MODULE_VERSION = '2.0.0' + +# datetime 字段(sqlor 返回 python datetime,框架 json.dumps 无 default=str 会崩) +_DT_FIELDS = ('valid_from', 'valid_to', 'issued_at', 'used_at', 'created_at', 'updated_at') + + +def _jsonable_instance(item): + """实例行 → JSON 安全(datetime 转 str,金额转 float)。""" + for k in _DT_FIELDS: + if item.get(k) is not None: + item[k] = str(item[k]) + for k in ('face_value', 'actual_deducted'): + if item.get(k) is not None: + item[k] = float(item[k]) + return item + +# 注册送券活动的模板编码(init/data.json 种子模板,部署后管理员可在界面调整) +WELCOME_TEMPLATE_CODE = 'WELCOME400' def _get_dbname(): - return DBNAME + """库名一律由宿主决定(get_module_dbname),不硬编码。""" + env = ServerEnv() + fn = getattr(env, 'get_module_dbname', None) + if callable(fn): + name = fn(MODULE_NAME) + if name: + return name + return 'pipeline' -# ========== Template CRUD ========== - -async def create_voucher_template(data): - sor = DBPools().sqlorContext(_get_dbname()) - data['id'] = getID() - data['issued_count'] = 0 - if 'status' not in data: - data['status'] = 'draft' - sor.I('voucher_template', data) - return {'status': 'success', 'data': {'id': data['id']}} +def _get_db(): + db = DBPools() + if not getattr(db, 'databases', None): + config = getConfig() + db.databases = config.databases + return db -async def update_voucher_template(conditions, data): - sor = DBPools().sqlorContext(_get_dbname()) - sor.U('voucher_template', conditions, data) - return {'status': 'success'} - - -async def delete_voucher_template(conditions): - sor = DBPools().sqlorContext(_get_dbname()) - # 级联删除关联规则 - sor.D('voucher_rule', {'template_id': conditions.get('id', '')}) - sor.D('voucher_template', conditions) - return {'status': 'success'} - - -# ========== Rule CRUD ========== - -async def create_voucher_rule(data): - sor = DBPools().sqlorContext(_get_dbname()) - data['id'] = getID() - if 'enabled' not in data: - data['enabled'] = 1 - if 'sort_order' not in data: - data['sort_order'] = 0 - sor.I('voucher_rule', data) - return {'status': 'success', 'data': {'id': data['id']}} - - -async def update_voucher_rule(conditions, data): - sor = DBPools().sqlorContext(_get_dbname()) - sor.U('voucher_rule', conditions, data) - return {'status': 'success'} - - -async def delete_voucher_rule(conditions): - sor = DBPools().sqlorContext(_get_dbname()) - sor.D('voucher_rule', conditions) - return {'status': 'success'} - - -# ========== Instance CRUD ========== - -async def create_voucher_instance(data): - from datetime import datetime, timedelta - sor = DBPools().sqlorContext(_get_dbname()) - - data['id'] = getID() - data['status'] = 'unused' - - # 从模板获取面值 - template = sor.R('voucher_template', {'id': data.get('template_id', '')}).first() - if template: - data['face_value'] = template.face_value if hasattr(template, 'face_value') else template['face_value'] - valid_days = template.valid_days if hasattr(template, 'valid_days') else template.get('valid_days', 30) - else: - valid_days = data.get('valid_days', 30) - - # 设置有效期 - now = datetime.now() - data['valid_from'] = data.get('valid_from', now.strftime('%Y-%m-%d %H:%M:%S')) - data['valid_to'] = data.get('valid_to', (now + timedelta(days=valid_days)).strftime('%Y-%m-%d %H:%M:%S')) - data['issued_at'] = now.strftime('%Y-%m-%d %H:%M:%S') - - # 生成唯一券码 - if 'code' not in data or not data['code']: - import hashlib - raw = f"{data['id']}-{data.get('customer_id', '')}-{now.timestamp()}" - data['code'] = 'VCH-' + hashlib.md5(raw.encode()).hexdigest()[:12].upper() - - sor.I('voucher_instance', data) - - # 更新模板已发放量 - if template: - tid = data.get('template_id', '') - sor.db_execute(f"UPDATE voucher_template SET issued_count = issued_count + 1 WHERE id = '{tid}'") - - return {'status': 'success', 'data': {'id': data['id'], 'code': data['code']}} - - -async def update_voucher_instance(conditions, data): - sor = DBPools().sqlorContext(_get_dbname()) - sor.U('voucher_instance', conditions, data) - return {'status': 'success'} - - -async def delete_voucher_instance(conditions): - sor = DBPools().sqlorContext(_get_dbname()) - sor.D('voucher_usage_log', {'instance_id': conditions.get('id', '')}) - sor.D('voucher_instance', conditions) - return {'status': 'success'} - - -# ========== Usage Log CRUD ========== - -async def create_voucher_usage_log(data): - sor = DBPools().sqlorContext(_get_dbname()) - data['id'] = getID() - sor.I('voucher_usage_log', data) - return {'status': 'success', 'data': {'id': data['id']}} - - -async def update_voucher_usage_log(conditions, data): - sor = DBPools().sqlorContext(_get_dbname()) - sor.U('voucher_usage_log', conditions, data) - return {'status': 'success'} - - -async def delete_voucher_usage_log(conditions): - sor = DBPools().sqlorContext(_get_dbname()) - sor.D('voucher_usage_log', conditions) - return {'status': 'success'} - - -# ========== Options API (dropdown) ========== - -async def get_voucher_template_options(): - sor = DBPools().sqlorContext(_get_dbname()) - rows = sor.R('voucher_template', {}, {'sort': 'name'}) - options = [] - for r in rows: - tid = r.id if hasattr(r, 'id') else r['id'] - name = r.name if hasattr(r, 'name') else r['name'] - options.append({'value': tid, 'text': name}) - return {'status': 'success', 'data': {'options': options}} - - -async def get_voucher_instance_options(): - sor = DBPools().sqlorContext(_get_dbname()) - rows = sor.R('voucher_instance', {}, {'sort': 'created_at desc'}) - options = [] - for r in rows: - vid = r.id if hasattr(r, 'id') else r['id'] - code = r.code if hasattr(r, 'code') else r['code'] - options.append({'value': vid, 'text': code}) - return {'status': 'success', 'data': {'options': options}} - - -# ========== Voucher Engine API ========== - -async def apply_voucher_api(customer_id, order_id, voucher_ids, context): - """外部调用:使用代金券""" - from voucher.rules.engine import batch_apply_vouchers - sor = DBPools().sqlorContext(_get_dbname()) - result = batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context) - return {'status': 'success', 'data': result} - +# ========== 客户侧 API ========== async def get_available_vouchers_api(customer_id, context=None): - """外部调用:查询可用代金券(含模板名称)""" - from voucher.rules.engine import get_available_vouchers - sor = DBPools().sqlorContext(_get_dbname()) - vouchers = get_available_vouchers(sor, customer_id, context) - # 预加载模板名称 - template_names = {} - for v in vouchers: - tid = v.template_id if hasattr(v, 'template_id') else v.get('template_id', '') - if tid and tid not in template_names: - t = sor.R('voucher_template', {'id': tid}).first() - if t: - template_names[tid] = t.name if hasattr(t, 'name') else t.get('name', '') - items = [] - for v in vouchers: - if hasattr(v, '__dict__'): - item = v.__dict__ - else: + """查询客户可用代金券(含模板名称;顺带把过期券置 expired)。""" + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + try: + await expire_vouchers(sor) + except Exception as e: + debug('get_available_vouchers_api: expire sweep skipped: {}'.format(e)) + vouchers = await get_available_vouchers(sor, customer_id, context) + template_names = {} + for v in vouchers: + tid = v.template_id or '' + if tid and tid not in template_names: + t = await sor.R('voucher_template', {'id': tid}) + template_names[tid] = (t[0].name if t else '') or '' + items = [] + for v in vouchers: item = dict(v) - tid = item.get('template_id', '') - item['template_name'] = template_names.get(tid, '') - items.append(item) - return {'status': 'success', 'data': items} + item['template_name'] = template_names.get(v.template_id or '', '') + items.append(_jsonable_instance(item)) + return {'status': 'success', 'data': items, 'total': len(items)} -async def get_registered_rule_types(): - """返回已注册的规则类型列表""" - from voucher.rules.registry import get_all_rule_types +async def apply_voucher_api(customer_id, order_id, voucher_ids, context): + """使用代金券(单张传一个 id 的列表即可)。""" + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + result = await batch_apply_vouchers(sor, customer_id, order_id, voucher_ids, context) + return {'status': 'success', 'data': result} + + +async def validate_voucher_api(instance_id, context): + """只校验不核销(下单前预检)。""" + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + ok, deductible, err = await validate_voucher(sor, instance_id, context or {}) + return {'status': 'success', 'data': { + 'ok': ok, 'deductible': float(deductible), 'error': err}} + + +async def my_vouchers_api(request, params_kw): + """客户「我的代金券」列表(本机构全部券,含已用/过期/作废;Tabular 契约 {total, rows})。""" + env = request._run_ns + org_id = (await env.get_userorgid()) or '' + if not org_id: + return {'total': 0, 'rows': []} + status = ((params_kw or {}).get('status') or '').strip() + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + try: + await expire_vouchers(sor) + except Exception as e: + debug('my_vouchers_api: expire sweep skipped: {}'.format(e)) + where = 'WHERE a.customer_id=${cid}$' + ns = {'cid': org_id} + if status: + where += ' AND a.status=${st}$' + ns['st'] = status + sql = ("SELECT a.id, a.code, a.status, a.face_value, a.actual_deducted, " + "a.valid_from, a.valid_to, a.issued_at, a.used_at, a.source, a.remark, " + "b.name as template_name " + "FROM voucher_instance a " + "LEFT JOIN voucher_template b ON a.template_id=b.id " + + where + " ORDER BY a.issued_at DESC") + recs = await sor.sqlExe(sql, ns) + rows = [_jsonable_instance(dict(r)) for r in (recs or [])] + return {'total': len(rows), 'rows': rows} + + +async def invalidate_voucher_api(request, params_kw): + """作废一张未使用的券(管理端操作;unused → void,已用/已过期/已作废拒绝)。""" + env = request._run_ns + user_id = await env.get_user() + data = dict(params_kw or {}) + vid = (data.get('id') or '').strip() + reason = (data.get('reason') or '').strip() + if not vid: + return {'success': False, 'message': '缺少 id'} + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + # 条件 UPDATE 原子作废:只有 unused 才可作废(并发防双花同款守卫) + remark_sql = "UPDATE voucher_instance SET status='void', updated_at=${now}$" + ns = {'now': now, 'vid': vid} + if reason: + remark_sql += ", remark=CONCAT(COALESCE(remark,''), ${rmark}$)" + ns['rmark'] = ' [作废:{}]'.format(reason[:200]) + remark_sql += (" WHERE id=${vid}$ AND status='unused'") + affected = await sor.execute(remark_sql, ns) + if not affected: + recs = await sor.R('voucher_instance', {'id': vid}) + if not recs: + return {'success': False, 'message': '券不存在'} + return {'success': False, + 'message': '券当前状态为 {},只有未使用的券可作废'.format(recs[0].status)} + info('voucher: {} voided by {} ({})'.format(vid, user_id, reason or 'no reason')) + return {'success': True, 'message': 'ok'} + + +# ========== 管理侧 API ========== + +async def create_voucher_template(request, params_kw): + """建模板(org_id 隔离 + code 唯一)。""" + env = request._run_ns + user_id = await env.get_user() + org_id = (await env.get_userorgid()) or '0' + data = dict(params_kw) + name = (data.get('name') or '').strip() + if not name: + return {'success': False, 'message': '模板名称必填'} + face_value = float(data.get('face_value') or 0) + if face_value <= 0: + return {'success': False, 'message': '面值必须大于 0'} + code = (data.get('code') or '').strip().upper() or ('VT-' + getID()[:12].upper()) + + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + exist = await sor.R('voucher_template', {'code': code}) + if exist: + return {'success': False, 'message': '模板编码 {} 已存在'.format(code)} + tid = getID() + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + await sor.C('voucher_template', { + 'id': tid, + 'name': name, + 'code': code, + 'face_value': face_value, + 'total_count': int(data.get('total_count') or 0), + 'issued_count': 0, + 'valid_days': int(data.get('valid_days') or 30), + 'status': data.get('status') or 'draft', + 'remark': data.get('remark') or '', + 'org_id': org_id, + 'created_by': user_id or '', + 'created_at': now, + 'updated_at': now, + }) + return {'success': True, 'message': 'ok', 'id': tid, 'code': code} + + +async def update_voucher_template(request, params_kw): + env = request._run_ns + org_id = (await env.get_userorgid()) or '0' + data = dict(params_kw) + tid = data.pop('id', None) + if not tid: + return {'success': False, 'message': '缺少 id'} + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + recs = await sor.R('voucher_template', {'id': tid}) + if not recs: + return {'success': False, 'message': '模板不存在'} + if (recs[0].org_id or '0') != org_id and org_id != '0': + return {'success': False, 'message': '无权操作该模板'} + new_code = (data.get('code') or '').strip().upper() + if new_code and new_code != (recs[0].code or ''): + return {'success': False, 'message': '模板编码不可修改(已发券按编码追溯)'} + allowed = {} + for k in ('name', 'face_value', 'total_count', 'valid_days', 'status', 'remark'): + if k in data and data[k] is not None: + allowed[k] = data[k] + if not allowed: + return {'success': False, 'message': '无可更新字段'} + allowed['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + allowed['id'] = tid + await sor.U('voucher_template', allowed) + return {'success': True, 'message': 'ok'} + + +async def delete_voucher_template(request, params_kw): + """删模板:级联删规则;已发出的实例保留(客户已持有的券不作废, + 但模板停用/删除后 validate 会拒绝使用——用「停用」代替「删除」更安全)。""" + env = request._run_ns + org_id = (await env.get_userorgid()) or '0' + data = dict(params_kw) + tid = data.get('id') + if not tid: + return {'success': False, 'message': '缺少 id'} + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + recs = await sor.R('voucher_template', {'id': tid}) + if not recs: + return {'success': False, 'message': '模板不存在'} + if (recs[0].org_id or '0') != org_id and org_id != '0': + return {'success': False, 'message': '无权操作该模板'} + issued = int(recs[0].issued_count or 0) + if issued > 0: + return {'success': False, + 'message': '模板已发放 {} 张券,不能删除;请改用「停用」'.format(issued)} + await sor.execute( + 'DELETE FROM voucher_rule WHERE template_id=${tid}$', {'tid': tid}) + await sor.D('voucher_template', {'id': tid}) + return {'success': True, 'message': 'ok'} + + +async def create_voucher_rule(request, params_kw): + """给模板加规则(rule_config 必须是合法 JSON)。""" + import json as _json + data = dict(params_kw) + template_id = data.get('template_id') + rule_type = data.get('rule_type') + if not template_id or not rule_type: + return {'success': False, 'message': 'template_id 与 rule_type 必填'} + if rule_type not in [r['rule_type'] for r in get_all_rule_types()]: + return {'success': False, 'message': '未知规则类型: {}'.format(rule_type)} + cfg_raw = data.get('rule_config') or '{}' + if isinstance(cfg_raw, dict): + cfg_str = _json.dumps(cfg_raw, ensure_ascii=False) + else: + try: + _json.loads(cfg_raw) + except (ValueError, TypeError): + return {'success': False, 'message': 'rule_config 不是合法 JSON'} + cfg_str = cfg_raw + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + t = await sor.R('voucher_template', {'id': template_id}) + if not t: + return {'success': False, 'message': '模板不存在'} + rid = getID() + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + await sor.C('voucher_rule', { + 'id': rid, + 'template_id': template_id, + 'rule_type': rule_type, + 'rule_config': cfg_str, + 'enabled': str(data.get('enabled', '1')), + 'sort_order': int(data.get('sort_order') or 0), + 'remark': data.get('remark') or '', + 'created_at': now, + 'updated_at': now, + }) + return {'success': True, 'message': 'ok', 'id': rid} + + +async def update_voucher_rule(request, params_kw): + data = dict(params_kw) + rid = data.pop('id', None) + if not rid: + return {'success': False, 'message': '缺少 id'} + allowed = {'id': rid} + for k in ('rule_type', 'rule_config', 'enabled', 'sort_order', 'remark'): + if k in data and data[k] is not None: + allowed[k] = data[k] + if len(allowed) <= 1: + return {'success': False, 'message': '无可更新字段'} + allowed['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + await sor.U('voucher_rule', allowed) + return {'success': True, 'message': 'ok'} + + +async def delete_voucher_rule(request, params_kw): + rid = (dict(params_kw)).get('id') + if not rid: + return {'success': False, 'message': '缺少 id'} + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + await sor.D('voucher_rule', {'id': rid}) + return {'success': True, 'message': 'ok'} + + +async def issue_voucher_api(request, params_kw): + """管理端手动发券:template_id/template_code 二选一 + customer_id。""" + env = request._run_ns + user_id = await env.get_user() + data = dict(params_kw) + customer_id = data.get('customer_id') + if not customer_id: + return {'success': False, 'message': 'customer_id 必填'} + template_id = data.get('template_id') + if not template_id and data.get('template_code'): + db0 = _get_db() + async with db0.sqlorContext(_get_dbname()) as sor: + recs = await sor.R('voucher_template', {'code': data['template_code']}) + template_id = recs[0].id if recs else None + if not template_id: + return {'success': False, 'message': '模板不存在'} + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + result = await issue_voucher( + sor, template_id, customer_id, + source=data.get('source') or 'manual', + issued_by=user_id or '', + remark=data.get('remark') or '') + return result + + +async def get_voucher_template_options(request, params_kw): + """模板下拉选项(启用中的模板;code 组件契约:纯 [{value,text}] 数组)。""" + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + recs = await sor.R('voucher_template', {'status': 'active', 'sort': 'name'}) + return [{'value': r.id, 'text': '{}({}元)'.format(r.name, r.face_value)} + for r in (recs or [])] + + +async def update_voucher_instance(request, params_kw): + """实例修正(管理端):仅未使用的券可改归属客户/备注。 + + 状态不走这里——使用由核销 API、作废由 invalidate_voucher_api, + 防止界面直改 status 绕过原子守卫造成账实不符。 + """ + data = dict(params_kw or {}) + vid = (data.get('id') or '').strip() + if not vid: + return {'success': False, 'message': '缺少 id'} + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + recs = await sor.R('voucher_instance', {'id': vid}) + if not recs: + return {'success': False, 'message': '券不存在'} + if (recs[0].status or '') != 'unused': + return {'success': False, + 'message': '券状态为 {},只有未使用的券可修改'.format(recs[0].status)} + # 显式拒绝危险字段变更(表单 submit_changed 只送变更字段,误改要报错而非静默丢弃) + new_tid = (data.get('template_id') or '').strip() + if new_tid and new_tid != (recs[0].template_id or ''): + return {'success': False, 'message': '券所属模板不可修改(面值/规则随模板)'} + new_src = (data.get('source') or '').strip() + if new_src and new_src != (recs[0].source or ''): + return {'success': False, 'message': '发放来源不可修改(审计字段)'} + allowed = {'id': vid} + for k in ('customer_id', 'remark'): + v = data.get(k) + if v is not None and str(v).strip() != '': + allowed[k] = v + if len(allowed) <= 1: + return {'success': False, 'message': '无可更新字段'} + allowed['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + await sor.U('voucher_instance', allowed) + return {'success': True, 'message': 'ok'} + + +async def delete_voucher_instance(request, params_kw): + """删除实例(管理端纠错):已使用的券有流水关联,禁删(审计完整性)。""" + data = dict(params_kw or {}) + vid = (data.get('id') or '').strip() + if not vid: + return {'success': False, 'message': '缺少 id'} + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + recs = await sor.R('voucher_instance', {'id': vid}) + if not recs: + return {'success': False, 'message': '券不存在'} + if (recs[0].status or '') == 'used': + return {'success': False, 'message': '券已使用(有流水关联),不能删除'} + await sor.D('voucher_instance', {'id': vid}) + # 发行额度回吐(未使用的券删除后名额释放) + await sor.execute( + "UPDATE voucher_template SET issued_count=GREATEST(issued_count-1,0) " + "WHERE id=${tid}$", {'tid': recs[0].template_id}) + return {'success': True, 'message': 'ok'} + + +async def get_registered_rule_types_api(request=None, params_kw=None): + """已注册规则类型清单(管理界面配规则用)。""" return {'status': 'success', 'data': get_all_rule_types()} +# ========== 注册送券挂钩 ========== + +async def on_organization_created(data): + """organization 表 C:after 事件 → 给新注册机构发欢迎券。 + + - 挂钩点在 sqlor 事件层(pricing/rbac 同款先例),不改 rbac 基准模块 + - 「前 N 名」由欢迎模板 total_count 原子占额控制,超发自动拒绝 + - 任何异常都只记日志(EventDispatcher continue_on_error),绝不阻断注册 + """ + ns = data if isinstance(data, dict) else {} + org_id = ns.get('id') or '' + if not org_id or org_id == '0': + return + try: + db = _get_db() + async with db.sqlorContext(_get_dbname()) as sor: + recs = await sor.R('voucher_template', {'code': WELCOME_TEMPLATE_CODE}) + if not recs: + debug('voucher: welcome template {} not found, skip'.format(WELCOME_TEMPLATE_CODE)) + return + tpl = recs[0] + if (tpl.status or '') != 'active': + return + # 幂等:同一机构不重复送(重复触发事件/重建机构时防刷) + exist = await sor.sqlExe( + "SELECT id FROM voucher_instance WHERE customer_id=${cid}$ " + "AND template_id=${tid}$ AND source='register'", + {'cid': org_id, 'tid': tpl.id}) + if exist: + debug('voucher: org {} already got welcome voucher'.format(org_id)) + return + result = await issue_voucher( + sor, tpl.id, org_id, source='register', + issued_by='system', remark='新客户注册赠券') + if result.get('success'): + info('voucher: welcome voucher {} issued to org {}'.format( + result.get('code'), org_id)) + else: + debug('voucher: welcome issue skipped for {}: {}'.format( + org_id, result.get('message'))) + except Exception as e: + exception('voucher: on_organization_created failed (non-blocking): {}'.format(e)) + + +def _bind_org_event(): + """bind organization:c: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 ========== def load_voucher(): - """注册所有函数到 ServerEnv""" + """注册所有函数到 ServerEnv + 绑定注册送券事件。""" env = ServerEnv() - # Template CRUD + # 客户侧 + env.get_available_vouchers_api = get_available_vouchers_api + env.apply_voucher_api = apply_voucher_api + env.validate_voucher_api = validate_voucher_api + env.my_vouchers_api = my_vouchers_api + + # 管理侧 env.create_voucher_template = create_voucher_template env.update_voucher_template = update_voucher_template env.delete_voucher_template = delete_voucher_template - - # Rule CRUD env.create_voucher_rule = create_voucher_rule env.update_voucher_rule = update_voucher_rule env.delete_voucher_rule = delete_voucher_rule - - # Instance CRUD - env.create_voucher_instance = create_voucher_instance + env.issue_voucher_api = issue_voucher_api + env.invalidate_voucher_api = invalidate_voucher_api env.update_voucher_instance = update_voucher_instance env.delete_voucher_instance = delete_voucher_instance - - # Usage Log CRUD - env.create_voucher_usage_log = create_voucher_usage_log - env.update_voucher_usage_log = update_voucher_usage_log - env.delete_voucher_usage_log = delete_voucher_usage_log - - # Options env.get_voucher_template_options = get_voucher_template_options - env.get_voucher_instance_options = get_voucher_instance_options + env.get_registered_rule_types = get_registered_rule_types_api - # Engine API - env.apply_voucher_api = apply_voucher_api - env.get_available_vouchers_api = get_available_vouchers_api - env.get_registered_rule_types = get_registered_rule_types - - # Import validators to trigger @register_rule decorators - import voucher.rules.validators # noqa: F401 + # 引擎级(供其他模块 import 后直接调,如 product_management 结算联动) + env.voucher_issue = issue_voucher + env.voucher_apply = apply_voucher + env.voucher_batch_apply = batch_apply_vouchers + env.voucher_get_available = get_available_vouchers + _bind_org_event() + info('voucher module loaded (v{})'.format(MODULE_VERSION)) return True diff --git a/voucher/rules/__init__.py b/voucher/rules/__init__.py new file mode 100644 index 0000000..d7a4ea3 --- /dev/null +++ b/voucher/rules/__init__.py @@ -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'] diff --git a/voucher/rules/engine.py b/voucher/rules/engine.py new file mode 100644 index 0000000..f82538a --- /dev/null +++ b/voucher/rules/engine.py @@ -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 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 (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) diff --git a/voucher/rules/validators.py b/voucher/rules/validators.py new file mode 100644 index 0000000..a041f7b --- /dev/null +++ b/voucher/rules/validators.py @@ -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 diff --git a/wwwroot/api/apply_voucher.dspy b/wwwroot/api/apply_voucher.dspy index 206ff4e..7b06ba1 100644 --- a/wwwroot/api/apply_voucher.dspy +++ b/wwwroot/api/apply_voucher.dspy @@ -1,17 +1,21 @@ -import json - -customer_id = params_kw.get('customer_id', '') -order_id = params_kw.get('order_id', '') -voucher_ids = params_kw.get('voucher_ids', []) +# apply_voucher.dspy — 核销代金券(一次性,不找零;并发安全条件 UPDATE) +customer_id = (params_kw.get('customer_id') or '').strip() +if not customer_id: + customer_id = await get_userorgid() or '' +order_id = (params_kw.get('order_id') or '').strip() +voucher_ids = params_kw.get('voucher_ids') or [] if isinstance(voucher_ids, str): - voucher_ids = json.loads(voucher_ids) - -context = { - 'request_amount': params_kw.get('request_amount', 0), - 'product_type': params_kw.get('product_type', ''), - 'product_name': params_kw.get('product_name', ''), - 'user_level': params_kw.get('user_level', 0), -} - -result = await apply_voucher_api(customer_id, order_id, voucher_ids, context) -return json.dumps(result) + try: + voucher_ids = json.loads(voucher_ids) + except (ValueError, TypeError): + voucher_ids = [s.strip() for s in voucher_ids.split(',') if s.strip()] +context = {} +for k in ('product_type', 'product_name'): + if params_kw.get(k): + context[k] = params_kw.get(k) +try: + context['request_amount'] = float(params_kw.get('request_amount') or 0) +except (ValueError, TypeError): + context['request_amount'] = 0 +context['used_by'] = await get_user() or '' +return await apply_voucher_api(customer_id, order_id, voucher_ids, context) diff --git a/wwwroot/api/get_available.dspy b/wwwroot/api/get_available.dspy index be520a2..d495bc7 100644 --- a/wwwroot/api/get_available.dspy +++ b/wwwroot/api/get_available.dspy @@ -1,13 +1,14 @@ -import json - -customer_id = params_kw.get('customer_id', '') +# get_available.dspy — 内部查询可用券(带规则预校验过滤;供结算方/调试) +customer_id = (params_kw.get('customer_id') or '').strip() +if not customer_id: + customer_id = await get_userorgid() or '' context = {} -if params_kw.get('product_type'): - context['product_type'] = params_kw.get('product_type') -if params_kw.get('product_name'): - context['product_name'] = params_kw.get('product_name') +for k in ('product_type', 'product_name'): + if params_kw.get(k): + context[k] = params_kw.get(k) if params_kw.get('request_amount'): - context['request_amount'] = params_kw.get('request_amount') - -result = await get_available_vouchers_api(customer_id, context if context else None) -return json.dumps(result) + try: + context['request_amount'] = float(params_kw.get('request_amount')) + except (ValueError, TypeError): + pass +return await get_available_vouchers_api(customer_id, context if context else None) diff --git a/wwwroot/api/instance/voucher_instance_create.dspy b/wwwroot/api/instance/voucher_instance_create.dspy deleted file mode 100644 index ff49fe8..0000000 --- a/wwwroot/api/instance/voucher_instance_create.dspy +++ /dev/null @@ -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 diff --git a/wwwroot/api/instance/voucher_instance_delete.dspy b/wwwroot/api/instance/voucher_instance_delete.dspy index 20fb070..18aa4ba 100644 --- a/wwwroot/api/instance/voucher_instance_delete.dspy +++ b/wwwroot/api/instance/voucher_instance_delete.dspy @@ -1,4 +1,5 @@ - -conditions = {'id': params_kw.get('id', '')} -result = await delete_voucher_instance(conditions) -return result +# voucher_instance_delete.dspy — 实例删除(已使用禁删保审计;未使用删除回吐发行额度) +r = await delete_voucher_instance(request, params_kw) +if r.get('success'): + return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}} +return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}} diff --git a/wwwroot/api/instance/voucher_instance_options.dspy b/wwwroot/api/instance/voucher_instance_options.dspy deleted file mode 100644 index c198214..0000000 --- a/wwwroot/api/instance/voucher_instance_options.dspy +++ /dev/null @@ -1,4 +0,0 @@ -import json - -result = await get_voucher_instance_options() -return json.dumps(result) diff --git a/wwwroot/api/instance/voucher_instance_update.dspy b/wwwroot/api/instance/voucher_instance_update.dspy index 18bcaaf..f4f3148 100644 --- a/wwwroot/api/instance/voucher_instance_update.dspy +++ b/wwwroot/api/instance/voucher_instance_update.dspy @@ -1,10 +1,5 @@ - -conditions = {'id': params_kw.get('id', '')} -data = {} -for key in ['template_id', 'customer_id', 'code', 'status', 'source', 'remark']: - val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None - if val is not None: - data[key] = val - -result = await update_voucher_instance(conditions, data) -return result +# voucher_instance_update.dspy — 实例修正(仅未使用的券可改客户/备注;状态变更走发券/核销/作废 API) +r = await update_voucher_instance(request, params_kw) +if r.get('success'): + return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}} +return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}} diff --git a/wwwroot/api/instance/voucher_invalidate.dspy b/wwwroot/api/instance/voucher_invalidate.dspy new file mode 100644 index 0000000..07b7ea8 --- /dev/null +++ b/wwwroot/api/instance/voucher_invalidate.dspy @@ -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}} diff --git a/wwwroot/api/instance/voucher_issue.dspy b/wwwroot/api/instance/voucher_issue.dspy new file mode 100644 index 0000000..a0bf0c0 --- /dev/null +++ b/wwwroot/api/instance/voucher_issue.dspy @@ -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}} diff --git a/wwwroot/api/my_vouchers.dspy b/wwwroot/api/my_vouchers.dspy new file mode 100644 index 0000000..0b78e92 --- /dev/null +++ b/wwwroot/api/my_vouchers.dspy @@ -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) diff --git a/wwwroot/api/rule/voucher_rule_create.dspy b/wwwroot/api/rule/voucher_rule_create.dspy index f17dd29..193f632 100644 --- a/wwwroot/api/rule/voucher_rule_create.dspy +++ b/wwwroot/api/rule/voucher_rule_create.dspy @@ -1,9 +1,5 @@ - -data = {} -for key in ['template_id', 'rule_type', 'rule_config', 'enabled', 'sort_order', 'remark']: - val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None - if val is not None: - data[key] = val - -result = await create_voucher_rule(data) -return result +# voucher_rule_create.dspy — 规则新增(rule_config 合法性由 helper 校验) +r = await create_voucher_rule(request, params_kw) +if r.get('success'): + return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}} +return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}} diff --git a/wwwroot/api/rule/voucher_rule_delete.dspy b/wwwroot/api/rule/voucher_rule_delete.dspy index 8175ef4..40a1628 100644 --- a/wwwroot/api/rule/voucher_rule_delete.dspy +++ b/wwwroot/api/rule/voucher_rule_delete.dspy @@ -1,4 +1,5 @@ - -conditions = {'id': params_kw.get('id', '')} -result = await delete_voucher_rule(conditions) -return result +# voucher_rule_delete.dspy — 规则删除 +r = await delete_voucher_rule(request, params_kw) +if r.get('success'): + return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}} +return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}} diff --git a/wwwroot/api/rule/voucher_rule_update.dspy b/wwwroot/api/rule/voucher_rule_update.dspy index fa43473..aa276fa 100644 --- a/wwwroot/api/rule/voucher_rule_update.dspy +++ b/wwwroot/api/rule/voucher_rule_update.dspy @@ -1,10 +1,5 @@ - -conditions = {'id': params_kw.get('id', '')} -data = {} -for key in ['template_id', 'rule_type', 'rule_config', 'enabled', 'sort_order', 'remark']: - val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None - if val is not None: - data[key] = val - -result = await update_voucher_rule(conditions, data) -return result +# voucher_rule_update.dspy — 规则更新 +r = await update_voucher_rule(request, params_kw) +if r.get('success'): + return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}} +return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}} diff --git a/wwwroot/api/rule_types.dspy b/wwwroot/api/rule_types.dspy index 45688bb..79cd2d5 100644 --- a/wwwroot/api/rule_types.dspy +++ b/wwwroot/api/rule_types.dspy @@ -1,4 +1,2 @@ -import json - -result = await get_registered_rule_types() -return json.dumps(result) +# rule_types.dspy — 已注册规则类型清单(管理界面配规则参考) +return await get_registered_rule_types(request, params_kw) diff --git a/wwwroot/api/template/voucher_template_create.dspy b/wwwroot/api/template/voucher_template_create.dspy index 64665eb..164d54b 100644 --- a/wwwroot/api/template/voucher_template_create.dspy +++ b/wwwroot/api/template/voucher_template_create.dspy @@ -1,10 +1,5 @@ - -params = params_kw if hasattr(params_kw, '__getitem__') else {} -data = {} -for key in ['name', 'code', 'face_value', 'total_count', 'valid_days', 'status', 'remark']: - val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None - if val is not None: - data[key] = val - -result = await create_voucher_template(data) -return result +# voucher_template_create.dspy — 模板新增(走模块 helper:org 隔离+code 唯一) +r = await create_voucher_template(request, params_kw) +if r.get('success'): + return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}} +return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}} diff --git a/wwwroot/api/template/voucher_template_delete.dspy b/wwwroot/api/template/voucher_template_delete.dspy index 5501416..48aabab 100644 --- a/wwwroot/api/template/voucher_template_delete.dspy +++ b/wwwroot/api/template/voucher_template_delete.dspy @@ -1,4 +1,5 @@ - -conditions = {'id': params_kw.get('id', '')} -result = await delete_voucher_template(conditions) -return result +# voucher_template_delete.dspy — 模板删除(已发放券的模板拒绝,引导用「停用」) +r = await delete_voucher_template(request, params_kw) +if r.get('success'): + return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}} +return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}} diff --git a/wwwroot/api/template/voucher_template_options.dspy b/wwwroot/api/template/voucher_template_options.dspy index f5b2292..ab7407e 100644 --- a/wwwroot/api/template/voucher_template_options.dspy +++ b/wwwroot/api/template/voucher_template_options.dspy @@ -1,4 +1,6 @@ -import json - -result = await get_voucher_template_options() -return json.dumps(result) +# voucher_template_options.dspy — 启用模板下拉(code 组件契约:纯 [{value,text}] 数组) +try: + return await get_voucher_template_options(request, params_kw) +except Exception as e: + debug('voucher_template_options error: ' + str(e)) + return [] diff --git a/wwwroot/api/template/voucher_template_update.dspy b/wwwroot/api/template/voucher_template_update.dspy index 2faf9b9..6d3757f 100644 --- a/wwwroot/api/template/voucher_template_update.dspy +++ b/wwwroot/api/template/voucher_template_update.dspy @@ -1,10 +1,5 @@ - -conditions = {'id': params_kw.get('id', '')} -data = {} -for key in ['name', 'code', 'face_value', 'total_count', 'valid_days', 'status', 'remark', 'updater']: - val = params_kw.get(key, None) if hasattr(params_kw, 'get') else None - if val is not None: - data[key] = val - -result = await update_voucher_template(conditions, data) -return result +# voucher_template_update.dspy — 模板更新 +r = await update_voucher_template(request, params_kw) +if r.get('success'): + return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}} +return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}} diff --git a/wwwroot/api/usage/voucher_usage_log_create.dspy b/wwwroot/api/usage/voucher_usage_log_create.dspy deleted file mode 100644 index a8549c6..0000000 --- a/wwwroot/api/usage/voucher_usage_log_create.dspy +++ /dev/null @@ -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 diff --git a/wwwroot/api/usage/voucher_usage_log_delete.dspy b/wwwroot/api/usage/voucher_usage_log_delete.dspy deleted file mode 100644 index 8d84db9..0000000 --- a/wwwroot/api/usage/voucher_usage_log_delete.dspy +++ /dev/null @@ -1,4 +0,0 @@ - -conditions = {'id': params_kw.get('id', '')} -result = await delete_voucher_usage_log(conditions) -return result diff --git a/wwwroot/api/usage/voucher_usage_log_update.dspy b/wwwroot/api/usage/voucher_usage_log_update.dspy deleted file mode 100644 index 8e6cfa6..0000000 --- a/wwwroot/api/usage/voucher_usage_log_update.dspy +++ /dev/null @@ -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 diff --git a/wwwroot/api/v1/available.dspy b/wwwroot/api/v1/available.dspy index c567a4e..6fbc7b9 100644 --- a/wwwroot/api/v1/available.dspy +++ b/wwwroot/api/v1/available.dspy @@ -1,9 +1,8 @@ -import json - -# 获取当前登录用户的组织ID作为客户ID +# v1/available.dspy — 客户自助查询可用券(HTTP API,登录态按机构隔离) customer_id = await get_userorgid() +if not customer_id: + return {'status': 'error', 'message': 'not logged in', 'data': [], 'total': 0} -# 构建查询上下文(可选过滤条件) context = {} if params_kw.get('product_type'): context['product_type'] = params_kw.get('product_type') @@ -15,25 +14,15 @@ if params_kw.get('request_amount'): except (ValueError, TypeError): pass -# 查询可用代金券 result = await get_available_vouchers_api(customer_id, context if context else None) - -# 格式化输出 -if result.get('status') == 'success': - vouchers = [] - for v in result.get('data', []): - vouchers.append({ - 'id': v.get('id'), - 'code': v.get('code'), - 'face_value': float(v.get('face_value', 0)), - 'valid_from': str(v.get('valid_from', '')), - 'valid_to': str(v.get('valid_to', '')), - 'template_name': v.get('template_name', ''), - }) - return json.dumps({ - 'status': 'success', - 'data': vouchers, - 'total': len(vouchers) +vouchers = [] +for v in (result.get('data') or []): + vouchers.append({ + 'id': v.get('id'), + 'code': v.get('code'), + 'face_value': float(v.get('face_value') or 0), + 'valid_from': str(v.get('valid_from') or ''), + 'valid_to': str(v.get('valid_to') or ''), + 'template_name': v.get('template_name') or '', }) -else: - return json.dumps(result) +return {'status': 'success', 'data': vouchers, 'total': len(vouchers)} diff --git a/wwwroot/index.ui b/wwwroot/index.ui index eb0783b..a160426 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -1,48 +1,159 @@ { "widgettype": "VBox", - "options": {"width": "100%", "height": "100%", "padding": "20px"}, + "options": { + "width": "100%", + "height": "100%", + "padding": "20px" + }, "subwidgets": [ - {"widgettype": "Title3", "options": {"text": "代金券管理"}}, - {"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "250px"}, - "subwidgets": [ - {"widgettype": "VBox", - "options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"}, - "binds": [{ - "wid": "self", "event": "click", "actiontype": "urlwidget", - "target": "app.voucher_content", - "options": {"url": "{{entire_url('/voucher/voucher_template_list/index.ui')}}"}, - "mode": "replace" - }], - "subwidgets": [ - {"widgettype": "Text", "options": {"text": "模板管理", "fontSize": "18px", "bold": true}}, - {"widgettype": "Text", "options": {"text": "管理代金券模板和规则配置", "fontSize": "14px", "color": "#666"}} - ]}, - {"widgettype": "VBox", - "options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"}, - "binds": [{ - "wid": "self", "event": "click", "actiontype": "urlwidget", - "target": "app.voucher_content", - "options": {"url": "{{entire_url('/voucher/voucher_instance_list/index.ui')}}"}, - "mode": "replace" - }], - "subwidgets": [ - {"widgettype": "Text", "options": {"text": "券实例管理", "fontSize": "18px", "bold": true}}, - {"widgettype": "Text", "options": {"text": "查看和发放代金券", "fontSize": "14px", "color": "#666"}} - ]}, - {"widgettype": "VBox", - "options": {"bgcolor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "cwidth": "20"}, - "binds": [{ - "wid": "self", "event": "click", "actiontype": "urlwidget", - "target": "app.voucher_content", - "options": {"url": "{{entire_url('/voucher/voucher_usage_log_list/index.ui')}}"}, - "mode": "replace" - }], - "subwidgets": [ - {"widgettype": "Text", "options": {"text": "使用流水", "fontSize": "18px", "bold": true}}, - {"widgettype": "Text", "options": {"text": "查看代金券使用记录", "fontSize": "14px", "color": "#666"}} - ]} - ]}, - {"widgettype": "VBox", "id": "voucher_content", - "options": {"width": "100%", "flex": "1", "marginTop": "20px"}} + { + "widgettype": "Title3", + "options": { + "otext": "代金券管理", + "text": "代金券管理", + "i18n": true + } + }, + { + "widgettype": "ResponsableBox", + "options": { + "gap": "16px", + "minWidth": "250px" + }, + "subwidgets": [ + { + "widgettype": "VBox", + "options": { + "css": "card", + "padding": "20px", + "cursor": "pointer", + "cwidth": 20 + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "voucher_content", + "options": { + "url": "{{entire_url('/voucher/voucher_template_list/index.ui')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Title4", + "options": { + "otext": "模板管理", + "text": "模板管理", + "i18n": true + } + }, + { + "widgettype": "Text", + "options": { + "otext": "管理代金券模板和规则配置", + "text": "管理代金券模板和规则配置", + "i18n": true, + "cfontsize": 0.9, + "color": "#666" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "css": "card", + "padding": "20px", + "cursor": "pointer", + "cwidth": 20 + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "voucher_content", + "options": { + "url": "{{entire_url('/voucher/voucher_instance_list/index.ui')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Title4", + "options": { + "otext": "券实例管理", + "text": "券实例管理", + "i18n": true + } + }, + { + "widgettype": "Text", + "options": { + "otext": "查看和发放代金券", + "text": "查看和发放代金券", + "i18n": true, + "cfontsize": 0.9, + "color": "#666" + } + } + ] + }, + { + "widgettype": "VBox", + "options": { + "css": "card", + "padding": "20px", + "cursor": "pointer", + "cwidth": 20 + }, + "binds": [ + { + "wid": "self", + "event": "click", + "actiontype": "urlwidget", + "target": "voucher_content", + "options": { + "url": "{{entire_url('/voucher/voucher_usage_log_list/index.ui')}}" + }, + "mode": "replace" + } + ], + "subwidgets": [ + { + "widgettype": "Title4", + "options": { + "otext": "使用流水", + "text": "使用流水", + "i18n": true + } + }, + { + "widgettype": "Text", + "options": { + "otext": "查看代金券使用记录", + "text": "查看代金券使用记录", + "i18n": true, + "cfontsize": 0.9, + "color": "#666" + } + } + ] + } + ] + }, + { + "widgettype": "VBox", + "id": "voucher_content", + "options": { + "width": "100%", + "flex": "1", + "marginTop": "20px" + } + } ] } diff --git a/wwwroot/invalidate_form.ui b/wwwroot/invalidate_form.ui new file mode 100644 index 0000000..154e474 --- /dev/null +++ b/wwwroot/invalidate_form.ui @@ -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({});} }" + } + ] +} diff --git a/wwwroot/issue_form.ui b/wwwroot/issue_form.ui new file mode 100644 index 0000000..90d7b64 --- /dev/null +++ b/wwwroot/issue_form.ui @@ -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({});} }" + } + ] +} diff --git a/wwwroot/menu.json b/wwwroot/menu.json deleted file mode 100644 index e29b702..0000000 --- a/wwwroot/menu.json +++ /dev/null @@ -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" - } - ] -} diff --git a/wwwroot/my/index.ui b/wwwroot/my/index.ui new file mode 100644 index 0000000..88cb203 --- /dev/null +++ b/wwwroot/my/index.ui @@ -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} + ] + } + } + } + ] +}