From e8860401bccb9b52479d7305ec0b1e2c338de02b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 20 Jun 2026 12:10:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=A7=E5=93=81=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=8A=9F=E8=83=BD=E5=AE=9E=E7=8E=B0=20?= =?UTF-8?q?=E2=80=94=20=E8=B5=84=E6=BA=90=E7=BB=91=E5=AE=9A/=E5=A4=9A?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E8=B7=AF=E7=94=B1/=E5=8C=85?= =?UTF-8?q?=E6=9C=88=E8=AE=A2=E8=B4=AD/=E6=B6=88=E8=80=97=E5=BC=95?= =?UTF-8?q?=E6=93=8E/=E6=88=90=E6=9C=AC=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增模型: 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 完整架构规范 --- DESIGN.md | 478 ++++++++++++++ init/data.json | 67 +- json/product_resource_list.json | 7 + json/product_resource_supplier_list.json | 7 + json/product_subscription_list.json | 10 + json/product_usage_log_list.json | 13 + models/product_resource.json | 58 ++ models/product_resource_supplier.json | 45 ++ models/product_subscription.json | 83 +++ models/product_usage_log.json | 67 ++ mysql.ddl.sql | 127 ++++ product_management/core.py | 606 ++++++++++++++++++ scripts/load_path.py | 42 ++ wwwroot/api/product_resource_bind.dspy | 17 + wwwroot/api/product_resource_unbind.dspy | 11 + wwwroot/api/product_resources_list.dspy | 11 + wwwroot/api/product_use_api.dspy | 18 + wwwroot/api/quota_check.dspy | 11 + wwwroot/api/resource_overflow_set.dspy | 12 + wwwroot/api/resource_supplier_add.dspy | 14 + wwwroot/api/resource_supplier_priority.dspy | 13 + wwwroot/api/resource_supplier_remove.dspy | 11 + wwwroot/api/subscribe_product.dspy | 15 + wwwroot/api/subscription_cancel.dspy | 11 + wwwroot/api/subscription_detail.dspy | 11 + wwwroot/api/subscriptions_list.dspy | 14 + wwwroot/api/usage_logs.dspy | 21 + wwwroot/api/usage_stats.dspy | 13 + wwwroot/product_resource_list/index.ui | 82 +++ .../product_resource_supplier_list/index.ui | 46 ++ wwwroot/product_subscription_list/index.ui | 78 +++ wwwroot/product_usage_log_list/index.ui | 67 ++ 32 files changed, 2085 insertions(+), 1 deletion(-) create mode 100644 DESIGN.md create mode 100644 json/product_resource_list.json create mode 100644 json/product_resource_supplier_list.json create mode 100644 json/product_subscription_list.json create mode 100644 json/product_usage_log_list.json create mode 100644 models/product_resource.json create mode 100644 models/product_resource_supplier.json create mode 100644 models/product_subscription.json create mode 100644 models/product_usage_log.json create mode 100644 wwwroot/api/product_resource_bind.dspy create mode 100644 wwwroot/api/product_resource_unbind.dspy create mode 100644 wwwroot/api/product_resources_list.dspy create mode 100644 wwwroot/api/product_use_api.dspy create mode 100644 wwwroot/api/quota_check.dspy create mode 100644 wwwroot/api/resource_overflow_set.dspy create mode 100644 wwwroot/api/resource_supplier_add.dspy create mode 100644 wwwroot/api/resource_supplier_priority.dspy create mode 100644 wwwroot/api/resource_supplier_remove.dspy create mode 100644 wwwroot/api/subscribe_product.dspy create mode 100644 wwwroot/api/subscription_cancel.dspy create mode 100644 wwwroot/api/subscription_detail.dspy create mode 100644 wwwroot/api/subscriptions_list.dspy create mode 100644 wwwroot/api/usage_logs.dspy create mode 100644 wwwroot/api/usage_stats.dspy create mode 100644 wwwroot/product_resource_list/index.ui create mode 100644 wwwroot/product_resource_supplier_list/index.ui create mode 100644 wwwroot/product_subscription_list/index.ui create mode 100644 wwwroot/product_usage_log_list/index.ui diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..92ce171 --- /dev/null +++ b/DESIGN.md @@ -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 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)) + } diff --git a/scripts/load_path.py b/scripts/load_path.py index d9e4963..725c6ac 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -91,6 +91,48 @@ PATHS_LOGINED = [ 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/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", ] # ============================================================ diff --git a/wwwroot/api/product_resource_bind.dspy b/wwwroot/api/product_resource_bind.dspy new file mode 100644 index 0000000..03f9c70 --- /dev/null +++ b/wwwroot/api/product_resource_bind.dspy @@ -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) diff --git a/wwwroot/api/product_resource_unbind.dspy b/wwwroot/api/product_resource_unbind.dspy new file mode 100644 index 0000000..274f2ff --- /dev/null +++ b/wwwroot/api/product_resource_unbind.dspy @@ -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) diff --git a/wwwroot/api/product_resources_list.dspy b/wwwroot/api/product_resources_list.dspy new file mode 100644 index 0000000..baf3adf --- /dev/null +++ b/wwwroot/api/product_resources_list.dspy @@ -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) diff --git a/wwwroot/api/product_use_api.dspy b/wwwroot/api/product_use_api.dspy new file mode 100644 index 0000000..f22d9ef --- /dev/null +++ b/wwwroot/api/product_use_api.dspy @@ -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) diff --git a/wwwroot/api/quota_check.dspy b/wwwroot/api/quota_check.dspy new file mode 100644 index 0000000..4da2e73 --- /dev/null +++ b/wwwroot/api/quota_check.dspy @@ -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) diff --git a/wwwroot/api/resource_overflow_set.dspy b/wwwroot/api/resource_overflow_set.dspy new file mode 100644 index 0000000..04804ad --- /dev/null +++ b/wwwroot/api/resource_overflow_set.dspy @@ -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) diff --git a/wwwroot/api/resource_supplier_add.dspy b/wwwroot/api/resource_supplier_add.dspy new file mode 100644 index 0000000..c14d558 --- /dev/null +++ b/wwwroot/api/resource_supplier_add.dspy @@ -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) diff --git a/wwwroot/api/resource_supplier_priority.dspy b/wwwroot/api/resource_supplier_priority.dspy new file mode 100644 index 0000000..f2ac723 --- /dev/null +++ b/wwwroot/api/resource_supplier_priority.dspy @@ -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) diff --git a/wwwroot/api/resource_supplier_remove.dspy b/wwwroot/api/resource_supplier_remove.dspy new file mode 100644 index 0000000..9c786d2 --- /dev/null +++ b/wwwroot/api/resource_supplier_remove.dspy @@ -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) diff --git a/wwwroot/api/subscribe_product.dspy b/wwwroot/api/subscribe_product.dspy new file mode 100644 index 0000000..78954cd --- /dev/null +++ b/wwwroot/api/subscribe_product.dspy @@ -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) diff --git a/wwwroot/api/subscription_cancel.dspy b/wwwroot/api/subscription_cancel.dspy new file mode 100644 index 0000000..f0186bf --- /dev/null +++ b/wwwroot/api/subscription_cancel.dspy @@ -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) diff --git a/wwwroot/api/subscription_detail.dspy b/wwwroot/api/subscription_detail.dspy new file mode 100644 index 0000000..9839af7 --- /dev/null +++ b/wwwroot/api/subscription_detail.dspy @@ -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) diff --git a/wwwroot/api/subscriptions_list.dspy b/wwwroot/api/subscriptions_list.dspy new file mode 100644 index 0000000..d146f7b --- /dev/null +++ b/wwwroot/api/subscriptions_list.dspy @@ -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) diff --git a/wwwroot/api/usage_logs.dspy b/wwwroot/api/usage_logs.dspy new file mode 100644 index 0000000..818c574 --- /dev/null +++ b/wwwroot/api/usage_logs.dspy @@ -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) diff --git a/wwwroot/api/usage_stats.dspy b/wwwroot/api/usage_stats.dspy new file mode 100644 index 0000000..6935b6b --- /dev/null +++ b/wwwroot/api/usage_stats.dspy @@ -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) diff --git a/wwwroot/product_resource_list/index.ui b/wwwroot/product_resource_list/index.ui new file mode 100644 index 0000000..9c6a5f6 --- /dev/null +++ b/wwwroot/product_resource_list/index.ui @@ -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({});" + } + ] + } + ] +} diff --git a/wwwroot/product_resource_supplier_list/index.ui b/wwwroot/product_resource_supplier_list/index.ui new file mode 100644 index 0000000..4507ee9 --- /dev/null +++ b/wwwroot/product_resource_supplier_list/index.ui @@ -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')}}"} + }] + } + ] +} diff --git a/wwwroot/product_subscription_list/index.ui b/wwwroot/product_subscription_list/index.ui new file mode 100644 index 0000000..e69fb80 --- /dev/null +++ b/wwwroot/product_subscription_list/index.ui @@ -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({});" + } + ] + } + ] +} diff --git a/wwwroot/product_usage_log_list/index.ui b/wwwroot/product_usage_log_list/index.ui new file mode 100644 index 0000000..550613f --- /dev/null +++ b/wwwroot/product_usage_log_list/index.ui @@ -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} + ] + } + } + } + ] +}