feat(storefront): 显示价=定价×客户折扣率(三类统一)+产线订阅购买链路+修既有控件bug

1) get_customer_price_display 新增(core.py + __init__.py + env 注册)
   统一三类产品对外报价口径:显示价 = 资源真实定价 × 客户折扣率
   - 产线 pipeline_pricing_map / 存储 storres_pricing_map / 模型 llm_model.ppid
     各自解析 ppid → get_pricing_display 取单位价 → × 折扣
   - 折扣率解析走 discount 模块 get_min_product_discount(专属优先/'*'兜底/1.0)
     >1 加价(存储1.5)、<1 让利(百炼0.9);折扣≠1 时返回 original_text 原价
   - 未登录(user_org_id 空)传 '' 只命中 '*' 通用折扣 → 访客可见报价

2) purchase_realtime 加产线分支(product_type=='pipeline')
   订阅制:无 usage 表,直接算价→余额预检→product_accounting_generic 实时记账→开订阅权益
   charge_mode month/year,entitlement.duration_days 30/365

3) 展示页 index.ui:接 get_customer_price_display;产线卡显示月/年价+原价划线+订阅态文案;
   按钮按订阅状态分流(已订阅→进入产线 / 未订阅→立即购买);product_code 兼容
   opportunity_general/bidding_general/sdlc_general 与旧 PIPE-* 两套映射

4) 修两个既有 bug(购买页原本就坏):
   - InputNumber 控件 bricks 未注册 → widgetBuild 返回 null 静默不渲染,改 UiInt
   - 支付脚本 getWidgetById 缺第二参数(查找起点) → from_widget undefined 抛 TypeError
     点击支付无反应,补 bricks.app.root;UiType 取值 .value → resultValue()
   - UiType 系控件同时传 name(表单/getValue)+id(set_id 覆盖 DOM id 供 getWidgetById)

本地 mock 渲染测试 6 项全过:三类价格/订阅态分流/规划中占位/未登录访客可见价无购买按钮
This commit is contained in:
yumoqing 2026-09-06 16:55:49 +08:00
parent 275806c1cc
commit 1f399d6b00
4 changed files with 239 additions and 35 deletions

View File

@ -91,6 +91,12 @@ async def get_product_display_info(product_id=None, product_code=None):
return await manager.get_product_display_info(product_id, product_code)
async def get_customer_price_display(product_id=None, product_code=None, user_org_id=None):
"""客户视角展示价 = 资源真实定价 × 客户折扣率(产线/存储/模型三类统一口径)。"""
manager = get_manager()
return await manager.get_customer_price_display(product_id, product_code, user_org_id)
async def check_product_availability(product_id=None, product_code=None, user_org_id=None):
"""Check product availability via resource module interface."""
manager = get_manager()
@ -182,6 +188,7 @@ def load_product_management():
env.import_categories_and_products = import_categories_and_products
# Resource module interface dispatchers
env.get_product_display_info = get_product_display_info
env.get_customer_price_display = get_customer_price_display
env.check_product_availability = check_product_availability
env.check_product_consumable = check_product_consumable
env.execute_product = execute_product

View File

@ -1293,6 +1293,142 @@ class ProductManager:
return {'success': False, 'message': err}
return await fn(ref_id)
async def get_customer_price_display(self, product_id=None, product_code=None,
user_org_id=None):
"""客户视角展示价 = 资源真实定价 × 客户折扣率(2026-09-06 新增)。
统一三类产品的对外报价口径(用户明确要求「产品显示的价格要用定价里
价格×客户的折扣率」):
产线 pipeline_pricing_map → ppid → 单位价(元/月) × 折扣
存储 storres_pricing_map → ppid → 单位价(元/GB月) × 折扣
模型 llm_model.ppid → ppid → 单位价(元/百万tokens 等) × 折扣
客户折扣率解析(discount 模块,专属优先 / '*' 兜底 / 无记录 1.0):
>1 = 加价卖给客户(如存储 1.5),<1 = 让利(如百炼模型 0.9)。
未登录(user_org_id 空)时用 '*' 通用折扣报价——展示页对访客可见。
Returns:
{'success', 'prices': [{'label','unit_price','unit_label','amount','unit'}],
'pricing_text', 'discount', 'original_text'}
"""
iface, product, err = await self._get_product_interface(
product_id, product_code)
if err:
return {'success': False, 'message': err}
product = product or {}
ref_id, err = self._get_ref_id(product)
if err:
return {'success': False, 'message': err}
env = ServerEnv()
product_type = product.get('product_type', '') or ''
pid = product.get('id') or ''
resellerid = product.get('org_id') or '0'
# 1. 客户折扣率(未登录 → 传 '' 让 discount 模块只命中 '*' 兜底)
discount = 1.0
try:
d = await env.get_min_product_discount(pid, resellerid, user_org_id or '')
if d not in (None, '', 0):
discount = float(d)
except Exception:
discount = 1.0
# 2. 按产品类型解析 ppid 列表 [(charge_mode/meter_mode 标签, ppid)]
dbname = self._get_dbname()
ppid_pairs = []
try:
async with DBPools().sqlorContext(dbname) as sor:
if product_type == 'pipeline':
recs = await sor.sqlExe(
"SELECT charge_mode, ppid, months, valid_days FROM pipeline_pricing_map "
"WHERE pipeline_id=${rid}$ AND status='active' ORDER BY months",
{'rid': ref_id})
for r in (recs or []):
cm = str(getattr(r, 'charge_mode', '') or '')
ppid_pairs.append(('包年' if cm == 'year' else '包月',
getattr(r, 'ppid', '') or ''))
elif product_type == 'workspace_storage':
recs = await sor.sqlExe(
"SELECT meter_mode, ppid FROM storres_pricing_map "
"WHERE spec_id=${rid}$ AND status='active'",
{'rid': ref_id})
seen = set()
for r in (recs or []):
ppid = getattr(r, 'ppid', '') or ''
if ppid and ppid not in seen:
seen.add(ppid)
ppid_pairs.append(('容量', ppid))
else:
# 模型类(pipeline_llm_model / llm_model):ppid 挂模型表
recs = await sor.sqlExe(
"SELECT ppid FROM llm_model WHERE id=${rid}$", {'rid': ref_id})
if recs:
ppid = getattr(recs[0], 'ppid', '') or ''
if ppid:
ppid_pairs.append(('按量', ppid))
await sor.sqlExe("COMMIT", {})
except Exception as e:
debug(f'get_customer_price_display: ppid 解析失败 {pid}: {e}')
if not ppid_pairs:
return {'success': False, 'message': '未配置定价方案',
'prices': [], 'pricing_text': '未配置定价',
'discount': discount, 'original_text': ''}
# 3. 取定价单位价 → × 折扣率
prices = []
for label, ppid in ppid_pairs:
if not ppid:
continue
try:
pd = await env.get_pricing_display(ppid)
except Exception:
pd = None
if not pd:
continue
for item in (pd.get('items') or []):
for pf in (item.get('price_factors') or []):
up = pf.get('unit_price')
if up is None:
continue
up = float(up)
prices.append({
'label': label,
'factor': pf.get('factor', ''),
'factor_label': pf.get('label', ''),
'unit': pf.get('unit', ''),
'unit_label': pf.get('unit_label', ''),
'original_price': round(up, 6),
'amount': round(up * discount, 6),
})
if not prices:
return {'success': False, 'message': '定价数据为空',
'prices': [], 'pricing_text': '未配置定价',
'discount': discount, 'original_text': ''}
# 4. 拼展示文本(折扣≠1 时同时给原价,让客户看见让利/加价幅度)
parts = []
orig_parts = []
for p in prices:
head = p['label'] if p['label'] and p['label'] not in ('按量', '容量') else ''
seg = ('%s ' % head if head else '') + '¥%.4g %s' % (
p['amount'], p['unit_label'] or '元')
parts.append(seg.strip())
if abs(discount - 1.0) > 1e-9:
orig_parts.append('%.4g' % p['original_price'])
pricing_text = ' | '.join(parts)
original_text = ''
if orig_parts:
original_text = '原价 %s %s' % (
' / '.join(orig_parts), prices[0]['unit_label'] or '元')
return {'success': True, 'prices': prices, 'pricing_text': pricing_text,
'original_text': original_text, 'discount': discount}
async def check_product_availability(self, product_id=None, product_code=None,
user_org_id=None):
"""检查产品可用性(通过资源模块接口)。"""
@ -1507,6 +1643,19 @@ class ProductManager:
'duration_days': duration_days}
usage_data = {'meter_mode': 'direct', 'storage_gb': storage_gb,
'valid_months': valid_months}
elif product_type == 'pipeline':
# 产线使用费(订阅制):无 usage 表,直接算价+实时记账+开权益
# (2026-09-06 产线产品化:月付 months=1 / 年付 months=10=月费×10)
from pipeline_core.pipeline_pricing import calculate_pipeline_amount
charging_module = 'pipeline'
cm = charge_mode or 'month'
if cm not in ('month', 'year'):
return {'success': False, 'message': '产线只支持 charge_mode=month/year'}
amount, ppid, months, duration_days = await calculate_pipeline_amount(ref_id, cm)
entitlement = {'duration_days': duration_days, 'charge_mode': cm,
'pipeline_id': ref_id}
usage_data = {'charge_mode': cm, 'months': months}
else:
return {'success': False,
'message': f'产品类型({product_type})暂不支持实时购买'}
@ -1527,11 +1676,17 @@ class ProductManager:
raise Exception(f'余额不足:可用 {balance:.2f},应付 {sell_amount:.2f},请先充值')
# ── 实时复式记账(内部写 biz_order + 分录,余额不足会抛 AccountOverDraw)──
rec_obj = DictObject(**usage_rec) if isinstance(usage_rec, dict) else usage_rec
if charging_module == 'account':
acc = await self.account_resource_accounting(rec_obj)
if charging_module == 'pipeline':
# 产线无 usage 表:直接走通用落账(product_accounting_generic
# 内部再算一次价含客户折扣,与预检同口径)
acc = await self.product_accounting_generic(
product.get('id'), usage_data, user_org_id, user_id)
else:
acc = await self.storage_resource_accounting(rec_obj)
rec_obj = DictObject(**usage_rec) if isinstance(usage_rec, dict) else usage_rec
if charging_module == 'account':
acc = await self.account_resource_accounting(rec_obj)
else:
acc = await self.storage_resource_accounting(rec_obj)
if not acc or not acc.get('success'):
raise Exception(f'记账失败: {acc}')
@ -1548,6 +1703,7 @@ class ProductManager:
except Exception as e:
exception(f'purchase_realtime failed: {e}')
# 回滚:删除已写的待记账用量记录,避免残留半截账
# (产线 charging_module='pipeline' 无 usage 表,usage_rec 恒 None 天然跳过)
if usage_rec is not None:
try:
rid = usage_rec.get('id') if isinstance(usage_rec, dict) \

View File

@ -50,7 +50,11 @@ else:
rows.append(_row('产品名称', getattr(p, 'product_name', ''), '#222', True))
# 参数输入区(存储需容量+月数;账号走默认月租)
# 参数输入区(存储需容量+月数;产线选月付/年付;账号走默认月租)
# ⚠️ 控件名必须是 bricks 实际注册的:数字输入 = UiInt(不是 InputNumber,
# 该名未注册 → widgetBuild 返回 null,控件静默不渲染,2026-09-06 修复)。
# UiType 系控件同时传 name + id:name 供 getValue/表单,id 供 getWidgetById 定位
# (widgetBuild 用 set_id(desc.id) 覆盖 DOM id)。
param_widgets = []
if product_type == 'workspace_storage':
param_widgets.append({
@ -59,8 +63,8 @@ else:
'subwidgets': [
{'widgettype': 'Text', 'options': {
'text': '购买容量(GB)', 'width': '90px', 'fontSize': '13px', 'color': '#666'}},
{'widgettype': 'InputNumber', 'id': 'buy_storage_gb',
'options': {'value': 100, 'min': 1, 'step': 10, 'width': '120px'}}
{'widgettype': 'UiInt', 'id': 'buy_storage_gb',
'options': {'name': 'buy_storage_gb', 'value': 100, 'width': '120px'}}
]
})
param_widgets.append({
@ -69,11 +73,26 @@ else:
'subwidgets': [
{'widgettype': 'Text', 'options': {
'text': '购买月数', 'width': '90px', 'fontSize': '13px', 'color': '#666'}},
{'widgettype': 'InputNumber', 'id': 'buy_valid_months',
'options': {'value': 1, 'min': 1, 'max': 12, 'step': 1, 'width': '120px'}}
{'widgettype': 'UiInt', 'id': 'buy_valid_months',
'options': {'name': 'buy_valid_months', 'value': 1, 'width': '120px'}}
]
})
rows.append(_row('计费方式', '按容量直购(元/GB月)', '#666'))
elif product_type == 'pipeline':
# 产线使用费:月付 / 年付(年费=月费×10,即付10个月用12个月)
param_widgets.append({
'widgettype': 'HBox',
'options': {'padding': '8px 0', 'gap': '8px', 'alignItems': 'center'},
'subwidgets': [
{'widgettype': 'Text', 'options': {
'text': '订阅周期', 'width': '90px', 'fontSize': '13px', 'color': '#666'}},
{'widgettype': 'UiCode', 'id': 'buy_charge_mode',
'options': {'name': 'buy_charge_mode', 'value': 'month', 'width': '200px',
'data': [{'value': 'month', 'text': '包月(30天)'},
{'value': 'year', 'text': '包年(365天,付10个月)'}]}}
]
})
rows.append(_row('计费方式', '订阅制使用费(模型/存储消耗另按量计费)', '#666'))
elif product_type == 'account':
rows.append(_row('计费方式', '订阅制(月/年/试用)', '#666'))
else:
@ -89,7 +108,12 @@ else:
rows.append(_row('账户余额', balance_text, balance_color, True))
# 确认支付按钮:script+fetch 提交,确保读取输入框当前值
# ⚠️ getWidgetById 必须传第二参数(查找起点):bricks.getWidgetById(idset, from_widget)
# 里 get_by_id 直接读 fromw.dom_element,from_widget 为 undefined 会抛 TypeError
# → 点击支付无任何反应(既有 bug,2026-09-06 修复)。用 bricks.app.root 作起点。
# UiInt 取值用 resultValue()(返回 int),UiCode 取值用 .value(返回字符串)。
is_storage = (product_type == 'workspace_storage')
is_pipeline = (product_type == 'pipeline')
pay_script = (
"var pid='" + product_id + "';"
"var params=new URLSearchParams();"
@ -97,13 +121,20 @@ else:
)
if is_storage:
pay_script += (
"var gb=bricks.getWidgetById('buy_storage_gb');"
"var mo=bricks.getWidgetById('buy_valid_months');"
"var gbv=gb?gb.value:100; var mov=mo?mo.value:1;"
"var gb=bricks.getWidgetById('buy_storage_gb',bricks.app.root);"
"var mo=bricks.getWidgetById('buy_valid_months',bricks.app.root);"
"var gbv=gb?gb.resultValue():100; var mov=mo?mo.resultValue():1;"
"if(!gbv||gbv<=0){alert('请输入购买容量');return;}"
"if(!mov||mov<=0){alert('请输入购买月数');return;}"
"params.append('storage_gb',gbv);"
"params.append('valid_months',mov);"
)
if is_pipeline:
pay_script += (
"var cm=bricks.getWidgetById('buy_charge_mode',bricks.app.root);"
"var cmv=cm?(cm.value||'month'):'month';"
"params.append('charge_mode',cmv);"
)
pay_script += (
"var btn=this; btn.disabled=true;"
"fetch('/product_management/storefront/api/purchase_realtime.dspy',"

View File

@ -1,6 +1,7 @@
{% set cats_r = get_category_tree(org_id='0') %}
{% set cats = (cats_r or {}).get('tree', []) if (cats_r or {}).get('success') else [] %}
{% set userid = get_user() %}
{% set userorgid = get_userorgid() %}
{
"widgettype": "VBox",
"options": {
@ -50,21 +51,17 @@
{% set ns = namespace(first=true) %}
{% for p in prods %}
{% if p.get('is_active', true) %}
{% set disp = get_product_display_info(p.get('id')) %}
{% set pricing_text = (disp or {}).get('pricing_text', '定价暂不可用') if (disp or {}).get('success') else '定价暂不可用' %}
{# 显示价 = 资源真实定价 × 客户折扣率(2026-09-06):未登录按 '*' 通用折扣报价 #}
{% set cust = get_customer_price_display(p.get('id'), user_org_id=userorgid or '') %}
{% set pricing_text = (cust or {}).get('pricing_text', '定价暂不可用') if (cust or {}).get('success') else ((cust or {}).get('message') or '定价暂不可用') %}
{% set original_text = (cust or {}).get('original_text', '') %}
{% set is_pipeline = (p.get('product_type') == 'pipeline') %}
{% set extra = {} %}
{% if is_pipeline %}
{% set extra = {'tab_name': 'pipeline_card_' + str(p.get('product_code','')), 'tab_label': p.get('product_name') or '', 'url': '', 'planned': False, 'message': ''} %}
{% if (p.get('product_code') or '') == 'PIPE-OPP' %}
{% set _ = extra.update({'url': '/pipeline-opportunity/agent'}) %}
{% elif (p.get('product_code') or '') == 'PIPE-BID' %}
{% set _ = extra.update({'url': '/pipeline-bidding/agent'}) %}
{% elif (p.get('product_code') or '') == 'PIPE-DEV' %}
{% set _ = extra.update({'url': '/pipeline_core/agent'}) %}
{% else %}
{% set _ = extra.update({'planned': True, 'message': (p.get('product_name') or '') + '规划中,尚未开发'}) %}
{% endif %}
{# 产线订阅状态:已订阅→「进入产线」直达;未订阅→「立即购买」弹购买确认 #}
{% set sub = check_subscription_valid(p.get('id'), userorgid or '') if userorgid else {'valid': False} %}
{% set _urls = {'PIPE-OPP': '/pipeline-opportunity/agent', 'PIPE-BID': '/pipeline-bidding/agent', 'PIPE-DEV': '/pipeline_core/agent', 'opportunity_general': '/pipeline-opportunity/agent', 'bidding_general': '/pipeline-bidding/agent', 'sdlc_general': '/pipeline_core/agent'} %}
{% set extra = {'tab_name': 'pipeline_card_' + str(p.get('product_code','')), 'tab_label': p.get('product_name') or '', 'url': _urls.get(p.get('product_code') or '', ''), 'planned': not _urls.get(p.get('product_code') or '', ''), 'message': (p.get('product_name') or '') + '规划中,尚未开发', 'subscribed': (sub or {}).get('valid', False), 'sub_end': (sub or {}).get('end_date', '')} %}
{% endif %}
{% if not ns.first %},{% endif %}
{% set ns.first = false %}
@ -147,20 +144,14 @@
}
]
},
{% if is_pipeline %}
{% if is_pipeline and extra.planned %}
{
"widgettype": "Text",
"options": {
{% if extra.planned %}
"text": "规划中",
"fontSize": "14px",
"color": "#94a3b8",
"fontWeight": "600"
{% else %}
"text": "订阅制",
"fontSize": "14px",
"color": "#e74c3c"
{% endif %}
}
},
{% else %}
@ -172,6 +163,27 @@
"color": "#e74c3c"
}
},
{% if original_text %}
{
"widgettype": "Text",
"options": {
"text": {{json.dumps(original_text, ensure_ascii=False)}},
"fontSize": "11px",
"color": "#94a3b8",
"textDecoration": "line-through"
}
},
{% endif %}
{% if is_pipeline %}
{
"widgettype": "Text",
"options": {
"text": {{json.dumps(('订阅制 · 已订阅至 ' + extra.sub_end) if extra.subscribed else '订阅制 · 模型/存储消耗另计', ensure_ascii=False)}},
"fontSize": "11px",
"color": {{json.dumps('#27ae60' if extra.subscribed else '#94a3b8')}}
}
},
{% endif %}
{% endif %}
{
"widgettype": "HBox",
@ -180,8 +192,7 @@
"justifyContent": "flex-end"
},
"subwidgets": [
{% if is_pipeline %}
{% if extra.planned %}
{% if is_pipeline and extra.planned %}
{
"widgettype": "Text",
"options": {
@ -190,7 +201,7 @@
"color": "#94a3b8"
}
}
{% else %}
{% elif is_pipeline and extra.subscribed %}
{
"widgettype": "Button",
"options": {
@ -210,7 +221,6 @@
}
]
}
{% endif %}
{% elif userid %}
{
"widgettype": "Button",