feat: 产品模块完整功能实现 — 资源绑定/多供应商路由/包月订购/消耗引擎/成本计算
新增模型: product_resource, product_resource_supplier, product_subscription, product_usage_log 新增API: 15个.dspy端点(资源绑定/供应商管理/订购/超额/消耗/统计) 新增UI: 4个管理界面(资源绑定/供应商关联/订购管理/消费记录) 核心逻辑: ProductManager新增 bind/unbind/subscribe/product_use/check_quota 等完整业务方法 设计文档: DESIGN.md 完整架构规范
This commit is contained in:
parent
79e8a5fa69
commit
e8860401bc
478
DESIGN.md
Normal file
478
DESIGN.md
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
# 产品模块 + 供应链模块 功能设计规范
|
||||||
|
|
||||||
|
> 版本: 1.0 | 日期: 2026-06-19
|
||||||
|
> 模块: product_management + supplychain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、总体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
平台方(owner)
|
||||||
|
│
|
||||||
|
├── product_management ← 产品目录层(定义+展示+消费)
|
||||||
|
│ ├── 产品分类树 (product_category)
|
||||||
|
│ ├── 产品注册 (product)
|
||||||
|
│ ├── 产品资源绑定 (product_resource + product_resource_supplier)
|
||||||
|
│ ├── 客户订购 (product_subscription)
|
||||||
|
│ └── 消费记录 (product_usage_log)
|
||||||
|
│
|
||||||
|
└── supplychain ← 供应链层(供应+定价+结算)
|
||||||
|
├── 供应商 (suppliers)
|
||||||
|
├── 供销关系 (platform_supply_relations)
|
||||||
|
├── 供销产品 (platform_supply_products)
|
||||||
|
├── 供应商资源定价 (supplier_resource_price) ← NEW
|
||||||
|
├── 分销协议 (distribution_agreements + items)
|
||||||
|
├── 销售记账 (sales_ledger)
|
||||||
|
└── 对账结算 (provider_reconcile / reseller_reconcile)
|
||||||
|
```
|
||||||
|
|
||||||
|
**职责边界:**
|
||||||
|
- product_management: 产品是什么、挂哪里、卖多少、客户买了什么、用了多少、成本多少
|
||||||
|
- supplychain: 谁来供货、供货价多少、分销折扣、对账结算
|
||||||
|
|
||||||
|
**数据流向:**
|
||||||
|
```
|
||||||
|
供应商资源定价(supplychain) → 产品资源绑定(product_mgmt) → 消费成本计算(product_mgmt) → 销售记账(supplychain) → 对账结算(supplychain)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、新增数据模型
|
||||||
|
|
||||||
|
### 2.1 product_management 新增表
|
||||||
|
|
||||||
|
#### product_resource (产品资源绑定表)
|
||||||
|
产品绑定的底层资源,一个产品可绑定多个资源。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | VARCHAR(32) PK | 主键 |
|
||||||
|
| product_id | VARCHAR(32) | 产品ID → product.id |
|
||||||
|
| resource_type | VARCHAR(32) | 资源类型: llm_model/llm_monthly/compute |
|
||||||
|
| resource_ref_id | VARCHAR(32) | 资源引用ID (如 llm.id) |
|
||||||
|
| resource_ref_name | VARCHAR(255) | 资源显示名(冗余,展示用) |
|
||||||
|
| quota | DOUBLE(15,4) | 配额量(包月类用), 0=不限 |
|
||||||
|
| quota_unit | VARCHAR(32) | 配额单位: tokens/requests/gpu_hours |
|
||||||
|
| priority | INT | 多供应商时的优先级(1=最高) |
|
||||||
|
| overflow_product_id | VARCHAR(32) | 超额后转用的按量产品ID |
|
||||||
|
| status | CHAR(1) | 1=启用 0=禁用 |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
| updated_at | DATETIME | |
|
||||||
|
|
||||||
|
索引: idx_pr_product(product_id), idx_pr_resource(resource_type,resource_ref_id)
|
||||||
|
|
||||||
|
#### product_resource_supplier (产品资源-供应商关联表)
|
||||||
|
同一产品资源可关联多个供应商,按优先级路由消耗。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | VARCHAR(32) PK | 主键 |
|
||||||
|
| product_resource_id | VARCHAR(32) | → product_resource.id |
|
||||||
|
| supplier_org_id | VARCHAR(32) | 供应商机构ID → organization.id |
|
||||||
|
| priority | INT | 优先级(1=最高,消耗时优先用高优先级供应商) |
|
||||||
|
| weight | INT | 权重(priority相同时按权重分配) |
|
||||||
|
| status | CHAR(1) | 1=启用 0=禁用 |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
|
||||||
|
索引: idx_prs_resource(product_resource_id), UNIQUE idx_prs_unique(product_resource_id,supplier_org_id)
|
||||||
|
|
||||||
|
#### product_subscription (客户订购表)
|
||||||
|
客户购买包月/包量产品后的订购记录。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | VARCHAR(32) PK | 主键 |
|
||||||
|
| product_id | VARCHAR(32) | → product.id |
|
||||||
|
| user_id | VARCHAR(32) | 客户用户ID |
|
||||||
|
| user_org_id | VARCHAR(32) | 客户机构ID |
|
||||||
|
| subscription_type | CHAR(1) | 1=包月 2=包量 3=一次性 |
|
||||||
|
| status | CHAR(1) | 1=活跃 2=已过期 3=已取消 4=已超额 |
|
||||||
|
| start_date | DATE | 生效日期 |
|
||||||
|
| end_date | DATE | 到期日期 |
|
||||||
|
| quota_total | DOUBLE(15,4) | 总配额 |
|
||||||
|
| quota_used | DOUBLE(15,4) | 已使用量 |
|
||||||
|
| quota_unit | VARCHAR(32) | 配额单位 |
|
||||||
|
| overflow_mode | CHAR(1) | 超额模式: 1=转按量 2=停服 |
|
||||||
|
| overflow_rate | DOUBLE(15,6) | 超额后单价(转按量时) |
|
||||||
|
| purchase_price | DOUBLE(15,2) | 购买价格 |
|
||||||
|
| purchase_currency | CHAR(8) | 货币 |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
| updated_at | DATETIME | |
|
||||||
|
|
||||||
|
索引: idx_ps_product(product_id), idx_ps_user(user_id,user_org_id), idx_ps_status(status), idx_ps_dates(start_date,end_date)
|
||||||
|
|
||||||
|
#### product_usage_log (产品消费记录表)
|
||||||
|
每次资源消耗的详细记录,含成本精确计算。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | VARCHAR(32) PK | 主键 |
|
||||||
|
| product_id | VARCHAR(32) | → product.id |
|
||||||
|
| subscription_id | VARCHAR(32) | → product_subscription.id (可空,按量产品无订购) |
|
||||||
|
| user_id | VARCHAR(32) | 消费者用户ID |
|
||||||
|
| user_org_id | VARCHAR(32) | 消费者机构ID |
|
||||||
|
| product_resource_id | VARCHAR(32) | → product_resource.id |
|
||||||
|
| supplier_org_id | VARCHAR(32) | 实际供应商机构ID |
|
||||||
|
| resource_type | VARCHAR(32) | 资源类型 |
|
||||||
|
| resource_ref_id | VARCHAR(32) | 资源引用ID |
|
||||||
|
| used_amount | DOUBLE(15,4) | 消耗量 |
|
||||||
|
| used_unit | VARCHAR(32) | 消耗单位 |
|
||||||
|
| unit_cost | DOUBLE(15,8) | 单位成本(来自supplier_resource_price) |
|
||||||
|
| total_cost | DOUBLE(15,6) | 总成本 = used_amount × unit_cost |
|
||||||
|
| sell_price | DOUBLE(15,6) | 客户侧售价 |
|
||||||
|
| billing_mode | CHAR(1) | 1=配额内 2=超额按量 |
|
||||||
|
| source_ref_table | VARCHAR(64) | 来源表(如llmusage) |
|
||||||
|
| source_ref_id | VARCHAR(32) | 来源记录ID |
|
||||||
|
| use_time | DATETIME | 消费时间 |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
|
||||||
|
索引: idx_pul_product(product_id), idx_pul_subscription(subscription_id), idx_pul_user(user_id,user_org_id), idx_pul_supplier(supplier_org_id), idx_pul_time(use_time)
|
||||||
|
|
||||||
|
### 2.2 supplychain 新增表
|
||||||
|
|
||||||
|
#### supplier_resource_price (供应商资源定价表)
|
||||||
|
供应商对各资源的基准定价,是成本计算的源头。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | VARCHAR(32) PK | 主键 |
|
||||||
|
| supplier_org_id | VARCHAR(32) | 供应商机构ID |
|
||||||
|
| resource_type | VARCHAR(32) | 资源类型 |
|
||||||
|
| resource_ref_id | VARCHAR(32) | 资源引用ID (如 llm.id) |
|
||||||
|
| resource_ref_name | VARCHAR(255) | 资源名称(冗余) |
|
||||||
|
| unit_price | DOUBLE(15,8) | 单位价格 |
|
||||||
|
| price_unit | VARCHAR(32) | 价格单位: per_1k_tokens/per_request/per_gpu_hour |
|
||||||
|
| input_price | DOUBLE(15,8) | 输入价格(LLM专用,可空) |
|
||||||
|
| output_price | DOUBLE(15,8) | 输出价格(LLM专用,可空) |
|
||||||
|
| currency | CHAR(8) | 货币 CNY/USD |
|
||||||
|
| effective_date | DATE | 生效日期 |
|
||||||
|
| expiry_date | DATE | 失效日期(可空=长期有效) |
|
||||||
|
| status | CHAR(1) | 1=有效 0=无效 |
|
||||||
|
| created_at | DATETIME | |
|
||||||
|
| updated_at | DATETIME | |
|
||||||
|
|
||||||
|
索引: idx_srp_supplier(supplier_org_id), idx_srp_resource(resource_type,resource_ref_id), idx_srp_dates(effective_date,expiry_date), UNIQUE idx_srp_unique(supplier_org_id,resource_type,resource_ref_id,effective_date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、appcodes 初始化数据
|
||||||
|
|
||||||
|
```
|
||||||
|
resource_type:
|
||||||
|
llm_model = 大模型按量
|
||||||
|
llm_monthly = 大模型包月
|
||||||
|
compute = 算力
|
||||||
|
|
||||||
|
quota_unit / price_unit:
|
||||||
|
tokens = tokens
|
||||||
|
requests = 次
|
||||||
|
gpu_hours = GPU时
|
||||||
|
per_1k_tokens = 每千tokens
|
||||||
|
per_request = 每次
|
||||||
|
per_gpu_hour = 每GPU时
|
||||||
|
|
||||||
|
subscription_status:
|
||||||
|
1 = 活跃
|
||||||
|
2 = 已过期
|
||||||
|
3 = 已取消
|
||||||
|
4 = 已超额(转按量)
|
||||||
|
|
||||||
|
overflow_mode:
|
||||||
|
1 = 转按量
|
||||||
|
2 = 停服
|
||||||
|
|
||||||
|
billing_mode:
|
||||||
|
1 = 配额内
|
||||||
|
2 = 超额按量
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、功能清单与实现逻辑
|
||||||
|
|
||||||
|
### 模块A: product_management (产品目录层)
|
||||||
|
|
||||||
|
#### A1. 产品资源绑定管理 (管理端)
|
||||||
|
|
||||||
|
| 功能 | API | 逻辑 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 查看产品的资源绑定列表 | GET /api/product_resources?product_id=X | 查 product_resource WHERE product_id=X, JOIN product_resource_supplier 获取供应商列表 |
|
||||||
|
| 为产品绑定资源 | POST /api/product_resource_bind | 插入 product_resource + 校验 product 存在且属于当前 org |
|
||||||
|
| 解绑资源 | DELETE /api/product_resource_unbind?id=X | 删除 product_resource + 级联删除 product_resource_supplier |
|
||||||
|
| 添加供应商到资源绑定 | POST /api/resource_supplier_add | 插入 product_resource_supplier, 校验 supplier_org_id 在 supplychain.suppliers 中存在 |
|
||||||
|
| 移除供应商 | DELETE /api/resource_supplier_remove?id=X | 删除 product_resource_supplier |
|
||||||
|
| 调整供应商优先级 | PUT /api/resource_supplier_priority | 更新 priority/weight |
|
||||||
|
| 设置超额产品 | PUT /api/resource_overflow | 更新 product_resource.overflow_product_id |
|
||||||
|
|
||||||
|
#### A2. 客户订购 (管理端创建 + 客户端查看)
|
||||||
|
|
||||||
|
| 功能 | API | 逻辑 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 创建订购(客户购买包月产品) | POST /api/subscribe | 1.校验产品存在+状态+日期范围 2.读取product_resource获取quota 3.插入product_subscription(status=1) 4.设置overflow_rate(从超额产品的price取) |
|
||||||
|
| 查看订购列表(管理端) | GET /api/subscriptions | 查 product_subscription, 可按user_id/product_id/status筛选 |
|
||||||
|
| 查看我的订购(客户端) | GET /api/my_subscriptions | 查 product_subscription WHERE user_id=当前用户 |
|
||||||
|
| 订购详情 | GET /api/subscription_detail?id=X | 返回订购信息 + 配额使用百分比 + 关联产品信息 |
|
||||||
|
| 取消订购 | PUT /api/subscription_cancel?id=X | status改为3, 停止配额使用 |
|
||||||
|
|
||||||
|
#### A3. 产品消费 (核心引擎)
|
||||||
|
|
||||||
|
| 功能 | API | 逻辑 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 消费资源 | POST /api/product_use | **核心流程(见下方详细逻辑)** |
|
||||||
|
| 查看消费记录 | GET /api/usage_logs | 查 product_usage_log, 支持按product_id/user_id/日期/供应商筛选 |
|
||||||
|
| 消费统计 | GET /api/usage_stats | 聚合查询: 按产品/供应商/日期汇总消耗量和成本 |
|
||||||
|
| 配额检查 | GET /api/quota_check?subscription_id=X | 返回 quota_total/quota_used/remaining/percentage |
|
||||||
|
|
||||||
|
**product_use 核心流程:**
|
||||||
|
```
|
||||||
|
1. 接收参数: product_id, user_id, user_org_id, used_amount, used_unit,
|
||||||
|
source_ref_table, source_ref_id, resource_ref_id
|
||||||
|
|
||||||
|
2. 查找产品: product WHERE id=product_id AND status='1'
|
||||||
|
|
||||||
|
3. 判断产品类型:
|
||||||
|
IF product_type 是包月类(llm_monthly):
|
||||||
|
a. 查找活跃订购: product_subscription WHERE product_id AND user_id
|
||||||
|
AND status='1' AND start_date<=today AND end_date>=today
|
||||||
|
b. 如果有活跃订购:
|
||||||
|
- 计算剩余配额 = quota_total - quota_used
|
||||||
|
- IF used_amount <= 剩余:
|
||||||
|
billing_mode = '1'(配额内)
|
||||||
|
unit_cost = 0 (配额内不计单次成本)
|
||||||
|
更新 quota_used += used_amount
|
||||||
|
- ELSE (超额):
|
||||||
|
先用完配额: quota_used = quota_total
|
||||||
|
超额部分 = used_amount - 剩余
|
||||||
|
billing_mode = '2'(超额按量)
|
||||||
|
查找 overflow_product 获取 overflow_rate
|
||||||
|
sell_price = 超额部分 × overflow_rate
|
||||||
|
subscription.status = '4'(已超额)
|
||||||
|
c. 如果无活跃订购(已过期/未订购):
|
||||||
|
billing_mode = '2'
|
||||||
|
按产品自身 price 计算
|
||||||
|
|
||||||
|
ELSE (按量产品):
|
||||||
|
billing_mode = '2'
|
||||||
|
直接按量计费
|
||||||
|
|
||||||
|
4. 路由供应商(确定成本来源):
|
||||||
|
查找 product_resource WHERE product_id AND resource_ref_id
|
||||||
|
查找 product_resource_supplier WHERE product_resource_id AND status='1'
|
||||||
|
ORDER BY priority ASC, weight DESC
|
||||||
|
选择第一个可用供应商 → supplier_org_id
|
||||||
|
|
||||||
|
5. 计算成本:
|
||||||
|
查找 supplier_resource_price WHERE supplier_org_id AND resource_ref_id
|
||||||
|
AND effective_date<=today AND (expiry_date IS NULL OR expiry_date>=today)
|
||||||
|
unit_cost = supplier_resource_price.unit_price (或 input_price/output_price)
|
||||||
|
total_cost = used_amount × unit_cost
|
||||||
|
|
||||||
|
6. 计算售价(给客户的计费):
|
||||||
|
按量产品: sell_price = used_amount × product.price
|
||||||
|
包月超额: sell_price = 超额部分 × subscription.overflow_rate
|
||||||
|
|
||||||
|
7. 写入 product_usage_log
|
||||||
|
|
||||||
|
8. 返回: {success, billing_mode, used_amount, total_cost, sell_price,
|
||||||
|
supplier_org_id, remaining_quota}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A4. 产品目录展示 (面向客户端)
|
||||||
|
|
||||||
|
| 功能 | API | 逻辑 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 分类树(客户端) | GET /api/public_categories | 现有 get_category_tree, 只返回 status='1' AND has_product='1' 的叶子节点 |
|
||||||
|
| 分类下产品列表 | GET /api/public_products?category_id=X | 现有 get_products_by_category, 返回产品摘要+价格+是否有资源绑定 |
|
||||||
|
| 产品详情 | GET /api/public_product_detail?id=X | 现有 get_product_detail, 增加资源绑定信息(绑定了几种资源,各多少配额) |
|
||||||
|
|
||||||
|
### 模块B: supplychain (供应链层)
|
||||||
|
|
||||||
|
#### B1. 供应商资源定价管理
|
||||||
|
|
||||||
|
| 功能 | API | 逻辑 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 查看供应商资源定价列表 | GET /api/supplier_resource_prices | 查 supplier_resource_price, 可按supplier_org_id/resource_type筛选 |
|
||||||
|
| 创建/更新定价 | POST /api/supplier_resource_price_save | UPSERT supplier_resource_price, 校验supplier存在 |
|
||||||
|
| 删除定价(置为无效) | DELETE /api/supplier_resource_price_disable?id=X | status改为'0' |
|
||||||
|
| 批量导入定价 | POST /api/supplier_resource_price_import | 接收JSON数组,批量插入 |
|
||||||
|
| 查询指定资源的供应商价格 | GET /api/resource_supplier_cost?resource_type=X&resource_ref_id=Y | 返回所有供应商对该资源的定价,用于成本比较 |
|
||||||
|
|
||||||
|
#### B2. 消费成本对账 (连接 product_management 和 supplychain)
|
||||||
|
|
||||||
|
| 功能 | API | 逻辑 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 按供应商汇总成本 | GET /api/cost_by_supplier | 跨库JOIN: product_usage_log GROUP BY supplier_org_id, SUM(total_cost) |
|
||||||
|
| 按产品汇总成本 | GET /api/cost_by_product | product_usage_log GROUP BY product_id |
|
||||||
|
| 生成销售记账条目 | POST /api/generate_sales_ledger | 从 product_usage_log 汇总后写入 sales_ledger (周期任务或手动触发) |
|
||||||
|
| 成本趋势 | GET /api/cost_trend | 按日期汇总成本, 最近30天 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、业务流程推演
|
||||||
|
|
||||||
|
### 场景1: 大模型按量产品
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 供应商(OpenAI代理商)在 supplychain 注册,
|
||||||
|
在 supplier_resource_price 设置 GPT-4 价格:
|
||||||
|
input_price=0.03/1Ktokens, output_price=0.06/1Ktokens
|
||||||
|
|
||||||
|
2. 平台在 product_management 创建产品:
|
||||||
|
product: "GPT-4智能对话" product_type=llm_model price=0.05/1Ktokens
|
||||||
|
|
||||||
|
3. 产品绑定资源:
|
||||||
|
product_resource: resource_type=llm_model, resource_ref_id=llm.id(GPT-4)
|
||||||
|
product_resource_supplier: supplier_org_id=OpenAI代理商, priority=1
|
||||||
|
|
||||||
|
4. 客户调用GPT-4, 消耗2000 tokens:
|
||||||
|
product_use → 路由到OpenAI代理商
|
||||||
|
unit_cost = 0.03/1K × 2 = 0.06 (成本)
|
||||||
|
sell_price = 0.05/1K × 2 = 0.10 (售价)
|
||||||
|
写入 product_usage_log
|
||||||
|
|
||||||
|
5. 月末对账:
|
||||||
|
supplychain 从 product_usage_log 汇总:
|
||||||
|
供应商: OpenAI代理商, 总成本 = SUM(total_cost)
|
||||||
|
→ 写入 sales_ledger → 供应商结算
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景2: 大模型包月产品 + 超额转按量
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 平台创建包月产品:
|
||||||
|
product: "AI助手月度套餐" product_type=llm_monthly price=99.00/月
|
||||||
|
|
||||||
|
2. 产品绑定资源:
|
||||||
|
product_resource: resource_type=llm_monthly, resource_ref_id=llm.id,
|
||||||
|
quota=1000000(100万tokens), quota_unit=tokens,
|
||||||
|
overflow_product_id=指向"GPT-4按量产品"
|
||||||
|
|
||||||
|
3. 绑定两个供应商(同资源多供应商):
|
||||||
|
product_resource_supplier:
|
||||||
|
- supplier_A: priority=1, weight=70
|
||||||
|
- supplier_B: priority=1, weight=30
|
||||||
|
|
||||||
|
4. 客户购买:
|
||||||
|
product_subscription: quota_total=1000000, start=2026-06-01, end=2026-06-30
|
||||||
|
|
||||||
|
5. 客户使用中(配额内):
|
||||||
|
每次调用 → billing_mode='1', 更新 quota_used
|
||||||
|
按 weight 比例分配供应商: 70%走A, 30%走B
|
||||||
|
成本分别按各供应商的 supplier_resource_price 计算
|
||||||
|
|
||||||
|
6. 配额用完(第25天用完100万tokens):
|
||||||
|
subscription.status → '4'(已超额)
|
||||||
|
后续调用 → billing_mode='2'
|
||||||
|
sell_price = 超额量 × overflow_rate(从overflow_product的price取)
|
||||||
|
total_cost = 超额量 × supplier_unit_price(实时成本)
|
||||||
|
|
||||||
|
7. 月末: 订购到期 → 定时任务将 status='1' 且 end_date<today 的改为 '2'(已过期)
|
||||||
|
客户可选择续费(创建新subscription)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景3: 算力产品
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 供应商设置GPU定价:
|
||||||
|
supplier_resource_price: resource_type=compute, resource_ref_id=gpu_a100,
|
||||||
|
unit_price=8.00/gpu_hour
|
||||||
|
|
||||||
|
2. 平台创建算力产品:
|
||||||
|
product: "A100 GPU租用" product_type=compute, price=12.00/gpu_hour
|
||||||
|
|
||||||
|
3. 客户购买后使用 10 GPU时:
|
||||||
|
total_cost = 10 × 8.00 = 80.00
|
||||||
|
sell_price = 10 × 12.00 = 120.00
|
||||||
|
profit = 40.00
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景4: 分销商定价
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 平台(owner)创建产品和资源绑定
|
||||||
|
|
||||||
|
2. 分销商通过 supplychain.distribution_agreement_items 设置自己的折扣:
|
||||||
|
discount=0.85 → 客户按 85折 购买
|
||||||
|
sale_price = product.price × 0.85
|
||||||
|
|
||||||
|
3. 客户通过分销商购买:
|
||||||
|
售价 = product.price × distribution_discount
|
||||||
|
分销商成本 = product.price × supply_discount (从supplychain取)
|
||||||
|
利润 = 售价 - 成本
|
||||||
|
|
||||||
|
4. 对账时:
|
||||||
|
sales_ledger 记录 supply_amount(进货成本) + distribution_amount(分销收入) + profit_amount
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、UI 页面规划
|
||||||
|
|
||||||
|
### product_management (管理端)
|
||||||
|
| 页面 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| product_resource_list/index.ui | 选择产品 → 显示/管理该产品的资源绑定 |
|
||||||
|
| product_resource_list/bind_resource.ui | 绑定资源弹窗(选资源类型+引用+配额) |
|
||||||
|
| product_resource_list/supplier_bind.ui | 为资源绑定添加/管理供应商 |
|
||||||
|
| subscription_list/index.ui | 订购列表(筛选产品/用户/状态) |
|
||||||
|
| subscription_detail.ui | 订购详情(配额进度条+消费记录) |
|
||||||
|
|
||||||
|
### product_management (客户端/展示端)
|
||||||
|
| 页面 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| (复用现有) index.ui → category树 → product列表 | 展示产品目录 |
|
||||||
|
| product_detail.ui | 产品详情(含资源信息+价格+购买按钮) |
|
||||||
|
| my_subscriptions.ui | 我的订购(配额进度+消费记录) |
|
||||||
|
|
||||||
|
### supplychain (管理端)
|
||||||
|
| 页面 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| supplier_resource_price_list/index.ui | 供应商资源定价CRUD |
|
||||||
|
| cost_dashboard.ui | 成本分析看板(按供应商/产品/日期) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、跨模块数据访问
|
||||||
|
|
||||||
|
| 场景 | 读 | 写 |
|
||||||
|
|------|-----|-----|
|
||||||
|
| product_use计算成本 | supplychain.supplier_resource_price | product_management.product_usage_log, product_subscription |
|
||||||
|
| 供应商定价查询 | supplychain.supplier_resource_price | - |
|
||||||
|
| 生成sales_ledger | product_management.product_usage_log | supplychain.sales_ledger |
|
||||||
|
| 供应商列表(绑定供应商时) | supplychain.suppliers | - |
|
||||||
|
| LLM模型列表(绑定资源时) | llmage.llm | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、定时任务
|
||||||
|
|
||||||
|
| 任务 | 周期 | 逻辑 |
|
||||||
|
|------|------|------|
|
||||||
|
| 订购过期检查 | 每日00:05 | product_subscription WHERE status='1' AND end_date<today → status='2' |
|
||||||
|
| 成本汇总入账 | 每月1日 | 上月 product_usage_log 汇总 → 写入 sales_ledger |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、检查清单
|
||||||
|
|
||||||
|
- [ ] product_resource model.json + DDL
|
||||||
|
- [ ] product_resource_supplier model.json + DDL
|
||||||
|
- [ ] product_subscription model.json + DDL
|
||||||
|
- [ ] product_usage_log model.json + DDL
|
||||||
|
- [ ] supplier_resource_price model.json + DDL (supplychain)
|
||||||
|
- [ ] appcodes init data 更新
|
||||||
|
- [ ] 资源绑定 CRUD APIs (6个)
|
||||||
|
- [ ] 订购 APIs (5个)
|
||||||
|
- [ ] 消费引擎 product_use (核心)
|
||||||
|
- [ ] 消费记录查询/统计 APIs (3个)
|
||||||
|
- [ ] 供应商资源定价 CRUD APIs (5个)
|
||||||
|
- [ ] 成本对账 APIs (4个)
|
||||||
|
- [ ] 管理端 UI (5个页面)
|
||||||
|
- [ ] 客户端 UI (2个页面)
|
||||||
|
- [ ] 供应商定价 UI (2个页面)
|
||||||
|
- [ ] 定时任务 (2个)
|
||||||
|
- [ ] 集成测试: 按量产品完整流程
|
||||||
|
- [ ] 集成测试: 包月产品完整流程(含超额转按量)
|
||||||
|
- [ ] 集成测试: 多供应商路由
|
||||||
|
- [ ] 集成测试: 分销商折扣
|
||||||
@ -29,6 +29,41 @@
|
|||||||
"id": "enabled_flg",
|
"id": "enabled_flg",
|
||||||
"name": "是否启用",
|
"name": "是否启用",
|
||||||
"hierarchy_flg": "0"
|
"hierarchy_flg": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "resource_type",
|
||||||
|
"name": "资源类型",
|
||||||
|
"hierarchy_flg": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quota_unit",
|
||||||
|
"name": "配额单位",
|
||||||
|
"hierarchy_flg": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "price_unit",
|
||||||
|
"name": "价格单位",
|
||||||
|
"hierarchy_flg": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "subscription_type",
|
||||||
|
"name": "订购类型",
|
||||||
|
"hierarchy_flg": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "subscription_status",
|
||||||
|
"name": "订购状态",
|
||||||
|
"hierarchy_flg": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "overflow_mode",
|
||||||
|
"name": "超额模式",
|
||||||
|
"hierarchy_flg": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "billing_mode",
|
||||||
|
"name": "计费模式",
|
||||||
|
"hierarchy_flg": "0"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"appcodes_kv": [
|
"appcodes_kv": [
|
||||||
@ -50,9 +85,39 @@
|
|||||||
{"id": "product_type_data", "parentid": "product_type", "k": "data", "v": "数据服务"},
|
{"id": "product_type_data", "parentid": "product_type", "k": "data", "v": "数据服务"},
|
||||||
{"id": "product_type_api", "parentid": "product_type", "k": "api", "v": "API服务"},
|
{"id": "product_type_api", "parentid": "product_type", "k": "api", "v": "API服务"},
|
||||||
{"id": "product_type_custom", "parentid": "product_type", "k": "custom", "v": "自定义"},
|
{"id": "product_type_custom", "parentid": "product_type", "k": "custom", "v": "自定义"},
|
||||||
|
{"id": "product_type_llm_model", "parentid": "product_type", "k": "llm_model", "v": "大模型按量"},
|
||||||
|
{"id": "product_type_llm_monthly", "parentid": "product_type", "k": "llm_monthly", "v": "大模型包月"},
|
||||||
|
{"id": "product_type_compute", "parentid": "product_type", "k": "compute", "v": "算力"},
|
||||||
|
|
||||||
{"id": "enabled_flg_1", "parentid": "enabled_flg", "k": "1", "v": "启用"},
|
{"id": "enabled_flg_1", "parentid": "enabled_flg", "k": "1", "v": "启用"},
|
||||||
{"id": "enabled_flg_0", "parentid": "enabled_flg", "k": "0", "v": "禁用"}
|
{"id": "enabled_flg_0", "parentid": "enabled_flg", "k": "0", "v": "禁用"},
|
||||||
|
|
||||||
|
{"id": "resource_type_llm_model", "parentid": "resource_type", "k": "llm_model", "v": "大模型按量"},
|
||||||
|
{"id": "resource_type_llm_monthly", "parentid": "resource_type", "k": "llm_monthly", "v": "大模型包月"},
|
||||||
|
{"id": "resource_type_compute", "parentid": "resource_type", "k": "compute", "v": "算力"},
|
||||||
|
|
||||||
|
{"id": "quota_unit_tokens", "parentid": "quota_unit", "k": "tokens", "v": "tokens"},
|
||||||
|
{"id": "quota_unit_requests", "parentid": "quota_unit", "k": "requests", "v": "次"},
|
||||||
|
{"id": "quota_unit_gpu_hours", "parentid": "quota_unit", "k": "gpu_hours", "v": "GPU时"},
|
||||||
|
|
||||||
|
{"id": "price_unit_per_1k_tokens", "parentid": "price_unit", "k": "per_1k_tokens", "v": "每千tokens"},
|
||||||
|
{"id": "price_unit_per_request", "parentid": "price_unit", "k": "per_request", "v": "每次"},
|
||||||
|
{"id": "price_unit_per_gpu_hour", "parentid": "price_unit", "k": "per_gpu_hour", "v": "每GPU时"},
|
||||||
|
|
||||||
|
{"id": "subscription_type_1", "parentid": "subscription_type", "k": "1", "v": "包月"},
|
||||||
|
{"id": "subscription_type_2", "parentid": "subscription_type", "k": "2", "v": "包量"},
|
||||||
|
{"id": "subscription_type_3", "parentid": "subscription_type", "k": "3", "v": "一次性"},
|
||||||
|
|
||||||
|
{"id": "subscription_status_1", "parentid": "subscription_status", "k": "1", "v": "活跃"},
|
||||||
|
{"id": "subscription_status_2", "parentid": "subscription_status", "k": "2", "v": "已过期"},
|
||||||
|
{"id": "subscription_status_3", "parentid": "subscription_status", "k": "3", "v": "已取消"},
|
||||||
|
{"id": "subscription_status_4", "parentid": "subscription_status", "k": "4", "v": "已超额"},
|
||||||
|
|
||||||
|
{"id": "overflow_mode_1", "parentid": "overflow_mode", "k": "1", "v": "转按量"},
|
||||||
|
{"id": "overflow_mode_2", "parentid": "overflow_mode", "k": "2", "v": "停服"},
|
||||||
|
|
||||||
|
{"id": "billing_mode_1", "parentid": "billing_mode", "k": "1", "v": "配额内"},
|
||||||
|
{"id": "billing_mode_2", "parentid": "billing_mode", "k": "2", "v": "超额按量"}
|
||||||
],
|
],
|
||||||
"_note_product_category": "产品类别树由每个 reseller (org_id) 自行管理,不在 init/data.json 中预设全局数据。新机构注册时自动创建根类别。"
|
"_note_product_category": "产品类别树由每个 reseller (org_id) 自行管理,不在 init/data.json 中预设全局数据。新机构注册时自动创建根类别。"
|
||||||
}
|
}
|
||||||
|
|||||||
7
json/product_resource_list.json
Normal file
7
json/product_resource_list.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"product_resource": {
|
||||||
|
"params": {
|
||||||
|
"product_id": {"type": "str"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
json/product_resource_supplier_list.json
Normal file
7
json/product_resource_supplier_list.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"product_resource_supplier": {
|
||||||
|
"params": {
|
||||||
|
"product_resource_id": {"type": "str"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
json/product_subscription_list.json
Normal file
10
json/product_subscription_list.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"product_subscription": {
|
||||||
|
"params": {
|
||||||
|
"product_id": {"type": "str"},
|
||||||
|
"user_id": {"type": "str"},
|
||||||
|
"user_org_id": {"type": "str"},
|
||||||
|
"status": {"type": "str"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
json/product_usage_log_list.json
Normal file
13
json/product_usage_log_list.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"product_usage_log": {
|
||||||
|
"params": {
|
||||||
|
"product_id": {"type": "str"},
|
||||||
|
"subscription_id": {"type": "str"},
|
||||||
|
"user_id": {"type": "str"},
|
||||||
|
"supplier_org_id": {"type": "str"},
|
||||||
|
"billing_mode": {"type": "str"},
|
||||||
|
"start_date": {"type": "str"},
|
||||||
|
"end_date": {"type": "str"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
models/product_resource.json
Normal file
58
models/product_resource.json
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"summary": [
|
||||||
|
{
|
||||||
|
"name": "product_resource",
|
||||||
|
"title": "产品资源绑定表",
|
||||||
|
"primary": ["id"],
|
||||||
|
"catelog": "relation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "product_id", "title": "产品ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "resource_type", "title": "资源类型", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "resource_ref_id", "title": "资源引用ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "resource_ref_name", "title": "资源显示名", "type": "str", "length": 255},
|
||||||
|
{"name": "quota", "title": "配额量", "type": "double", "length": 15, "dec": 4, "default": "0"},
|
||||||
|
{"name": "quota_unit", "title": "配额单位", "type": "str", "length": 32},
|
||||||
|
{"name": "priority", "title": "优先级", "type": "int", "default": "1"},
|
||||||
|
{"name": "overflow_product_id", "title": "超额后转用产品ID", "type": "str", "length": 32},
|
||||||
|
{"name": "status", "title": "状态", "type": "char", "length": 1, "default": "1"},
|
||||||
|
{"name": "created_at", "title": "创建时间", "type": "datetime", "nullable": "no"},
|
||||||
|
{"name": "updated_at", "title": "更新时间", "type": "datetime"}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
{"name": "idx_pr_product", "idxtype": "index", "idxfields": ["product_id"]},
|
||||||
|
{"name": "idx_pr_resource", "idxtype": "index", "idxfields": ["resource_type", "resource_ref_id"]},
|
||||||
|
{"name": "idx_pr_status", "idxtype": "index", "idxfields": ["status"]}
|
||||||
|
],
|
||||||
|
"codes": [
|
||||||
|
{
|
||||||
|
"field": "product_id",
|
||||||
|
"table": "product",
|
||||||
|
"valuefield": "id",
|
||||||
|
"textfield": "product_name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "resource_type",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='resource_type'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "quota_unit",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='quota_unit'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "status",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='product_status'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
45
models/product_resource_supplier.json
Normal file
45
models/product_resource_supplier.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"summary": [
|
||||||
|
{
|
||||||
|
"name": "product_resource_supplier",
|
||||||
|
"title": "产品资源供应商关联表",
|
||||||
|
"primary": ["id"],
|
||||||
|
"catelog": "relation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "product_resource_id", "title": "产品资源绑定ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "supplier_org_id", "title": "供应商机构ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "priority", "title": "优先级", "type": "int", "default": "1"},
|
||||||
|
{"name": "weight", "title": "权重", "type": "int", "default": "100"},
|
||||||
|
{"name": "status", "title": "状态", "type": "char", "length": 1, "default": "1"},
|
||||||
|
{"name": "created_at", "title": "创建时间", "type": "datetime", "nullable": "no"}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
{"name": "idx_prs_resource", "idxtype": "index", "idxfields": ["product_resource_id"]},
|
||||||
|
{"name": "idx_prs_supplier", "idxtype": "index", "idxfields": ["supplier_org_id"]},
|
||||||
|
{"name": "idx_prs_unique", "idxtype": "unique", "idxfields": ["product_resource_id", "supplier_org_id"]}
|
||||||
|
],
|
||||||
|
"codes": [
|
||||||
|
{
|
||||||
|
"field": "product_resource_id",
|
||||||
|
"table": "product_resource",
|
||||||
|
"valuefield": "id",
|
||||||
|
"textfield": "resource_ref_name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "supplier_org_id",
|
||||||
|
"table": "supplychain.suppliers",
|
||||||
|
"valuefield": "org_id",
|
||||||
|
"textfield": "supplier_name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "status",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='product_status'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
83
models/product_subscription.json
Normal file
83
models/product_subscription.json
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"summary": [
|
||||||
|
{
|
||||||
|
"name": "product_subscription",
|
||||||
|
"title": "客户订购表",
|
||||||
|
"primary": ["id"],
|
||||||
|
"catelog": "relation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "product_id", "title": "产品ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "user_id", "title": "客户用户ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "user_org_id", "title": "客户机构ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "subscription_type", "title": "订购类型", "type": "char", "length": 1, "nullable": "no"},
|
||||||
|
{"name": "status", "title": "状态", "type": "char", "length": 1, "nullable": "no", "default": "1"},
|
||||||
|
{"name": "start_date", "title": "生效日期", "type": "date", "nullable": "no"},
|
||||||
|
{"name": "end_date", "title": "到期日期", "type": "date", "nullable": "no"},
|
||||||
|
{"name": "quota_total", "title": "总配额", "type": "double", "length": 15, "dec": 4, "default": "0"},
|
||||||
|
{"name": "quota_used", "title": "已使用量", "type": "double", "length": 15, "dec": 4, "default": "0"},
|
||||||
|
{"name": "quota_unit", "title": "配额单位", "type": "str", "length": 32},
|
||||||
|
{"name": "overflow_mode", "title": "超额模式", "type": "char", "length": 1, "default": "1"},
|
||||||
|
{"name": "overflow_rate", "title": "超额单价", "type": "double", "length": 15, "dec": 6, "default": "0"},
|
||||||
|
{"name": "purchase_price", "title": "购买价格", "type": "double", "length": 15, "dec": 2, "default": "0"},
|
||||||
|
{"name": "purchase_currency", "title": "货币", "type": "char", "length": 8, "default": "CNY"},
|
||||||
|
{"name": "created_at", "title": "创建时间", "type": "datetime", "nullable": "no"},
|
||||||
|
{"name": "updated_at", "title": "更新时间", "type": "datetime"}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
{"name": "idx_ps_product", "idxtype": "index", "idxfields": ["product_id"]},
|
||||||
|
{"name": "idx_ps_user", "idxtype": "index", "idxfields": ["user_id", "user_org_id"]},
|
||||||
|
{"name": "idx_ps_status", "idxtype": "index", "idxfields": ["status"]},
|
||||||
|
{"name": "idx_ps_dates", "idxtype": "index", "idxfields": ["start_date", "end_date"]}
|
||||||
|
],
|
||||||
|
"codes": [
|
||||||
|
{
|
||||||
|
"field": "product_id",
|
||||||
|
"table": "product",
|
||||||
|
"valuefield": "id",
|
||||||
|
"textfield": "product_name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "user_id",
|
||||||
|
"table": "sage.users",
|
||||||
|
"valuefield": "id",
|
||||||
|
"textfield": "username"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "user_org_id",
|
||||||
|
"table": "sage.organization",
|
||||||
|
"valuefield": "id",
|
||||||
|
"textfield": "orgname"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "subscription_type",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='subscription_type'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "status",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='subscription_status'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "overflow_mode",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='overflow_mode'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "quota_unit",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='quota_unit'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
67
models/product_usage_log.json
Normal file
67
models/product_usage_log.json
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"summary": [
|
||||||
|
{
|
||||||
|
"name": "product_usage_log",
|
||||||
|
"title": "产品消费记录表",
|
||||||
|
"primary": ["id"],
|
||||||
|
"catelog": "relation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "product_id", "title": "产品ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "subscription_id", "title": "订购ID", "type": "str", "length": 32},
|
||||||
|
{"name": "user_id", "title": "消费者用户ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "user_org_id", "title": "消费者机构ID", "type": "str", "length": 32, "nullable": "no"},
|
||||||
|
{"name": "product_resource_id", "title": "产品资源绑定ID", "type": "str", "length": 32},
|
||||||
|
{"name": "supplier_org_id", "title": "供应商机构ID", "type": "str", "length": 32},
|
||||||
|
{"name": "resource_type", "title": "资源类型", "type": "str", "length": 32},
|
||||||
|
{"name": "resource_ref_id", "title": "资源引用ID", "type": "str", "length": 32},
|
||||||
|
{"name": "used_amount", "title": "消耗量", "type": "double", "length": 15, "dec": 4, "nullable": "no"},
|
||||||
|
{"name": "used_unit", "title": "消耗单位", "type": "str", "length": 32},
|
||||||
|
{"name": "unit_cost", "title": "单位成本", "type": "double", "length": 15, "dec": 8, "default": "0"},
|
||||||
|
{"name": "total_cost", "title": "总成本", "type": "double", "length": 15, "dec": 6, "default": "0"},
|
||||||
|
{"name": "sell_price", "title": "客户售价", "type": "double", "length": 15, "dec": 6, "default": "0"},
|
||||||
|
{"name": "billing_mode", "title": "计费模式", "type": "char", "length": 1, "nullable": "no"},
|
||||||
|
{"name": "source_ref_table", "title": "来源表", "type": "str", "length": 64},
|
||||||
|
{"name": "source_ref_id", "title": "来源记录ID", "type": "str", "length": 32},
|
||||||
|
{"name": "use_time", "title": "消费时间", "type": "datetime", "nullable": "no"},
|
||||||
|
{"name": "created_at", "title": "创建时间", "type": "datetime", "nullable": "no"}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
{"name": "idx_pul_product", "idxtype": "index", "idxfields": ["product_id"]},
|
||||||
|
{"name": "idx_pul_subscription", "idxtype": "index", "idxfields": ["subscription_id"]},
|
||||||
|
{"name": "idx_pul_user", "idxtype": "index", "idxfields": ["user_id", "user_org_id"]},
|
||||||
|
{"name": "idx_pul_supplier", "idxtype": "index", "idxfields": ["supplier_org_id"]},
|
||||||
|
{"name": "idx_pul_time", "idxtype": "index", "idxfields": ["use_time"]},
|
||||||
|
{"name": "idx_pul_source", "idxtype": "index", "idxfields": ["source_ref_table", "source_ref_id"]}
|
||||||
|
],
|
||||||
|
"codes": [
|
||||||
|
{
|
||||||
|
"field": "product_id",
|
||||||
|
"table": "product",
|
||||||
|
"valuefield": "id",
|
||||||
|
"textfield": "product_name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "supplier_org_id",
|
||||||
|
"table": "supplychain.suppliers",
|
||||||
|
"valuefield": "org_id",
|
||||||
|
"textfield": "supplier_name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "resource_type",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='resource_type'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "billing_mode",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='billing_mode'"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
127
mysql.ddl.sql
127
mysql.ddl.sql
@ -128,3 +128,130 @@ CREATE INDEX product_type_config_idx_ptc_org_opr ON product_type_config(org_id,
|
|||||||
CREATE UNIQUE INDEX product_type_config_idx_ptc_org_cat ON product_type_config(org_id,operator_id,category_id,config_name);
|
CREATE UNIQUE INDEX product_type_config_idx_ptc_org_cat ON product_type_config(org_id,operator_id,category_id,config_name);
|
||||||
CREATE INDEX product_type_config_idx_ptc_enabled ON product_type_config(enabled_flg);
|
CREATE INDEX product_type_config_idx_ptc_enabled ON product_type_config(enabled_flg);
|
||||||
|
|
||||||
|
-- ./product_resource.json
|
||||||
|
|
||||||
|
drop table if exists product_resource;
|
||||||
|
CREATE TABLE product_resource
|
||||||
|
(
|
||||||
|
`id` VARCHAR(32) NOT NULL comment '主键ID',
|
||||||
|
`product_id` VARCHAR(32) NOT NULL comment '产品ID',
|
||||||
|
`resource_type` VARCHAR(32) NOT NULL comment '资源类型',
|
||||||
|
`resource_ref_id` VARCHAR(32) NOT NULL comment '资源引用ID',
|
||||||
|
`resource_ref_name` VARCHAR(255) comment '资源显示名',
|
||||||
|
`quota` double(15,4) DEFAULT '0' comment '配额量',
|
||||||
|
`quota_unit` VARCHAR(32) comment '配额单位',
|
||||||
|
`priority` int DEFAULT '1' comment '优先级',
|
||||||
|
`overflow_product_id` VARCHAR(32) comment '超额后转用产品ID',
|
||||||
|
`status` CHAR(1) DEFAULT '1' comment '状态',
|
||||||
|
`created_at` datetime NOT NULL comment '创建时间',
|
||||||
|
`updated_at` datetime comment '更新时间'
|
||||||
|
,primary key(id)
|
||||||
|
)
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci
|
||||||
|
engine=innodb
|
||||||
|
comment '产品资源绑定表'
|
||||||
|
;
|
||||||
|
|
||||||
|
CREATE INDEX product_resource_idx_pr_product ON product_resource(product_id);
|
||||||
|
CREATE INDEX product_resource_idx_pr_resource ON product_resource(resource_type,resource_ref_id);
|
||||||
|
CREATE INDEX product_resource_idx_pr_status ON product_resource(status);
|
||||||
|
|
||||||
|
-- ./product_resource_supplier.json
|
||||||
|
|
||||||
|
drop table if exists product_resource_supplier;
|
||||||
|
CREATE TABLE product_resource_supplier
|
||||||
|
(
|
||||||
|
`id` VARCHAR(32) NOT NULL comment '主键ID',
|
||||||
|
`product_resource_id` VARCHAR(32) NOT NULL comment '产品资源绑定ID',
|
||||||
|
`supplier_org_id` VARCHAR(32) NOT NULL comment '供应商机构ID',
|
||||||
|
`priority` int DEFAULT '1' comment '优先级',
|
||||||
|
`weight` int DEFAULT '100' comment '权重',
|
||||||
|
`status` CHAR(1) DEFAULT '1' comment '状态',
|
||||||
|
`created_at` datetime NOT NULL comment '创建时间'
|
||||||
|
,primary key(id)
|
||||||
|
)
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci
|
||||||
|
engine=innodb
|
||||||
|
comment '产品资源供应商关联表'
|
||||||
|
;
|
||||||
|
|
||||||
|
CREATE INDEX product_resource_supplier_idx_prs_resource ON product_resource_supplier(product_resource_id);
|
||||||
|
CREATE INDEX product_resource_supplier_idx_prs_supplier ON product_resource_supplier(supplier_org_id);
|
||||||
|
CREATE UNIQUE INDEX product_resource_supplier_idx_prs_unique ON product_resource_supplier(product_resource_id,supplier_org_id);
|
||||||
|
|
||||||
|
-- ./product_subscription.json
|
||||||
|
|
||||||
|
drop table if exists product_subscription;
|
||||||
|
CREATE TABLE product_subscription
|
||||||
|
(
|
||||||
|
`id` VARCHAR(32) NOT NULL comment '主键ID',
|
||||||
|
`product_id` VARCHAR(32) NOT NULL comment '产品ID',
|
||||||
|
`user_id` VARCHAR(32) NOT NULL comment '客户用户ID',
|
||||||
|
`user_org_id` VARCHAR(32) NOT NULL comment '客户机构ID',
|
||||||
|
`subscription_type` CHAR(1) NOT NULL comment '订购类型',
|
||||||
|
`status` CHAR(1) NOT NULL DEFAULT '1' comment '状态',
|
||||||
|
`start_date` date NOT NULL comment '生效日期',
|
||||||
|
`end_date` date NOT NULL comment '到期日期',
|
||||||
|
`quota_total` double(15,4) DEFAULT '0' comment '总配额',
|
||||||
|
`quota_used` double(15,4) DEFAULT '0' comment '已使用量',
|
||||||
|
`quota_unit` VARCHAR(32) comment '配额单位',
|
||||||
|
`overflow_mode` CHAR(1) DEFAULT '1' comment '超额模式',
|
||||||
|
`overflow_rate` double(15,6) DEFAULT '0' comment '超额单价',
|
||||||
|
`purchase_price` double(15,2) DEFAULT '0' comment '购买价格',
|
||||||
|
`purchase_currency` CHAR(8) DEFAULT 'CNY' comment '货币',
|
||||||
|
`created_at` datetime NOT NULL comment '创建时间',
|
||||||
|
`updated_at` datetime comment '更新时间'
|
||||||
|
,primary key(id)
|
||||||
|
)
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci
|
||||||
|
engine=innodb
|
||||||
|
comment '客户订购表'
|
||||||
|
;
|
||||||
|
|
||||||
|
CREATE INDEX product_subscription_idx_ps_product ON product_subscription(product_id);
|
||||||
|
CREATE INDEX product_subscription_idx_ps_user ON product_subscription(user_id,user_org_id);
|
||||||
|
CREATE INDEX product_subscription_idx_ps_status ON product_subscription(status);
|
||||||
|
CREATE INDEX product_subscription_idx_ps_dates ON product_subscription(start_date,end_date);
|
||||||
|
|
||||||
|
-- ./product_usage_log.json
|
||||||
|
|
||||||
|
drop table if exists product_usage_log;
|
||||||
|
CREATE TABLE product_usage_log
|
||||||
|
(
|
||||||
|
`id` VARCHAR(32) NOT NULL comment '主键ID',
|
||||||
|
`product_id` VARCHAR(32) NOT NULL comment '产品ID',
|
||||||
|
`subscription_id` VARCHAR(32) comment '订购ID',
|
||||||
|
`user_id` VARCHAR(32) NOT NULL comment '消费者用户ID',
|
||||||
|
`user_org_id` VARCHAR(32) NOT NULL comment '消费者机构ID',
|
||||||
|
`product_resource_id` VARCHAR(32) comment '产品资源绑定ID',
|
||||||
|
`supplier_org_id` VARCHAR(32) comment '供应商机构ID',
|
||||||
|
`resource_type` VARCHAR(32) comment '资源类型',
|
||||||
|
`resource_ref_id` VARCHAR(32) comment '资源引用ID',
|
||||||
|
`used_amount` double(15,4) NOT NULL comment '消耗量',
|
||||||
|
`used_unit` VARCHAR(32) comment '消耗单位',
|
||||||
|
`unit_cost` double(15,8) DEFAULT '0' comment '单位成本',
|
||||||
|
`total_cost` double(15,6) DEFAULT '0' comment '总成本',
|
||||||
|
`sell_price` double(15,6) DEFAULT '0' comment '客户售价',
|
||||||
|
`billing_mode` CHAR(1) NOT NULL comment '计费模式',
|
||||||
|
`source_ref_table` VARCHAR(64) comment '来源表',
|
||||||
|
`source_ref_id` VARCHAR(32) comment '来源记录ID',
|
||||||
|
`use_time` datetime NOT NULL comment '消费时间',
|
||||||
|
`created_at` datetime NOT NULL comment '创建时间'
|
||||||
|
,primary key(id)
|
||||||
|
)
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci
|
||||||
|
engine=innodb
|
||||||
|
comment '产品消费记录表'
|
||||||
|
;
|
||||||
|
|
||||||
|
CREATE INDEX product_usage_log_idx_pul_product ON product_usage_log(product_id);
|
||||||
|
CREATE INDEX product_usage_log_idx_pul_subscription ON product_usage_log(subscription_id);
|
||||||
|
CREATE INDEX product_usage_log_idx_pul_user ON product_usage_log(user_id,user_org_id);
|
||||||
|
CREATE INDEX product_usage_log_idx_pul_supplier ON product_usage_log(supplier_org_id);
|
||||||
|
CREATE INDEX product_usage_log_idx_pul_time ON product_usage_log(use_time);
|
||||||
|
CREATE INDEX product_usage_log_idx_pul_source ON product_usage_log(source_ref_table,source_ref_id);
|
||||||
|
|
||||||
|
|||||||
@ -490,3 +490,609 @@ class ProductManager:
|
|||||||
'updated_at': now
|
'updated_at': now
|
||||||
})
|
})
|
||||||
return {'success': True, 'id': config_id, 'message': 'Config created'}
|
return {'success': True, 'id': config_id, 'message': 'Config created'}
|
||||||
|
|
||||||
|
# ─── Resource Binding ───
|
||||||
|
|
||||||
|
async def bind_resource(self, product_id, resource_type, resource_ref_id,
|
||||||
|
resource_ref_name='', quota=0, quota_unit='',
|
||||||
|
overflow_product_id=None, org_id=None):
|
||||||
|
"""Bind a resource to a product."""
|
||||||
|
if not org_id:
|
||||||
|
org_id = self._get_current_org_id()
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
now = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
# Verify product exists and belongs to org
|
||||||
|
prod = await sor.sqlExe(
|
||||||
|
"SELECT id FROM product WHERE id=${pid}$ AND org_id=${oid}$",
|
||||||
|
{'pid': product_id, 'oid': org_id})
|
||||||
|
if not prod:
|
||||||
|
return {'success': False, 'message': 'Product not found'}
|
||||||
|
|
||||||
|
rid = getID()
|
||||||
|
await sor.I('product_resource', {
|
||||||
|
'id': rid,
|
||||||
|
'product_id': product_id,
|
||||||
|
'resource_type': resource_type,
|
||||||
|
'resource_ref_id': resource_ref_id,
|
||||||
|
'resource_ref_name': resource_ref_name,
|
||||||
|
'quota': float(quota) if quota else 0,
|
||||||
|
'quota_unit': quota_unit,
|
||||||
|
'priority': 1,
|
||||||
|
'overflow_product_id': overflow_product_id or '',
|
||||||
|
'status': '1',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now
|
||||||
|
})
|
||||||
|
return {'success': True, 'id': rid}
|
||||||
|
|
||||||
|
async def unbind_resource(self, product_resource_id, org_id=None):
|
||||||
|
"""Unbind a resource from product (cascade delete suppliers)."""
|
||||||
|
if not org_id:
|
||||||
|
org_id = self._get_current_org_id()
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
# Verify ownership via product
|
||||||
|
check = await sor.sqlExe(
|
||||||
|
"""SELECT pr.id FROM product_resource pr
|
||||||
|
JOIN product p ON pr.product_id=p.id
|
||||||
|
WHERE pr.id=${id}$ AND p.org_id=${oid}$""",
|
||||||
|
{'id': product_resource_id, 'oid': org_id})
|
||||||
|
if not check:
|
||||||
|
return {'success': False, 'message': 'Resource binding not found'}
|
||||||
|
|
||||||
|
# Cascade delete suppliers
|
||||||
|
await sor.sqlExe(
|
||||||
|
"DELETE FROM product_resource_supplier WHERE product_resource_id=${id}$",
|
||||||
|
{'id': product_resource_id})
|
||||||
|
await sor.D('product_resource', {'id': product_resource_id})
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
async def get_product_resources(self, product_id, org_id=None):
|
||||||
|
"""Get all resource bindings for a product, with suppliers."""
|
||||||
|
if not org_id:
|
||||||
|
org_id = self._get_current_org_id()
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
resources = await sor.sqlExe(
|
||||||
|
"""SELECT pr.* FROM product_resource pr
|
||||||
|
JOIN product p ON pr.product_id=p.id
|
||||||
|
WHERE pr.product_id=${pid}$ AND p.org_id=${oid}$
|
||||||
|
ORDER BY pr.priority ASC""",
|
||||||
|
{'pid': product_id, 'oid': org_id})
|
||||||
|
resources = [dict(r) for r in (resources or [])]
|
||||||
|
|
||||||
|
# Attach suppliers for each resource
|
||||||
|
for res in resources:
|
||||||
|
suppliers = await sor.sqlExe(
|
||||||
|
"""SELECT prs.*, s.supplier_name
|
||||||
|
FROM product_resource_supplier prs
|
||||||
|
LEFT JOIN supplychain.suppliers s ON prs.supplier_org_id=s.org_id
|
||||||
|
WHERE prs.product_resource_id=${rid}$
|
||||||
|
ORDER BY prs.priority ASC, prs.weight DESC""",
|
||||||
|
{'rid': res['id']})
|
||||||
|
res['suppliers'] = [dict(s) for s in (suppliers or [])]
|
||||||
|
|
||||||
|
return {'success': True, 'resources': resources}
|
||||||
|
|
||||||
|
async def add_supplier_to_resource(self, product_resource_id, supplier_org_id,
|
||||||
|
priority=1, weight=100, org_id=None):
|
||||||
|
"""Add a supplier to a product resource binding."""
|
||||||
|
if not org_id:
|
||||||
|
org_id = self._get_current_org_id()
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
now = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
# Verify resource belongs to org's product
|
||||||
|
check = await sor.sqlExe(
|
||||||
|
"""SELECT pr.id FROM product_resource pr
|
||||||
|
JOIN product p ON pr.product_id=p.id
|
||||||
|
WHERE pr.id=${rid}$ AND p.org_id=${oid}$""",
|
||||||
|
{'rid': product_resource_id, 'oid': org_id})
|
||||||
|
if not check:
|
||||||
|
return {'success': False, 'message': 'Resource binding not found'}
|
||||||
|
|
||||||
|
sid = getID()
|
||||||
|
try:
|
||||||
|
await sor.I('product_resource_supplier', {
|
||||||
|
'id': sid,
|
||||||
|
'product_resource_id': product_resource_id,
|
||||||
|
'supplier_org_id': supplier_org_id,
|
||||||
|
'priority': int(priority),
|
||||||
|
'weight': int(weight),
|
||||||
|
'status': '1',
|
||||||
|
'created_at': now
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'message': f'Duplicate supplier: {e}'}
|
||||||
|
return {'success': True, 'id': sid}
|
||||||
|
|
||||||
|
async def remove_supplier_from_resource(self, prs_id, org_id=None):
|
||||||
|
"""Remove a supplier from a resource binding."""
|
||||||
|
if not org_id:
|
||||||
|
org_id = self._get_current_org_id()
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
check = await sor.sqlExe(
|
||||||
|
"""SELECT prs.id FROM product_resource_supplier prs
|
||||||
|
JOIN product_resource pr ON prs.product_resource_id=pr.id
|
||||||
|
JOIN product p ON pr.product_id=p.id
|
||||||
|
WHERE prs.id=${id}$ AND p.org_id=${oid}$""",
|
||||||
|
{'id': prs_id, 'oid': org_id})
|
||||||
|
if not check:
|
||||||
|
return {'success': False, 'message': 'Not found'}
|
||||||
|
await sor.D('product_resource_supplier', {'id': prs_id})
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
async def update_supplier_priority(self, prs_id, priority=None, weight=None, org_id=None):
|
||||||
|
"""Update supplier priority/weight."""
|
||||||
|
if not org_id:
|
||||||
|
org_id = self._get_current_org_id()
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
data = {}
|
||||||
|
if priority is not None:
|
||||||
|
data['priority'] = int(priority)
|
||||||
|
if weight is not None:
|
||||||
|
data['weight'] = int(weight)
|
||||||
|
if not data:
|
||||||
|
return {'success': False, 'message': 'No fields to update'}
|
||||||
|
await sor.U('product_resource_supplier', data, {'id': prs_id})
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
async def set_overflow_product(self, product_resource_id, overflow_product_id, org_id=None):
|
||||||
|
"""Set overflow product for a resource binding."""
|
||||||
|
if not org_id:
|
||||||
|
org_id = self._get_current_org_id()
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
now = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.U('product_resource', {
|
||||||
|
'overflow_product_id': overflow_product_id or '',
|
||||||
|
'updated_at': now
|
||||||
|
}, {'id': product_resource_id})
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
# ─── Subscriptions ───
|
||||||
|
|
||||||
|
async def subscribe_product(self, product_id, user_id, user_org_id,
|
||||||
|
start_date, end_date, org_id=None):
|
||||||
|
"""Create a subscription for a monthly/quantity product."""
|
||||||
|
if not org_id:
|
||||||
|
org_id = self._get_current_org_id()
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
now = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
# Get product
|
||||||
|
prod = await sor.sqlExe(
|
||||||
|
"SELECT * FROM product WHERE id=${pid}$ AND status='1'",
|
||||||
|
{'pid': product_id})
|
||||||
|
if not prod:
|
||||||
|
return {'success': False, 'message': 'Product not found'}
|
||||||
|
product = dict(prod[0])
|
||||||
|
|
||||||
|
# Get resource binding for quota
|
||||||
|
resources = await sor.sqlExe(
|
||||||
|
"SELECT * FROM product_resource WHERE product_id=${pid}$ AND status='1' LIMIT 1",
|
||||||
|
{'pid': product_id})
|
||||||
|
quota_total = 0
|
||||||
|
quota_unit = ''
|
||||||
|
overflow_product_id = ''
|
||||||
|
if resources:
|
||||||
|
res = dict(resources[0])
|
||||||
|
quota_total = float(res.get('quota', 0))
|
||||||
|
quota_unit = res.get('quota_unit', '')
|
||||||
|
overflow_product_id = res.get('overflow_product_id', '')
|
||||||
|
|
||||||
|
# Get overflow rate from overflow product's price
|
||||||
|
overflow_rate = 0
|
||||||
|
if overflow_product_id:
|
||||||
|
op = await sor.sqlExe(
|
||||||
|
"SELECT price FROM product WHERE id=${oid}$",
|
||||||
|
{'oid': overflow_product_id})
|
||||||
|
if op:
|
||||||
|
overflow_rate = float(op[0].get('price', 0))
|
||||||
|
|
||||||
|
sub_id = getID()
|
||||||
|
sub_type = '1' # monthly by default
|
||||||
|
if product.get('product_type') == 'llm_monthly':
|
||||||
|
sub_type = '1'
|
||||||
|
elif product.get('product_type') in ('llm_model', 'compute'):
|
||||||
|
sub_type = '2'
|
||||||
|
|
||||||
|
await sor.I('product_subscription', {
|
||||||
|
'id': sub_id,
|
||||||
|
'product_id': product_id,
|
||||||
|
'user_id': user_id,
|
||||||
|
'user_org_id': user_org_id,
|
||||||
|
'subscription_type': sub_type,
|
||||||
|
'status': '1',
|
||||||
|
'start_date': start_date,
|
||||||
|
'end_date': end_date,
|
||||||
|
'quota_total': quota_total,
|
||||||
|
'quota_used': 0,
|
||||||
|
'quota_unit': quota_unit,
|
||||||
|
'overflow_mode': '1',
|
||||||
|
'overflow_rate': overflow_rate,
|
||||||
|
'purchase_price': float(product.get('price', 0)),
|
||||||
|
'purchase_currency': product.get('currency', 'CNY'),
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now
|
||||||
|
})
|
||||||
|
return {'success': True, 'id': sub_id, 'quota_total': quota_total,
|
||||||
|
'overflow_rate': overflow_rate}
|
||||||
|
|
||||||
|
async def get_subscriptions(self, filters=None, org_id=None):
|
||||||
|
"""List subscriptions with optional filters."""
|
||||||
|
if not org_id:
|
||||||
|
org_id = self._get_current_org_id()
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
filters = filters or {}
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
ns = {}
|
||||||
|
if filters.get('product_id'):
|
||||||
|
conditions.append("ps.product_id=${product_id}$")
|
||||||
|
ns['product_id'] = filters['product_id']
|
||||||
|
if filters.get('user_id'):
|
||||||
|
conditions.append("ps.user_id=${user_id}$")
|
||||||
|
ns['user_id'] = filters['user_id']
|
||||||
|
if filters.get('user_org_id'):
|
||||||
|
conditions.append("ps.user_org_id=${uoid}$")
|
||||||
|
ns['uoid'] = filters['user_org_id']
|
||||||
|
if filters.get('status'):
|
||||||
|
conditions.append("ps.status=${status}$")
|
||||||
|
ns['status'] = filters['status']
|
||||||
|
|
||||||
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
sql = f"""SELECT ps.*, p.product_name, p.product_type
|
||||||
|
FROM product_subscription ps
|
||||||
|
LEFT JOIN product p ON ps.product_id=p.id
|
||||||
|
{where}
|
||||||
|
ORDER BY ps.created_at DESC"""
|
||||||
|
rows = await sor.sqlExe(sql, ns)
|
||||||
|
return {'success': True, 'rows': [dict(r) for r in (rows or [])]}
|
||||||
|
|
||||||
|
async def get_subscription_detail(self, subscription_id):
|
||||||
|
"""Get subscription detail with quota usage."""
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
rows = await sor.sqlExe(
|
||||||
|
"""SELECT ps.*, p.product_name, p.product_type, p.brief_intro
|
||||||
|
FROM product_subscription ps
|
||||||
|
LEFT JOIN product p ON ps.product_id=p.id
|
||||||
|
WHERE ps.id=${sid}$""",
|
||||||
|
{'sid': subscription_id})
|
||||||
|
if not rows:
|
||||||
|
return {'success': False, 'message': 'Not found'}
|
||||||
|
|
||||||
|
sub = dict(rows[0])
|
||||||
|
total = float(sub.get('quota_total', 0))
|
||||||
|
used = float(sub.get('quota_used', 0))
|
||||||
|
remaining = max(0, total - used)
|
||||||
|
pct = round((used / total * 100), 2) if total > 0 else 0
|
||||||
|
|
||||||
|
sub['quota_remaining'] = remaining
|
||||||
|
sub['quota_percentage'] = pct
|
||||||
|
return {'success': True, 'data': sub}
|
||||||
|
|
||||||
|
async def cancel_subscription(self, subscription_id, org_id=None):
|
||||||
|
"""Cancel a subscription."""
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
now = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.U('product_subscription', {
|
||||||
|
'status': '3', 'updated_at': now
|
||||||
|
}, {'id': subscription_id})
|
||||||
|
return {'success': True}
|
||||||
|
|
||||||
|
async def expire_subscriptions(self):
|
||||||
|
"""Batch expire: set status='2' for subscriptions past end_date."""
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
today = datetime.date.today().isoformat()
|
||||||
|
now = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
result = await sor.sqlExe(
|
||||||
|
"""UPDATE product_subscription
|
||||||
|
SET status='2', updated_at=${now}$
|
||||||
|
WHERE status='1' AND end_date < ${today}$""",
|
||||||
|
{'today': today, 'now': now})
|
||||||
|
return {'success': True, 'expired_count': result}
|
||||||
|
|
||||||
|
# ─── Product Use Engine (CORE) ───
|
||||||
|
|
||||||
|
async def product_use(self, product_id, user_id, user_org_id,
|
||||||
|
used_amount, used_unit, resource_ref_id=None,
|
||||||
|
source_ref_table=None, source_ref_id=None):
|
||||||
|
"""Core consumption engine: route supplier, calc cost, log usage.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Validate product
|
||||||
|
2. Check subscription (for monthly products)
|
||||||
|
3. Route to supplier (by priority/weight)
|
||||||
|
4. Calculate cost from supplier_resource_price
|
||||||
|
5. Calculate sell price
|
||||||
|
6. Write product_usage_log
|
||||||
|
7. Update subscription quota
|
||||||
|
"""
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
now = time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
today = datetime.date.today().isoformat()
|
||||||
|
used_amount = float(used_amount)
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
# Step 1: Get product
|
||||||
|
prod = await sor.sqlExe(
|
||||||
|
"SELECT * FROM product WHERE id=${pid}$ AND status='1'",
|
||||||
|
{'pid': product_id})
|
||||||
|
if not prod:
|
||||||
|
return {'success': False, 'message': 'Product not found or inactive'}
|
||||||
|
product = dict(prod[0])
|
||||||
|
product_type = product.get('product_type', '')
|
||||||
|
|
||||||
|
# Step 2: Check subscription for monthly products
|
||||||
|
subscription = None
|
||||||
|
billing_mode = '2' # default pay-per-use
|
||||||
|
subscription_id = None
|
||||||
|
sell_price = 0
|
||||||
|
remaining_quota = None
|
||||||
|
|
||||||
|
is_monthly = product_type in ('llm_monthly',)
|
||||||
|
|
||||||
|
if is_monthly:
|
||||||
|
subs = await sor.sqlExe(
|
||||||
|
"""SELECT * FROM product_subscription
|
||||||
|
WHERE product_id=${pid}$ AND user_id=${uid}$
|
||||||
|
AND user_org_id=${uoid}$ AND status='1'
|
||||||
|
AND start_date <= ${today}$ AND end_date >= ${today}$
|
||||||
|
ORDER BY created_at ASC LIMIT 1""",
|
||||||
|
{'pid': product_id, 'uid': user_id,
|
||||||
|
'uoid': user_org_id, 'today': today})
|
||||||
|
if subs:
|
||||||
|
subscription = dict(subs[0])
|
||||||
|
subscription_id = subscription['id']
|
||||||
|
quota_total = float(subscription.get('quota_total', 0))
|
||||||
|
quota_used = float(subscription.get('quota_used', 0))
|
||||||
|
remaining = quota_total - quota_used
|
||||||
|
|
||||||
|
if remaining >= used_amount:
|
||||||
|
# Within quota
|
||||||
|
billing_mode = '1'
|
||||||
|
remaining_quota = remaining - used_amount
|
||||||
|
# Update quota
|
||||||
|
await sor.U('product_subscription', {
|
||||||
|
'quota_used': quota_used + used_amount,
|
||||||
|
'updated_at': now
|
||||||
|
}, {'id': subscription_id})
|
||||||
|
else:
|
||||||
|
# Overflow: use up remaining, rest is overage
|
||||||
|
if remaining > 0:
|
||||||
|
await sor.U('product_subscription', {
|
||||||
|
'quota_used': quota_total,
|
||||||
|
'status': '4',
|
||||||
|
'updated_at': now
|
||||||
|
}, {'id': subscription_id})
|
||||||
|
billing_mode = '2'
|
||||||
|
overage = used_amount - remaining
|
||||||
|
else:
|
||||||
|
billing_mode = '2'
|
||||||
|
overage = used_amount
|
||||||
|
|
||||||
|
overflow_rate = float(subscription.get('overflow_rate', 0))
|
||||||
|
if overflow_rate <= 0:
|
||||||
|
overflow_rate = float(product.get('price', 0))
|
||||||
|
sell_price = overage * overflow_rate
|
||||||
|
remaining_quota = 0
|
||||||
|
|
||||||
|
if not is_monthly or billing_mode == '2':
|
||||||
|
# Pay-per-use: sell_price = amount × product price
|
||||||
|
if sell_price == 0:
|
||||||
|
sell_price = used_amount * float(product.get('price', 0))
|
||||||
|
|
||||||
|
# Step 3: Route supplier
|
||||||
|
supplier_org_id = None
|
||||||
|
product_resource_id = None
|
||||||
|
resource_type = ''
|
||||||
|
|
||||||
|
res_cond = "product_id=${pid}$ AND status='1'"
|
||||||
|
res_ns = {'pid': product_id}
|
||||||
|
if resource_ref_id:
|
||||||
|
res_cond += " AND resource_ref_id=${rrid}$"
|
||||||
|
res_ns['rrid'] = resource_ref_id
|
||||||
|
|
||||||
|
resources = await sor.sqlExe(
|
||||||
|
f"SELECT * FROM product_resource WHERE {res_cond} ORDER BY priority ASC",
|
||||||
|
res_ns)
|
||||||
|
|
||||||
|
if resources:
|
||||||
|
res = dict(resources[0])
|
||||||
|
product_resource_id = res['id']
|
||||||
|
resource_type = res.get('resource_type', '')
|
||||||
|
if not resource_ref_id:
|
||||||
|
resource_ref_id = res.get('resource_ref_id', '')
|
||||||
|
|
||||||
|
# Get suppliers ordered by priority, weight
|
||||||
|
suppliers = await sor.sqlExe(
|
||||||
|
"""SELECT * FROM product_resource_supplier
|
||||||
|
WHERE product_resource_id=${rid}$ AND status='1'
|
||||||
|
ORDER BY priority ASC, weight DESC""",
|
||||||
|
{'rid': product_resource_id})
|
||||||
|
if suppliers:
|
||||||
|
supplier_org_id = dict(suppliers[0]).get('supplier_org_id')
|
||||||
|
|
||||||
|
# Step 4: Calculate cost from supplier_resource_price
|
||||||
|
unit_cost = 0
|
||||||
|
total_cost = 0
|
||||||
|
|
||||||
|
if supplier_org_id and resource_ref_id:
|
||||||
|
sc_dbname = 'supplychain'
|
||||||
|
price_sql = (
|
||||||
|
"SELECT * FROM " + sc_dbname + ".supplier_resource_price"
|
||||||
|
" WHERE supplier_org_id=${soid}$"
|
||||||
|
" AND resource_ref_id=${rrid}$"
|
||||||
|
" AND status='1'"
|
||||||
|
" AND effective_date <= ${today}$"
|
||||||
|
" AND (expiry_date IS NULL OR expiry_date >= ${today}$)"
|
||||||
|
" ORDER BY effective_date DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
price_rows = await sor.sqlExe(price_sql,
|
||||||
|
{'soid': supplier_org_id, 'rrid': resource_ref_id, 'today': today})
|
||||||
|
if price_rows:
|
||||||
|
pricing = dict(price_rows[0])
|
||||||
|
# For LLM: use input_price if available, else unit_price
|
||||||
|
if resource_type.startswith('llm'):
|
||||||
|
ip = pricing.get('input_price')
|
||||||
|
unit_cost = float(ip) if ip else float(pricing.get('unit_price', 0))
|
||||||
|
else:
|
||||||
|
unit_cost = float(pricing.get('unit_price', 0))
|
||||||
|
total_cost = used_amount * unit_cost
|
||||||
|
|
||||||
|
# Step 5: Write usage log
|
||||||
|
log_id = getID()
|
||||||
|
await sor.I('product_usage_log', {
|
||||||
|
'id': log_id,
|
||||||
|
'product_id': product_id,
|
||||||
|
'subscription_id': subscription_id or '',
|
||||||
|
'user_id': user_id,
|
||||||
|
'user_org_id': user_org_id,
|
||||||
|
'product_resource_id': product_resource_id or '',
|
||||||
|
'supplier_org_id': supplier_org_id or '',
|
||||||
|
'resource_type': resource_type,
|
||||||
|
'resource_ref_id': resource_ref_id or '',
|
||||||
|
'used_amount': used_amount,
|
||||||
|
'used_unit': used_unit,
|
||||||
|
'unit_cost': unit_cost,
|
||||||
|
'total_cost': total_cost,
|
||||||
|
'sell_price': sell_price,
|
||||||
|
'billing_mode': billing_mode,
|
||||||
|
'source_ref_table': source_ref_table or '',
|
||||||
|
'source_ref_id': source_ref_id or '',
|
||||||
|
'use_time': now,
|
||||||
|
'created_at': now
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'log_id': log_id,
|
||||||
|
'billing_mode': billing_mode,
|
||||||
|
'used_amount': used_amount,
|
||||||
|
'total_cost': round(total_cost, 6),
|
||||||
|
'sell_price': round(sell_price, 6),
|
||||||
|
'supplier_org_id': supplier_org_id,
|
||||||
|
'remaining_quota': remaining_quota
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Usage Logs & Stats ───
|
||||||
|
|
||||||
|
async def get_usage_logs(self, filters=None, page=1, page_size=50):
|
||||||
|
"""Query usage logs with filters."""
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
filters = filters or {}
|
||||||
|
page = int(page) or 1
|
||||||
|
page_size = int(page_size) or 50
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
ns = {}
|
||||||
|
for key in ('product_id', 'subscription_id', 'user_id', 'user_org_id',
|
||||||
|
'supplier_org_id', 'billing_mode', 'resource_type'):
|
||||||
|
if filters.get(key):
|
||||||
|
conditions.append(f"pul.{key}=${key}$")
|
||||||
|
ns[key] = filters[key]
|
||||||
|
if filters.get('start_date'):
|
||||||
|
conditions.append("pul.use_time >= ${start_date}$")
|
||||||
|
ns['start_date'] = filters['start_date']
|
||||||
|
if filters.get('end_date'):
|
||||||
|
conditions.append("pul.use_time <= ${end_date}$")
|
||||||
|
ns['end_date'] = filters['end_date']
|
||||||
|
|
||||||
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
count_sql = f"SELECT COUNT(*) as cnt FROM product_usage_log pul {where}"
|
||||||
|
cnt_rows = await sor.sqlExe(count_sql, ns)
|
||||||
|
total = cnt_rows[0]['cnt'] if cnt_rows else 0
|
||||||
|
|
||||||
|
sql = f"""SELECT pul.*, p.product_name, s.supplier_name
|
||||||
|
FROM product_usage_log pul
|
||||||
|
LEFT JOIN product p ON pul.product_id=p.id
|
||||||
|
LEFT JOIN supplychain.suppliers s ON pul.supplier_org_id=s.org_id
|
||||||
|
{where}
|
||||||
|
ORDER BY pul.use_time DESC
|
||||||
|
LIMIT {page_size} OFFSET {offset}"""
|
||||||
|
rows = await sor.sqlExe(sql, ns)
|
||||||
|
|
||||||
|
return {'success': True, 'rows': [dict(r) for r in (rows or [])],
|
||||||
|
'total': total, 'page': page}
|
||||||
|
|
||||||
|
async def get_usage_stats(self, filters=None):
|
||||||
|
"""Aggregate usage stats by product/supplier/date."""
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
filters = filters or {}
|
||||||
|
group_by = filters.get('group_by', 'product_id')
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
ns = {}
|
||||||
|
if filters.get('start_date'):
|
||||||
|
conditions.append("use_time >= ${start_date}$")
|
||||||
|
ns['start_date'] = filters['start_date']
|
||||||
|
if filters.get('end_date'):
|
||||||
|
conditions.append("use_time <= ${end_date}$")
|
||||||
|
ns['end_date'] = filters['end_date']
|
||||||
|
|
||||||
|
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
sql = f"""SELECT {group_by},
|
||||||
|
COUNT(*) as usage_count,
|
||||||
|
SUM(used_amount) as total_used,
|
||||||
|
SUM(total_cost) as total_cost,
|
||||||
|
SUM(sell_price) as total_revenue,
|
||||||
|
SUM(sell_price) - SUM(total_cost) as total_profit
|
||||||
|
FROM product_usage_log
|
||||||
|
{where}
|
||||||
|
GROUP BY {group_by}
|
||||||
|
ORDER BY total_cost DESC"""
|
||||||
|
rows = await sor.sqlExe(sql, ns)
|
||||||
|
|
||||||
|
return {'success': True, 'stats': [dict(r) for r in (rows or [])]}
|
||||||
|
|
||||||
|
async def check_quota(self, subscription_id):
|
||||||
|
"""Check quota status for a subscription."""
|
||||||
|
dbname = self._get_dbname()
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
rows = await sor.sqlExe(
|
||||||
|
"SELECT * FROM product_subscription WHERE id=${sid}$",
|
||||||
|
{'sid': subscription_id})
|
||||||
|
if not rows:
|
||||||
|
return {'success': False, 'message': 'Not found'}
|
||||||
|
|
||||||
|
sub = dict(rows[0])
|
||||||
|
total = float(sub.get('quota_total', 0))
|
||||||
|
used = float(sub.get('quota_used', 0))
|
||||||
|
remaining = max(0, total - used)
|
||||||
|
pct = round((used / total * 100), 2) if total > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'quota_total': total,
|
||||||
|
'quota_used': used,
|
||||||
|
'quota_remaining': remaining,
|
||||||
|
'quota_percentage': pct,
|
||||||
|
'status': sub.get('status'),
|
||||||
|
'overflow_mode': sub.get('overflow_mode'),
|
||||||
|
'overflow_rate': float(sub.get('overflow_rate', 0))
|
||||||
|
}
|
||||||
|
|||||||
@ -91,6 +91,48 @@ PATHS_LOGINED = [
|
|||||||
f"/{MOD}/product_type_config_list/add_product_type_config.dspy",
|
f"/{MOD}/product_type_config_list/add_product_type_config.dspy",
|
||||||
f"/{MOD}/product_type_config_list/update_product_type_config.dspy",
|
f"/{MOD}/product_type_config_list/update_product_type_config.dspy",
|
||||||
f"/{MOD}/product_type_config_list/delete_product_type_config.dspy",
|
f"/{MOD}/product_type_config_list/delete_product_type_config.dspy",
|
||||||
|
|
||||||
|
# Resource binding
|
||||||
|
f"/{MOD}/product_resource_list",
|
||||||
|
f"/{MOD}/product_resource_list/index.ui",
|
||||||
|
f"/{MOD}/product_resource_list/get_product_resource.dspy",
|
||||||
|
f"/{MOD}/product_resource_list/add_product_resource.dspy",
|
||||||
|
f"/{MOD}/product_resource_list/update_product_resource.dspy",
|
||||||
|
f"/{MOD}/product_resource_list/delete_product_resource.dspy",
|
||||||
|
f"/{MOD}/product_resource_supplier_list",
|
||||||
|
f"/{MOD}/product_resource_supplier_list/index.ui",
|
||||||
|
f"/{MOD}/product_resource_supplier_list/get_product_resource_supplier.dspy",
|
||||||
|
f"/{MOD}/product_resource_supplier_list/add_product_resource_supplier.dspy",
|
||||||
|
f"/{MOD}/product_resource_supplier_list/update_product_resource_supplier.dspy",
|
||||||
|
f"/{MOD}/product_resource_supplier_list/delete_product_resource_supplier.dspy",
|
||||||
|
f"/{MOD}/api/product_resource_bind.dspy",
|
||||||
|
f"/{MOD}/api/product_resource_unbind.dspy",
|
||||||
|
f"/{MOD}/api/product_resources_list.dspy",
|
||||||
|
f"/{MOD}/api/resource_supplier_add.dspy",
|
||||||
|
f"/{MOD}/api/resource_supplier_remove.dspy",
|
||||||
|
f"/{MOD}/api/resource_supplier_priority.dspy",
|
||||||
|
f"/{MOD}/api/resource_overflow_set.dspy",
|
||||||
|
|
||||||
|
# Subscriptions
|
||||||
|
f"/{MOD}/product_subscription_list",
|
||||||
|
f"/{MOD}/product_subscription_list/index.ui",
|
||||||
|
f"/{MOD}/product_subscription_list/get_product_subscription.dspy",
|
||||||
|
f"/{MOD}/product_subscription_list/add_product_subscription.dspy",
|
||||||
|
f"/{MOD}/product_subscription_list/update_product_subscription.dspy",
|
||||||
|
f"/{MOD}/product_subscription_list/delete_product_subscription.dspy",
|
||||||
|
f"/{MOD}/api/subscribe_product.dspy",
|
||||||
|
f"/{MOD}/api/subscriptions_list.dspy",
|
||||||
|
f"/{MOD}/api/subscription_detail.dspy",
|
||||||
|
f"/{MOD}/api/subscription_cancel.dspy",
|
||||||
|
f"/{MOD}/api/quota_check.dspy",
|
||||||
|
|
||||||
|
# Usage logs
|
||||||
|
f"/{MOD}/product_usage_log_list",
|
||||||
|
f"/{MOD}/product_usage_log_list/index.ui",
|
||||||
|
f"/{MOD}/product_usage_log_list/get_product_usage_log.dspy",
|
||||||
|
f"/{MOD}/api/product_use_api.dspy",
|
||||||
|
f"/{MOD}/api/usage_logs.dspy",
|
||||||
|
f"/{MOD}/api/usage_stats.dspy",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
17
wwwroot/api/product_resource_bind.dspy
Normal file
17
wwwroot/api/product_resource_bind.dspy
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
result = {'success': False, 'message': ''}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.bind_resource(
|
||||||
|
product_id=params_kw.get('product_id', ''),
|
||||||
|
resource_type=params_kw.get('resource_type', ''),
|
||||||
|
resource_ref_id=params_kw.get('resource_ref_id', ''),
|
||||||
|
resource_ref_name=params_kw.get('resource_ref_name', ''),
|
||||||
|
quota=params_kw.get('quota', 0),
|
||||||
|
quota_unit=params_kw.get('quota_unit', ''),
|
||||||
|
overflow_product_id=params_kw.get('overflow_product_id', '')
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['message'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
11
wwwroot/api/product_resource_unbind.dspy
Normal file
11
wwwroot/api/product_resource_unbind.dspy
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
result = {'success': False, 'message': ''}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.unbind_resource(
|
||||||
|
product_resource_id=params_kw.get('product_resource_id', '')
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['message'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
11
wwwroot/api/product_resources_list.dspy
Normal file
11
wwwroot/api/product_resources_list.dspy
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
result = {'success': False, 'resources': []}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.get_product_resources(
|
||||||
|
product_id=params_kw.get('product_id', '')
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['error'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
18
wwwroot/api/product_use_api.dspy
Normal file
18
wwwroot/api/product_use_api.dspy
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
result = {'success': False, 'message': ''}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.product_use(
|
||||||
|
product_id=params_kw.get('product_id', ''),
|
||||||
|
user_id=params_kw.get('user_id', ''),
|
||||||
|
user_org_id=params_kw.get('user_org_id', ''),
|
||||||
|
used_amount=params_kw.get('used_amount', 0),
|
||||||
|
used_unit=params_kw.get('used_unit', ''),
|
||||||
|
resource_ref_id=params_kw.get('resource_ref_id', ''),
|
||||||
|
source_ref_table=params_kw.get('source_ref_table', ''),
|
||||||
|
source_ref_id=params_kw.get('source_ref_id', '')
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['message'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
11
wwwroot/api/quota_check.dspy
Normal file
11
wwwroot/api/quota_check.dspy
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
result = {'success': False}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.check_quota(
|
||||||
|
subscription_id=params_kw.get('subscription_id', '')
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['error'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
12
wwwroot/api/resource_overflow_set.dspy
Normal file
12
wwwroot/api/resource_overflow_set.dspy
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
result = {'success': False, 'message': ''}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.set_overflow_product(
|
||||||
|
product_resource_id=params_kw.get('product_resource_id', ''),
|
||||||
|
overflow_product_id=params_kw.get('overflow_product_id', '')
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['message'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
14
wwwroot/api/resource_supplier_add.dspy
Normal file
14
wwwroot/api/resource_supplier_add.dspy
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
result = {'success': False, 'message': ''}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.add_supplier_to_resource(
|
||||||
|
product_resource_id=params_kw.get('product_resource_id', ''),
|
||||||
|
supplier_org_id=params_kw.get('supplier_org_id', ''),
|
||||||
|
priority=params_kw.get('priority', 1),
|
||||||
|
weight=params_kw.get('weight', 100)
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['message'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
13
wwwroot/api/resource_supplier_priority.dspy
Normal file
13
wwwroot/api/resource_supplier_priority.dspy
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
result = {'success': False, 'message': ''}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.update_supplier_priority(
|
||||||
|
prs_id=params_kw.get('id', ''),
|
||||||
|
priority=params_kw.get('priority'),
|
||||||
|
weight=params_kw.get('weight')
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['message'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
11
wwwroot/api/resource_supplier_remove.dspy
Normal file
11
wwwroot/api/resource_supplier_remove.dspy
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
result = {'success': False, 'message': ''}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.remove_supplier_from_resource(
|
||||||
|
prs_id=params_kw.get('id', '')
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['message'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
15
wwwroot/api/subscribe_product.dspy
Normal file
15
wwwroot/api/subscribe_product.dspy
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
result = {'success': False, 'message': ''}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.subscribe_product(
|
||||||
|
product_id=params_kw.get('product_id', ''),
|
||||||
|
user_id=params_kw.get('user_id', ''),
|
||||||
|
user_org_id=params_kw.get('user_org_id', ''),
|
||||||
|
start_date=params_kw.get('start_date', ''),
|
||||||
|
end_date=params_kw.get('end_date', '')
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['message'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
11
wwwroot/api/subscription_cancel.dspy
Normal file
11
wwwroot/api/subscription_cancel.dspy
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
result = {'success': False, 'message': ''}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.cancel_subscription(
|
||||||
|
subscription_id=params_kw.get('id', '')
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['message'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
11
wwwroot/api/subscription_detail.dspy
Normal file
11
wwwroot/api/subscription_detail.dspy
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
result = {'success': False, 'data': {}}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.get_subscription_detail(
|
||||||
|
subscription_id=params_kw.get('id', '')
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['error'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
14
wwwroot/api/subscriptions_list.dspy
Normal file
14
wwwroot/api/subscriptions_list.dspy
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
result = {'success': False, 'rows': []}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.get_subscriptions(filters={
|
||||||
|
'product_id': params_kw.get('product_id', ''),
|
||||||
|
'user_id': params_kw.get('user_id', ''),
|
||||||
|
'user_org_id': params_kw.get('user_org_id', ''),
|
||||||
|
'status': params_kw.get('status', '')
|
||||||
|
})
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['error'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
21
wwwroot/api/usage_logs.dspy
Normal file
21
wwwroot/api/usage_logs.dspy
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
result = {'success': False, 'rows': [], 'total': 0}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.get_usage_logs(
|
||||||
|
filters={
|
||||||
|
'product_id': params_kw.get('product_id', ''),
|
||||||
|
'subscription_id': params_kw.get('subscription_id', ''),
|
||||||
|
'user_id': params_kw.get('user_id', ''),
|
||||||
|
'supplier_org_id': params_kw.get('supplier_org_id', ''),
|
||||||
|
'billing_mode': params_kw.get('billing_mode', ''),
|
||||||
|
'start_date': params_kw.get('start_date', ''),
|
||||||
|
'end_date': params_kw.get('end_date', '')
|
||||||
|
},
|
||||||
|
page=params_kw.get('page', 1),
|
||||||
|
page_size=params_kw.get('page_size', 50)
|
||||||
|
)
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['error'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
13
wwwroot/api/usage_stats.dspy
Normal file
13
wwwroot/api/usage_stats.dspy
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
result = {'success': False, 'stats': []}
|
||||||
|
try:
|
||||||
|
mgr = ProductManager()
|
||||||
|
r = await mgr.get_usage_stats(filters={
|
||||||
|
'group_by': params_kw.get('group_by', 'product_id'),
|
||||||
|
'start_date': params_kw.get('start_date', ''),
|
||||||
|
'end_date': params_kw.get('end_date', '')
|
||||||
|
})
|
||||||
|
result = r
|
||||||
|
except Exception as e:
|
||||||
|
result['error'] = str(e)
|
||||||
|
debug(format_exc())
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
82
wwwroot/product_resource_list/index.ui
Normal file
82
wwwroot/product_resource_list/index.ui
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"widgettype": "VBox",
|
||||||
|
"options": {"width": "100%", "padding": "8px", "gap": "8px"},
|
||||||
|
"subwidgets": [
|
||||||
|
{
|
||||||
|
"widgettype": "InlineForm",
|
||||||
|
"id": "filter_form",
|
||||||
|
"options": {
|
||||||
|
"css": "card", "padding": "8px",
|
||||||
|
"submit_label": "查询",
|
||||||
|
"fields": [
|
||||||
|
{"name": "product_id", "label": "产品ID", "uitype": "str", "cwidth": 16}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"binds": [{
|
||||||
|
"wid": "self", "event": "submit",
|
||||||
|
"actiontype": "script", "target": "res_table",
|
||||||
|
"script": "var tbl = bricks.getWidgetById('res_table', bricks.app.root); if(tbl) await tbl.render(params);"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"widgettype": "Tabular",
|
||||||
|
"id": "res_table",
|
||||||
|
"options": {
|
||||||
|
"width": "100%", "css": "card",
|
||||||
|
"data_url": "{{entire_url('/product_management/product_resource_list/get_product_resource.dspy')}}",
|
||||||
|
"data_method": "GET", "page_rows": 20,
|
||||||
|
"toolbar": {
|
||||||
|
"tools": [
|
||||||
|
{"name": "add_resource", "label": "绑定资源"},
|
||||||
|
{"name": "manage_suppliers", "label": "管理供应商", "selected_row": true},
|
||||||
|
{"name": "set_overflow", "label": "设置超额产品", "selected_row": true}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"row_options": {
|
||||||
|
"browserfields": {
|
||||||
|
"exclouded": ["id", "created_at", "updated_at"],
|
||||||
|
"alters": {
|
||||||
|
"product_id": {
|
||||||
|
"uitype": "code", "valueField": "product_id", "textField": "product_id_text",
|
||||||
|
"params": {"dbname": "product_management", "table": "product", "tblvalue": "id", "tbltext": "product_name", "valueField": "product_id", "textField": "product_id_text"},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
"resource_type": {"uitype": "code", "data": [{"value": "llm_model", "text": "大模型按量"}, {"value": "llm_monthly", "text": "大模型包月"}, {"value": "compute", "text": "算力"}]},
|
||||||
|
"quota_unit": {"uitype": "code", "data": [{"value": "tokens", "text": "tokens"}, {"value": "requests", "text": "次"}, {"value": "gpu_hours", "text": "GPU时"}]},
|
||||||
|
"status": {"uitype": "code", "data": [{"value": "1", "text": "启用"}, {"value": "0", "text": "禁用"}]}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": [
|
||||||
|
{"name": "product_id", "title": "产品", "type": "str", "length": 32, "cwidth": 14},
|
||||||
|
{"name": "resource_type", "title": "资源类型", "type": "str", "length": 32, "cwidth": 10},
|
||||||
|
{"name": "resource_ref_id", "title": "资源引用ID", "type": "str", "length": 32, "cwidth": 14},
|
||||||
|
{"name": "resource_ref_name", "title": "资源名称", "type": "str", "length": 255, "cwidth": 14},
|
||||||
|
{"name": "quota", "title": "配额", "type": "double", "length": 15, "dec": 4, "cwidth": 10},
|
||||||
|
{"name": "quota_unit", "title": "单位", "type": "str", "length": 32, "cwidth": 8},
|
||||||
|
{"name": "overflow_product_id", "title": "超额产品", "type": "str", "length": 32, "cwidth": 14},
|
||||||
|
{"name": "status", "title": "状态", "type": "char", "length": 1, "cwidth": 6}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"binds": [
|
||||||
|
{
|
||||||
|
"wid": "self", "event": "add_resource",
|
||||||
|
"actiontype": "urlwidget", "target": "PopupWindow",
|
||||||
|
"popup_options": {"title": "绑定资源", "cwidth": 40, "cheight": 25},
|
||||||
|
"options": {"url": "{{entire_url('/product_management/product_resource_list/add_product_resource.dspy')}}"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wid": "self", "event": "manage_suppliers",
|
||||||
|
"actiontype": "urlwidget", "target": "app.sage_main_content",
|
||||||
|
"options": {"url": "{{entire_url('/product_management/product_resource_supplier_list/index.ui')}}?product_resource_id=${id}$"},
|
||||||
|
"mode": "replace"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wid": "self", "event": "set_overflow",
|
||||||
|
"actiontype": "script", "target": "self",
|
||||||
|
"script": "var dv = bricks.getWidgetById('res_table', bricks.app.root); if(!dv || !dv.select_row) { alert('请先选中一条记录'); return; } var row = dv.select_row.user_data; var pid = prompt('输入超额产品ID:'); if(!pid) return; var resp = await fetch('{{entire_url('/product_management/api/resource_overflow_set.dspy')}}?product_resource_id=' + row.id + '&overflow_product_id=' + pid); var d = await resp.json(); alert(d.success ? '设置成功' : '失败: ' + d.message); if(d.success) await dv.render({});"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
46
wwwroot/product_resource_supplier_list/index.ui
Normal file
46
wwwroot/product_resource_supplier_list/index.ui
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"widgettype": "VBox",
|
||||||
|
"options": {"width": "100%", "padding": "8px", "gap": "8px"},
|
||||||
|
"subwidgets": [
|
||||||
|
{
|
||||||
|
"widgettype": "Tabular",
|
||||||
|
"id": "supplier_table",
|
||||||
|
"options": {
|
||||||
|
"width": "100%", "css": "card",
|
||||||
|
"data_url": "{{entire_url('/product_management/product_resource_supplier_list/get_product_resource_supplier.dspy')}}",
|
||||||
|
"data_method": "GET", "page_rows": 20,
|
||||||
|
"toolbar": {
|
||||||
|
"tools": [
|
||||||
|
{"name": "add_supplier", "label": "添加供应商"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"row_options": {
|
||||||
|
"browserfields": {
|
||||||
|
"exclouded": ["id", "created_at"],
|
||||||
|
"alters": {
|
||||||
|
"supplier_org_id": {
|
||||||
|
"uitype": "code", "valueField": "supplier_org_id", "textField": "supplier_org_id_text",
|
||||||
|
"params": {"dbname": "supplychain", "table": "suppliers", "tblvalue": "org_id", "tbltext": "supplier_name", "valueField": "supplier_org_id", "textField": "supplier_org_id_text"},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
"status": {"uitype": "code", "data": [{"value": "1", "text": "启用"}, {"value": "0", "text": "禁用"}]}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": [
|
||||||
|
{"name": "product_resource_id", "title": "资源绑定ID", "type": "str", "length": 32, "cwidth": 16},
|
||||||
|
{"name": "supplier_org_id", "title": "供应商", "type": "str", "length": 32, "cwidth": 16},
|
||||||
|
{"name": "priority", "title": "优先级", "type": "int", "cwidth": 8},
|
||||||
|
{"name": "weight", "title": "权重", "type": "int", "cwidth": 8},
|
||||||
|
{"name": "status", "title": "状态", "type": "char", "length": 1, "cwidth": 6}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"binds": [{
|
||||||
|
"wid": "self", "event": "add_supplier",
|
||||||
|
"actiontype": "urlwidget", "target": "PopupWindow",
|
||||||
|
"popup_options": {"title": "添加供应商", "cwidth": 40, "cheight": 20},
|
||||||
|
"options": {"url": "{{entire_url('/product_management/product_resource_supplier_list/add_product_resource_supplier.dspy')}}"}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
78
wwwroot/product_subscription_list/index.ui
Normal file
78
wwwroot/product_subscription_list/index.ui
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"widgettype": "VBox",
|
||||||
|
"options": {"width": "100%", "padding": "8px", "gap": "8px"},
|
||||||
|
"subwidgets": [
|
||||||
|
{
|
||||||
|
"widgettype": "InlineForm",
|
||||||
|
"id": "sub_filter",
|
||||||
|
"options": {
|
||||||
|
"css": "card", "padding": "8px", "submit_label": "查询",
|
||||||
|
"fields": [
|
||||||
|
{"name": "user_id", "label": "用户ID", "uitype": "str", "cwidth": 12},
|
||||||
|
{"name": "status", "label": "状态", "uitype": "code", "cwidth": 8,
|
||||||
|
"data": [{"value": "", "text": "全部"}, {"value": "1", "text": "活跃"}, {"value": "2", "text": "已过期"}, {"value": "3", "text": "已取消"}, {"value": "4", "text": "已超额"}]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"binds": [{
|
||||||
|
"wid": "self", "event": "submit",
|
||||||
|
"actiontype": "script", "target": "sub_table",
|
||||||
|
"script": "var tbl = bricks.getWidgetById('sub_table', bricks.app.root); if(tbl) await tbl.render(params);"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"widgettype": "Tabular",
|
||||||
|
"id": "sub_table",
|
||||||
|
"options": {
|
||||||
|
"width": "100%", "css": "card",
|
||||||
|
"data_url": "{{entire_url('/product_management/product_subscription_list/get_product_subscription.dspy')}}",
|
||||||
|
"data_method": "GET", "page_rows": 20,
|
||||||
|
"toolbar": {
|
||||||
|
"tools": [
|
||||||
|
{"name": "view_detail", "label": "查看详情", "selected_row": true},
|
||||||
|
{"name": "cancel_sub", "label": "取消订购", "selected_row": true}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"row_options": {
|
||||||
|
"browserfields": {
|
||||||
|
"exclouded": ["id", "created_at", "updated_at"],
|
||||||
|
"alters": {
|
||||||
|
"product_id": {
|
||||||
|
"uitype": "code", "valueField": "product_id", "textField": "product_id_text",
|
||||||
|
"params": {"dbname": "product_management", "table": "product", "tblvalue": "id", "tbltext": "product_name", "valueField": "product_id", "textField": "product_id_text"},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
"subscription_type": {"uitype": "code", "data": [{"value": "1", "text": "包月"}, {"value": "2", "text": "包量"}, {"value": "3", "text": "一次性"}]},
|
||||||
|
"status": {"uitype": "code", "data": [{"value": "1", "text": "活跃"}, {"value": "2", "text": "已过期"}, {"value": "3", "text": "已取消"}, {"value": "4", "text": "已超额"}]},
|
||||||
|
"overflow_mode": {"uitype": "code", "data": [{"value": "1", "text": "转按量"}, {"value": "2", "text": "停服"}]}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": [
|
||||||
|
{"name": "product_id", "title": "产品", "type": "str", "length": 32, "cwidth": 14},
|
||||||
|
{"name": "user_id", "title": "用户ID", "type": "str", "length": 32, "cwidth": 12},
|
||||||
|
{"name": "subscription_type", "title": "类型", "type": "char", "length": 1, "cwidth": 6},
|
||||||
|
{"name": "status", "title": "状态", "type": "char", "length": 1, "cwidth": 6},
|
||||||
|
{"name": "start_date", "title": "开始", "type": "date", "cwidth": 10},
|
||||||
|
{"name": "end_date", "title": "结束", "type": "date", "cwidth": 10},
|
||||||
|
{"name": "quota_total", "title": "总配额", "type": "double", "length": 15, "dec": 4, "cwidth": 10},
|
||||||
|
{"name": "quota_used", "title": "已用", "type": "double", "length": 15, "dec": 4, "cwidth": 10},
|
||||||
|
{"name": "overflow_mode", "title": "超额", "type": "char", "length": 1, "cwidth": 6},
|
||||||
|
{"name": "purchase_price", "title": "购买价", "type": "double", "length": 15, "dec": 2, "cwidth": 8}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"binds": [
|
||||||
|
{
|
||||||
|
"wid": "self", "event": "view_detail",
|
||||||
|
"actiontype": "urlwidget", "target": "PopupWindow",
|
||||||
|
"popup_options": {"title": "订购详情", "cwidth": 40, "cheight": 20},
|
||||||
|
"options": {"url": "{{entire_url('/product_management/api/subscription_detail.dspy')}}?id=${id}$"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wid": "self", "event": "cancel_sub",
|
||||||
|
"actiontype": "script", "target": "self",
|
||||||
|
"script": "var dv = bricks.getWidgetById('sub_table', bricks.app.root); if(!dv || !dv.select_row) { alert('请先选中一条记录'); return; } var row = dv.select_row.user_data; if(!confirm('确认取消此订购?')) return; var resp = await fetch('{{entire_url('/product_management/api/subscription_cancel.dspy')}}?id=' + row.id); var d = await resp.json(); alert(d.success ? '已取消' : '失败: ' + d.message); if(d.success) await dv.render({});"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
67
wwwroot/product_usage_log_list/index.ui
Normal file
67
wwwroot/product_usage_log_list/index.ui
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"widgettype": "VBox",
|
||||||
|
"options": {"width": "100%", "padding": "8px", "gap": "8px"},
|
||||||
|
"subwidgets": [
|
||||||
|
{
|
||||||
|
"widgettype": "InlineForm",
|
||||||
|
"id": "log_filter",
|
||||||
|
"options": {
|
||||||
|
"css": "card", "padding": "8px", "submit_label": "查询",
|
||||||
|
"fields": [
|
||||||
|
{"name": "product_id", "label": "产品ID", "uitype": "str", "cwidth": 10},
|
||||||
|
{"name": "supplier_org_id", "label": "供应商ID", "uitype": "str", "cwidth": 10},
|
||||||
|
{"name": "billing_mode", "label": "计费模式", "uitype": "code", "cwidth": 8,
|
||||||
|
"data": [{"value": "", "text": "全部"}, {"value": "1", "text": "配额内"}, {"value": "2", "text": "超额按量"}]},
|
||||||
|
{"name": "start_date", "label": "开始日期", "uitype": "date", "cwidth": 10},
|
||||||
|
{"name": "end_date", "label": "结束日期", "uitype": "date", "cwidth": 10}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"binds": [{
|
||||||
|
"wid": "self", "event": "submit",
|
||||||
|
"actiontype": "script", "target": "log_table",
|
||||||
|
"script": "var tbl = bricks.getWidgetById('log_table', bricks.app.root); if(tbl) await tbl.render(params);"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"widgettype": "Tabular",
|
||||||
|
"id": "log_table",
|
||||||
|
"options": {
|
||||||
|
"width": "100%", "css": "card",
|
||||||
|
"data_url": "{{entire_url('/product_management/product_usage_log_list/get_product_usage_log.dspy')}}",
|
||||||
|
"data_method": "GET", "page_rows": 20,
|
||||||
|
"row_options": {
|
||||||
|
"browserfields": {
|
||||||
|
"exclouded": ["id", "created_at", "source_ref_table", "source_ref_id", "product_resource_id"],
|
||||||
|
"alters": {
|
||||||
|
"product_id": {
|
||||||
|
"uitype": "code", "valueField": "product_id", "textField": "product_id_text",
|
||||||
|
"params": {"dbname": "product_management", "table": "product", "tblvalue": "id", "tbltext": "product_name", "valueField": "product_id", "textField": "product_id_text"},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
"supplier_org_id": {
|
||||||
|
"uitype": "code", "valueField": "supplier_org_id", "textField": "supplier_org_id_text",
|
||||||
|
"params": {"dbname": "supplychain", "table": "suppliers", "tblvalue": "org_id", "tbltext": "supplier_name", "valueField": "supplier_org_id", "textField": "supplier_org_id_text"},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
"resource_type": {"uitype": "code", "data": [{"value": "llm_model", "text": "大模型按量"}, {"value": "llm_monthly", "text": "大模型包月"}, {"value": "compute", "text": "算力"}]},
|
||||||
|
"billing_mode": {"uitype": "code", "data": [{"value": "1", "text": "配额内"}, {"value": "2", "text": "超额按量"}]}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": [
|
||||||
|
{"name": "product_id", "title": "产品", "type": "str", "length": 32, "cwidth": 12},
|
||||||
|
{"name": "user_id", "title": "用户", "type": "str", "length": 32, "cwidth": 10},
|
||||||
|
{"name": "supplier_org_id", "title": "供应商", "type": "str", "length": 32, "cwidth": 12},
|
||||||
|
{"name": "resource_type", "title": "资源类型", "type": "str", "length": 32, "cwidth": 8},
|
||||||
|
{"name": "used_amount", "title": "消耗量", "type": "double", "length": 15, "dec": 4, "cwidth": 8},
|
||||||
|
{"name": "used_unit", "title": "单位", "type": "str", "length": 32, "cwidth": 6},
|
||||||
|
{"name": "unit_cost", "title": "单位成本", "type": "double", "length": 15, "dec": 8, "cwidth": 10},
|
||||||
|
{"name": "total_cost", "title": "总成本", "type": "double", "length": 15, "dec": 6, "cwidth": 8},
|
||||||
|
{"name": "sell_price", "title": "售价", "type": "double", "length": 15, "dec": 6, "cwidth": 8},
|
||||||
|
{"name": "billing_mode", "title": "计费", "type": "char", "length": 1, "cwidth": 6},
|
||||||
|
{"name": "use_time", "title": "消费时间", "type": "datetime", "cwidth": 14}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user