diff --git a/product_management/__init__.py b/product_management/__init__.py index 8404da7..5e13ff8 100644 --- a/product_management/__init__.py +++ b/product_management/__init__.py @@ -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 diff --git a/product_management/core.py b/product_management/core.py index 007574f..43978a0 100644 --- a/product_management/core.py +++ b/product_management/core.py @@ -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) \ diff --git a/wwwroot/storefront/api/purchase_confirm.dspy b/wwwroot/storefront/api/purchase_confirm.dspy index 6aad5ae..1806b70 100644 --- a/wwwroot/storefront/api/purchase_confirm.dspy +++ b/wwwroot/storefront/api/purchase_confirm.dspy @@ -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'," diff --git a/wwwroot/storefront/index.ui b/wwwroot/storefront/index.ui index b5daf08..6dc2cb5 100644 --- a/wwwroot/storefront/index.ui +++ b/wwwroot/storefront/index.ui @@ -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",