feat(投顾): 方案交付落点、可视化图表与推荐依据的大模型增强
交付落点
- 新增 GET /api/v1/users/me/advisor-contents(客户读**自己**已发布方案):
「发送给客户」原先只改数据状态、客户端没有任何页面或接口能读到它
- 客户端新增「我的投顾方案」页与导航入口
可视化(投顾结果区与客户页**共用** common/advisor-plan-view.js,避免两处漂移)
- 净值折线图(带坐标轴与网格)、组合业绩等权合成曲线(含区间收益与最大回撤)、
资产配置环形图与图例、组合构成条
- 修 num(null)=0 的假 0:Number(null)/Number('') 会得 0,导致「没数据」被渲染成 0.00%;
现一律显示「--」。同理管理费/起投未维护时按没数据处理,不显示 0
- 涨跌口径为「涨红跌绿」(A 股习惯),由 CSS 变量 --plan-up / --plan-down 集中定义
推荐依据接入大模型(可选,失败即回退)
- 新增 AdvisorReasonService:**只改文案,不参与选品**(候选池与排序在它之前已固定)
- 输入只允许是已算出的真实参数(风险等级、排序得分、区间收益、最大回撤、期限与流动性)
- 命中收益承诺词(保本/保证收益/稳赚/无风险…)整条丢弃并回退规则文案
- 未启用 / 缺密钥 / 超时 / 解析失败一律回退,推荐主流程不因模型不可用而失败
- 前端标注来源(AI 生成 / 规则生成)
数据与权限
- 客户角色补齐:绑 customer 角色、补建缺失的账户与交易段权限码(9060-9065)
- 净值全量同步(20 只产品),行情同步脚本按 --codes 分块(全量一次会被超时终止)
测试
- 新增 tests/unit/service/test_advisor_reason_service.py(10 项,专测三条合规边界)
- 前端模块自检纳入 service-request-module;补「两处共用同一渲染」回归测试
This commit is contained in:
@@ -21,6 +21,15 @@ admin_router = APIRouter(
|
|||||||
tags=["platform-admin"],
|
tags=["platform-admin"],
|
||||||
dependencies=[Depends(enforce_rate_limit)],
|
dependencies=[Depends(enforce_rate_limit)],
|
||||||
)
|
)
|
||||||
|
#: 客户自助路由:客户看**自己**已发布的投顾交付物。
|
||||||
|
#: 前缀挂在 `/api/v1/users/me` 下,与 §T 段(交易)同一约定。
|
||||||
|
#: **故意不挂 `enforce_advisor_rollout`** —— 那是投顾业务的灰度闸门(按
|
||||||
|
#: `sys_customer_assignment` 归属命中白名单),客户看自己的交付物不该被它拦下。
|
||||||
|
client_router = APIRouter(
|
||||||
|
prefix="/api/v1/users/me",
|
||||||
|
tags=["advisor-deliveries"],
|
||||||
|
dependencies=[Depends(enforce_rate_limit)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@advisor_router.post("/recommendations")
|
@advisor_router.post("/recommendations")
|
||||||
@@ -41,6 +50,19 @@ async def published_recommendations(
|
|||||||
return await ProductRecommendationService().published(context)
|
return await ProductRecommendationService().published(context)
|
||||||
|
|
||||||
|
|
||||||
|
@advisor_router.get("/recommendations/history")
|
||||||
|
async def recommendation_history(
|
||||||
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""历史方案留档:本人 + 名下客户的**全部状态**方案与方案书。
|
||||||
|
|
||||||
|
与 `/recommendations/published` 分开的原因:published 只给"已发布、对客户可见"的内容,
|
||||||
|
投顾刚生成、还在待审的草案不在其中。历史记录面板要的是"以前生成过什么",
|
||||||
|
所以这里返回全部状态,按生成时间倒序。
|
||||||
|
"""
|
||||||
|
return await ProductRecommendationService().history(context)
|
||||||
|
|
||||||
|
|
||||||
# ---- 投顾自助审核/发布(2026-09-14 新增)------------------------------------
|
# ---- 投顾自助审核/发布(2026-09-14 新增)------------------------------------
|
||||||
#
|
#
|
||||||
# 为什么要有这两个**投顾侧**路由:审核/发布原先只在 `/api/v1/admin/advisor/...`
|
# 为什么要有这两个**投顾侧**路由:审核/发布原先只在 `/api/v1/admin/advisor/...`
|
||||||
@@ -76,6 +98,20 @@ async def advisor_publish_recommendation(
|
|||||||
return await ProductRecommendationService().publish(content_id, context, key)
|
return await ProductRecommendationService().publish(content_id, context, key)
|
||||||
|
|
||||||
|
|
||||||
|
@advisor_router.delete("/recommendations/{content_id}")
|
||||||
|
async def advisor_delete_recommendation(
|
||||||
|
content_id: int = Path(gt=0),
|
||||||
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||||
|
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""删除推荐方案(历史记录里的「删除」按钮)。
|
||||||
|
|
||||||
|
仅推荐方案可删:投资方案书被 `advisor_investment_goal.goal_book_content_id`
|
||||||
|
(`NO ACTION` 外键、`NOT NULL`)引用,硬删会撞外键 —— 方案书走自己的生命周期。
|
||||||
|
"""
|
||||||
|
return await ProductRecommendationService().delete(content_id, context, key)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get(
|
@admin_router.get(
|
||||||
"/advisor/pending-contents",
|
"/advisor/pending-contents",
|
||||||
dependencies=[Depends(enforce_advisor_rollout)],
|
dependencies=[Depends(enforce_advisor_rollout)],
|
||||||
@@ -123,3 +159,16 @@ async def publish_recommendation(
|
|||||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
return await ProductRecommendationService().publish(content_id, context, key)
|
return await ProductRecommendationService().publish(content_id, context, key)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 客户侧:接收投顾交付物 ------------------------------------------------
|
||||||
|
#
|
||||||
|
# 「发送给客户」此前**没有落点** —— 投顾发布后只是把 `published_at` 置上,
|
||||||
|
# 客户端门户没有任何页面/接口能读到它(客户"可见"只体现在数据口径上)。
|
||||||
|
# 这条路由补上落点:客户登录后看**自己**已发布的推荐方案与方案书。
|
||||||
|
@client_router.get("/advisor-contents")
|
||||||
|
async def my_advisor_contents(
|
||||||
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""我的投顾方案:本人已审核发布的投顾交付物(按发布时间倒序,最多 50 条)。"""
|
||||||
|
return await ProductRecommendationService().my_published(context)
|
||||||
|
|||||||
@@ -102,6 +102,18 @@ class Settings(BaseSettings):
|
|||||||
offsite_deepseek_api_key: str = ""
|
offsite_deepseek_api_key: str = ""
|
||||||
offsite_deepseek_model: str = "deepseek-v4-flash"
|
offsite_deepseek_model: str = "deepseek-v4-flash"
|
||||||
offsite_deepseek_timeout_seconds: float = Field(default=30, gt=0)
|
offsite_deepseek_timeout_seconds: float = Field(default=30, gt=0)
|
||||||
|
#: 平台级 DeepSeek 密钥。`model_endpoint_config.secret_ref` 走 `env:DEEPSEEK_API_KEY`,
|
||||||
|
#: 投顾「推荐依据」的 LLM 增强也复用它 —— 密钥只有一个存放点,不按特性各配一把。
|
||||||
|
deepseek_api_key: str = ""
|
||||||
|
#: 投顾「推荐依据」的 LLM 增强(可选)。未启用、或取不到密钥/调用失败时,
|
||||||
|
#: **自动回退**到确定性的数据化文案:推荐流程绝不因为模型不可用而失败。
|
||||||
|
advisor_reason_llm_enabled: bool = False
|
||||||
|
advisor_reason_llm_base_url: str = "https://api.deepseek.com"
|
||||||
|
#: 实测 `api.deepseek.com`:`deepseek-chat` 正常返回文本;`deepseek-v4-flash` /
|
||||||
|
#: `deepseek-reasoner` 返回 200 但 `content` 为空(推理型内容在 `reasoning_content`),
|
||||||
|
#: 客户端已做兜底读取,但默认仍用最稳的 `deepseek-chat`。
|
||||||
|
advisor_reason_llm_model: str = "deepseek-chat"
|
||||||
|
advisor_reason_llm_timeout_seconds: float = Field(default=20, gt=0)
|
||||||
offsite_smtp_enabled: bool = False
|
offsite_smtp_enabled: bool = False
|
||||||
offsite_smtp_dry_run: bool = True
|
offsite_smtp_dry_run: bool = True
|
||||||
offsite_smtp_host: str = ""
|
offsite_smtp_host: str = ""
|
||||||
|
|||||||
+12
@@ -8,6 +8,12 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from app.api.controllers.admin import router as admin_router
|
from app.api.controllers.admin import router as admin_router
|
||||||
from app.api.controllers.agent_runs import router as agent_runs_router
|
from app.api.controllers.agent_runs import router as agent_runs_router
|
||||||
|
from app.api.controllers.advisor_service_requests import (
|
||||||
|
advisor_router as advisor_service_request_router,
|
||||||
|
)
|
||||||
|
from app.api.controllers.advisor_service_requests import (
|
||||||
|
client_router as advisor_service_request_client_router,
|
||||||
|
)
|
||||||
from app.api.controllers.asset_allocation import router as asset_allocation_router
|
from app.api.controllers.asset_allocation import router as asset_allocation_router
|
||||||
from app.api.controllers.auth import router as auth_router
|
from app.api.controllers.auth import router as auth_router
|
||||||
from app.api.controllers.conversations import router as conversations_router
|
from app.api.controllers.conversations import router as conversations_router
|
||||||
@@ -28,6 +34,9 @@ from app.api.controllers.recommendations import (
|
|||||||
from app.api.controllers.recommendations import (
|
from app.api.controllers.recommendations import (
|
||||||
advisor_router as recommendation_advisor_router,
|
advisor_router as recommendation_advisor_router,
|
||||||
)
|
)
|
||||||
|
from app.api.controllers.recommendations import (
|
||||||
|
client_router as recommendation_client_router,
|
||||||
|
)
|
||||||
from app.api.controllers.risk import router as risk_router
|
from app.api.controllers.risk import router as risk_router
|
||||||
from app.api.controllers.trading import router as trading_router
|
from app.api.controllers.trading import router as trading_router
|
||||||
from app.api.controllers.visitor_tokens import router as visitor_tokens_router
|
from app.api.controllers.visitor_tokens import router as visitor_tokens_router
|
||||||
@@ -136,6 +145,9 @@ def create_app() -> FastAPI:
|
|||||||
application.include_router(asset_allocation_router)
|
application.include_router(asset_allocation_router)
|
||||||
application.include_router(recommendation_advisor_router)
|
application.include_router(recommendation_advisor_router)
|
||||||
application.include_router(recommendation_admin_router)
|
application.include_router(recommendation_admin_router)
|
||||||
|
application.include_router(recommendation_client_router)
|
||||||
|
application.include_router(advisor_service_request_router)
|
||||||
|
application.include_router(advisor_service_request_client_router)
|
||||||
application.include_router(admin_router)
|
application.include_router(admin_router)
|
||||||
application.include_router(trading_router)
|
application.include_router(trading_router)
|
||||||
static_directory = Path(__file__).resolve().parent / "static"
|
static_directory = Path(__file__).resolve().parent / "static"
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""投顾「推荐依据」的 LLM 增强(**可选**,任何一步失败都回退到确定性文案)。
|
||||||
|
|
||||||
|
## 为什么要有它
|
||||||
|
|
||||||
|
投顾工作台账推方案里,每只产品的「推荐依据」原先是一句**所有产品都一样**的套话,
|
||||||
|
客户看不出"为什么选这一只"。这里用大模型把**已经算出来的真实参数**
|
||||||
|
(风险等级、排序得分、区间收益、最大回撤、客户的期限与流动性要求)写成
|
||||||
|
一段面向客户的说明。
|
||||||
|
|
||||||
|
## 合规边界(三条,都在代码里强制执行)
|
||||||
|
|
||||||
|
1. **只用给定数据**:提示词里明确禁止编造数字/业绩/奖项/排名/基金经理信息;
|
||||||
|
2. **禁止收益承诺**:产出命中 `PROHIBITED_PHRASES`(保本/保证收益/稳赚/无风险…)
|
||||||
|
即**整条丢弃** —— 与 `investment_goal_service._PROHIBITED_GOAL_PHRASES` 同一口径;
|
||||||
|
3. **失败即回退**:未启用、缺密钥、超时、HTTP 错误、JSON 解析失败、字段缺失,
|
||||||
|
一律返回空字典,由调用方保留确定性文案。**推荐流程绝不因模型不可用而失败**。
|
||||||
|
|
||||||
|
## 哪些不算数
|
||||||
|
|
||||||
|
本服务**不参与选品**,只改文案。选品仍然是 `ProductRecommendationService` 的
|
||||||
|
硬约束 + 适当性 + 排序,模型看不到也改不了候选池。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: 收益承诺/绝对化表述 —— 命中即丢弃该条 LLM 文案。
|
||||||
|
PROHIBITED_PHRASES: tuple[str, ...] = (
|
||||||
|
"保本", "保证收益", "保收益", "稳赚", "稳赢", "无风险", "零风险",
|
||||||
|
"收益承诺", "包赚", "必赚", "稳赚不赔", "绝对收益", "确保收益", "锁定收益",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: 文案长度边界:太短没信息量、太长在卡片里读不完。
|
||||||
|
MIN_REASON_CHARS = 20
|
||||||
|
MAX_REASON_CHARS = 160
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """你是南方基金的投顾文案助手,为**已通过合规校验**的推荐产品撰写「推荐依据」。
|
||||||
|
|
||||||
|
硬性要求:
|
||||||
|
1. 只能使用我提供的数据,**严禁编造**任何数字、业绩、奖项、排名或基金经理信息;
|
||||||
|
2. **严禁**出现承诺收益或绝对化表述,例如:保本、保证收益、稳赚、无风险、零风险、收益承诺、包赚、必赚;
|
||||||
|
3. 每条 45~80 个汉字,面向个人客户,专业克制、可读,说明"为什么这只产品适合这位客户";
|
||||||
|
4. 必须点出该产品的风险等级,并说明它与客户风险承受能力、投资期限或流动性要求的匹配关系;
|
||||||
|
5. 只输出 JSON,不要 Markdown 代码块、不要任何解释文字。
|
||||||
|
|
||||||
|
输出格式(严格):
|
||||||
|
{"items": [{"product_code": "159329", "reason": "……"}]}"""
|
||||||
|
|
||||||
|
|
||||||
|
def _pct(value: Any) -> str:
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
return "暂无"
|
||||||
|
return f"{value:+.2f}%"
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(customer: dict[str, Any], products: list[dict[str, Any]]) -> str:
|
||||||
|
"""把客户约束与每只产品的**真实参数**摊平成提示词。"""
|
||||||
|
lines = [
|
||||||
|
"【客户约束】",
|
||||||
|
f"- 风险承受等级:{customer.get('risk_level') or '未知'}",
|
||||||
|
f"- 投资期限:{customer.get('horizon_months') or '未知'} 个月",
|
||||||
|
f"- 流动性要求:{customer.get('liquidity') or '未知'}",
|
||||||
|
"",
|
||||||
|
"【待写依据的产品】",
|
||||||
|
]
|
||||||
|
for product in products:
|
||||||
|
lines.extend([
|
||||||
|
f"- product_code={product.get('product_code')}",
|
||||||
|
f" 名称:{product.get('product_name')}({product.get('product_category')})",
|
||||||
|
f" 风险等级:{product.get('risk_level')}",
|
||||||
|
f" 排序得分:{product.get('score')}(0~1,越高表示与客户越匹配)",
|
||||||
|
f" 近 20 个交易日区间收益:{_pct(product.get('return_20d_pct'))}",
|
||||||
|
f" 近 60 个交易日区间收益:{_pct(product.get('return_60d_pct'))}",
|
||||||
|
f" 近 60 个交易日最大回撤:{_pct(product.get('max_drawdown_60d_pct'))}",
|
||||||
|
f" 系统当前给出的依据(可改写得更易读,但事实不得改变):{product.get('rule_reason')}",
|
||||||
|
])
|
||||||
|
lines.append("")
|
||||||
|
lines.append("请为上面每一只产品各写一条 reason,product_code 必须原样返回。")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_code_fence(raw: str) -> str:
|
||||||
|
text = raw.strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.split("\n", 1)[-1] if "\n" in text else text
|
||||||
|
text = text.rsplit("```", 1)[0]
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_items(raw: str) -> dict[str, str]:
|
||||||
|
"""从模型输出里解出 `{product_code: reason}`;结构不符一律返回空字典。"""
|
||||||
|
text = _strip_code_fence(raw)
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
if start == -1 or end <= start:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
payload = json.loads(text[start : end + 1])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
items = payload.get("items") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return {}
|
||||||
|
parsed: dict[str, str] = {}
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
code = item.get("product_code")
|
||||||
|
reason = item.get("reason")
|
||||||
|
if isinstance(code, str) and isinstance(reason, str) and reason.strip():
|
||||||
|
parsed[code.strip()] = reason.strip()
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def is_compliant(text: str) -> bool:
|
||||||
|
"""合规守卫:长度合理 + 不含收益承诺类表述。"""
|
||||||
|
if not (MIN_REASON_CHARS <= len(text) <= MAX_REASON_CHARS):
|
||||||
|
return False
|
||||||
|
return not any(phrase in text for phrase in PROHIBITED_PHRASES)
|
||||||
|
|
||||||
|
|
||||||
|
class AdvisorReasonService:
|
||||||
|
"""调用 OpenAI-compatible `/chat/completions` 生成推荐依据;失败返回空字典。"""
|
||||||
|
|
||||||
|
def __init__(self, client: httpx.AsyncClient | None = None) -> None:
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
async def enhance(
|
||||||
|
self, *, customer: dict[str, Any], products: list[dict[str, Any]]
|
||||||
|
) -> dict[str, str]:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.advisor_reason_llm_enabled or not products:
|
||||||
|
return {}
|
||||||
|
api_key = settings.deepseek_api_key
|
||||||
|
if not api_key:
|
||||||
|
logger.warning("推荐依据 LLM 已启用但缺少 DEEPSEEK_API_KEY,回退到规则文案")
|
||||||
|
return {}
|
||||||
|
url = settings.advisor_reason_llm_base_url.rstrip("/") + "/chat/completions"
|
||||||
|
payload = {
|
||||||
|
"model": settings.advisor_reason_llm_model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": build_prompt(customer, products)},
|
||||||
|
],
|
||||||
|
"temperature": 0,
|
||||||
|
"max_tokens": 1500,
|
||||||
|
}
|
||||||
|
owns_client = self.client is None
|
||||||
|
client = self.client or httpx.AsyncClient()
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json"},
|
||||||
|
json=payload,
|
||||||
|
timeout=httpx.Timeout(settings.advisor_reason_llm_timeout_seconds),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body: Any = response.json()
|
||||||
|
message = (body.get("choices") or [{}])[0].get("message") or {}
|
||||||
|
# 推理型模型把正文放在 `reasoning_content`,`content` 可能为空 —— 兜底读一次。
|
||||||
|
content = message.get("content") or message.get("reasoning_content") or ""
|
||||||
|
parsed = parse_items(str(content))
|
||||||
|
except Exception: # noqa: BLE001 — 模型不可用绝不能影响推荐主流程
|
||||||
|
logger.warning("推荐依据 LLM 调用失败,回退到规则文案", exc_info=True)
|
||||||
|
return {}
|
||||||
|
finally:
|
||||||
|
if owns_client:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
allowed_codes = {str(product.get("product_code")) for product in products}
|
||||||
|
accepted: dict[str, str] = {}
|
||||||
|
for code, reason in parsed.items():
|
||||||
|
if code not in allowed_codes:
|
||||||
|
continue
|
||||||
|
if not is_compliant(reason):
|
||||||
|
logger.warning(
|
||||||
|
"推荐依据 LLM 文案未通过合规守卫,丢弃(product_code=%s)", code
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
accepted[code] = reason
|
||||||
|
return accepted
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Constraint-first recommendations for the exchange-traded simulation domain."""
|
"""Constraint-first recommendations for the exchange-traded simulation domain."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -8,19 +9,28 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.contracts import RequestContext
|
from app.core.contracts import RequestContext
|
||||||
from app.core.errors import GenericResourceNotFoundError, InvalidStateError
|
from app.core.errors import (
|
||||||
|
ForbiddenAgentError,
|
||||||
|
GenericResourceNotFoundError,
|
||||||
|
InvalidStateError,
|
||||||
|
)
|
||||||
from app.core.product_recommendation_contracts import ProductRecommendationQuery
|
from app.core.product_recommendation_contracts import ProductRecommendationQuery
|
||||||
from app.infrastructure.db import SessionFactory
|
from app.infrastructure.db import SessionFactory
|
||||||
from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver
|
from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver
|
||||||
from app.model.audit import InteractionAudit
|
from app.model.audit import InteractionAudit
|
||||||
|
from app.model.fund import FundNavHistory
|
||||||
from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent
|
from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent
|
||||||
from app.repository.advisor_product_repository import (
|
from app.repository.advisor_product_repository import (
|
||||||
AdvisorProductRepository,
|
AdvisorProductRepository,
|
||||||
AuthoritativeProductCandidate,
|
AuthoritativeProductCandidate,
|
||||||
)
|
)
|
||||||
|
from app.service.advisor_reason_service import AdvisorReasonService
|
||||||
from app.service.api_transaction_service import ApiTransactionService
|
from app.service.api_transaction_service import ApiTransactionService
|
||||||
from app.service.authorization_service import AuthorizationService
|
from app.service.authorization_service import AuthorizationService
|
||||||
from app.service.investment_goal_service import InvestmentGoalService
|
from app.service.investment_goal_service import InvestmentGoalService
|
||||||
|
# 流动性要求的中文文案与投资目标线**同源**(`investment_goal_service._LIQUIDITY_LABELS`):
|
||||||
|
# 这里直接复用,避免两处各翻一遍、日后口径漂移。
|
||||||
|
from app.service.investment_goal_service import _LIQUIDITY_LABELS
|
||||||
from app.service.product_governance_monitor_service import SALES_INSTITUTION
|
from app.service.product_governance_monitor_service import SALES_INSTITUTION
|
||||||
from app.service.profile_governance_service import ProfileGovernanceService
|
from app.service.profile_governance_service import ProfileGovernanceService
|
||||||
from app.service.relationship_service import RelationshipService
|
from app.service.relationship_service import RelationshipService
|
||||||
@@ -31,6 +41,8 @@ from app.service.suitability_service import SuitabilityService
|
|||||||
#: 推荐方案用 `content_id`,方案书用 `goal_no`。
|
#: 推荐方案用 `content_id`,方案书用 `goal_no`。
|
||||||
BOOK_CONTENT_TYPE = "investment_goal_book"
|
BOOK_CONTENT_TYPE = "investment_goal_book"
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ProductRecommendationService:
|
class ProductRecommendationService:
|
||||||
CONTENT_TYPE = "advisor_recommendation_plan"
|
CONTENT_TYPE = "advisor_recommendation_plan"
|
||||||
@@ -95,6 +107,12 @@ class ProductRecommendationService:
|
|||||||
self._view(item, index, goal, graph_context)
|
self._view(item, index, goal, graph_context)
|
||||||
for index, item in enumerate(selected, start=1)
|
for index, item in enumerate(selected, start=1)
|
||||||
]
|
]
|
||||||
|
# 「推荐依据」的 LLM 增强:把**已经算出来的真实参数**交给模型,写成客户读得懂的说明。
|
||||||
|
# 未启用 / 缺密钥 / 调用失败 / 未过合规守卫时,保留 `_view` 的确定性文案。
|
||||||
|
# 注意:模型只改文案,**不参与选品**(候选池与排序在它之前就已固定)。
|
||||||
|
products = await self._enhance_reasons(
|
||||||
|
products, selected, goal, authority.customer_risk_level
|
||||||
|
)
|
||||||
plan = {
|
plan = {
|
||||||
"document_type": "advisor_recommendation_plan",
|
"document_type": "advisor_recommendation_plan",
|
||||||
"document_version": "1.0",
|
"document_version": "1.0",
|
||||||
@@ -228,13 +246,24 @@ class ProductRecommendationService:
|
|||||||
candidate, score = item
|
candidate, score = item
|
||||||
product = candidate.product
|
product = candidate.product
|
||||||
contract = candidate.contract
|
contract = candidate.contract
|
||||||
|
# 推荐依据要给出**这一只**的真实参数,而不是每只都同一句套话。
|
||||||
|
# 全部取自本次计算过程(硬约束、适当性等级、目标的期限/流动性、排序得分),
|
||||||
|
# 不做任何收益承诺 —— 文案里不出现"稳赚/保本"这类词。
|
||||||
|
liquidity_raw = str(goal.get("liquidity_requirement") or "")
|
||||||
|
liquidity_label = _LIQUIDITY_LABELS.get(liquidity_raw, liquidity_raw or "--")
|
||||||
|
reason = (
|
||||||
|
"已通过三项硬约束:场内可交易 / 权威适当性证据 / 基金合同快照;"
|
||||||
|
f"风险等级 {candidate.suitability.risk_level},与客户风险承受力匹配;"
|
||||||
|
f"按投资期限 {goal.get('investment_horizon_months')} 个月、"
|
||||||
|
f"流动性要求「{liquidity_label}」筛入;"
|
||||||
|
f"综合排序得分 {round(score, 4)}。"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"rank": rank,
|
"rank": rank,
|
||||||
"product_code": product.product_code,
|
"product_code": product.product_code,
|
||||||
"product_name": product.product_name,
|
"product_name": product.product_name,
|
||||||
"product_category": product.product_category,
|
"product_category": product.product_category,
|
||||||
"reason": "该产品已通过场内可交易、权威适当性和合同证据校验,"
|
"reason": reason,
|
||||||
"并与已确认投资目标的期限和流动性要求相匹配。",
|
|
||||||
"score": round(score, 4),
|
"score": round(score, 4),
|
||||||
"recommendation_evidence_card": {
|
"recommendation_evidence_card": {
|
||||||
"card_version": "1.0",
|
"card_version": "1.0",
|
||||||
@@ -270,6 +299,80 @@ class ProductRecommendationService:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _performance_snapshot(
|
||||||
|
self, product_ids: list[int]
|
||||||
|
) -> dict[int, dict[str, float | None]]:
|
||||||
|
"""每只产品的近 20/60 个交易日区间收益与近 60 日最大回撤(`fin_nav_history`)。
|
||||||
|
|
||||||
|
这些数字是「推荐依据」的**事实来源**:既给确定性文案,也给大模型当输入,
|
||||||
|
避免模型自己编业绩。表里没数据时对应值为 None(文案与提示词都显示「暂无」)。
|
||||||
|
"""
|
||||||
|
if not product_ids:
|
||||||
|
return {}
|
||||||
|
snapshot: dict[int, dict[str, float | None]] = {
|
||||||
|
pid: {"return_20d_pct": None, "return_60d_pct": None, "max_drawdown_60d_pct": None}
|
||||||
|
for pid in product_ids
|
||||||
|
}
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(FundNavHistory.product_id, FundNavHistory.nav)
|
||||||
|
.where(FundNavHistory.product_id.in_(product_ids))
|
||||||
|
.order_by(FundNavHistory.product_id, FundNavHistory.nav_date.asc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
grouped: dict[int, list[float]] = {}
|
||||||
|
for product_id, nav in rows:
|
||||||
|
grouped.setdefault(int(product_id), []).append(float(nav))
|
||||||
|
for product_id, values in grouped.items():
|
||||||
|
snapshot[int(product_id)] = {
|
||||||
|
"return_20d_pct": _interval_return(values, 20),
|
||||||
|
"return_60d_pct": _interval_return(values, 60),
|
||||||
|
"max_drawdown_60d_pct": _max_drawdown_pct(values[-61:]),
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
async def _enhance_reasons(
|
||||||
|
self,
|
||||||
|
products: list[dict[str, object]],
|
||||||
|
selected: list[tuple[AuthoritativeProductCandidate, float]],
|
||||||
|
goal: dict[str, object],
|
||||||
|
customer_risk_level: int,
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
"""给每只产品标 `reason_source`(llm / rule),并在可用时替换成 LLM 文案。"""
|
||||||
|
snapshot: dict[int, dict[str, float | None]] = {}
|
||||||
|
try:
|
||||||
|
snapshot = await self._performance_snapshot(
|
||||||
|
[int(candidate.product.id) for candidate, _ in selected]
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 — 读不到历史不该影响出方案
|
||||||
|
logger.warning("推荐依据:历史净值读取失败,按「暂无」处理", exc_info=True)
|
||||||
|
|
||||||
|
prompt_products: list[dict[str, object]] = []
|
||||||
|
for index, product in enumerate(products):
|
||||||
|
product_id = int(selected[index][0].product.id) if index < len(selected) else None
|
||||||
|
facts = snapshot.get(product_id, {}) if product_id is not None else {}
|
||||||
|
product["performance"] = facts
|
||||||
|
prompt_products.append({**product, **facts})
|
||||||
|
|
||||||
|
liquidity_raw = str(goal.get("liquidity_requirement") or "")
|
||||||
|
generated = await AdvisorReasonService().enhance(
|
||||||
|
customer={
|
||||||
|
"risk_level": customer_risk_level,
|
||||||
|
"horizon_months": goal.get("investment_horizon_months"),
|
||||||
|
"liquidity": _LIQUIDITY_LABELS.get(liquidity_raw, liquidity_raw),
|
||||||
|
},
|
||||||
|
products=prompt_products,
|
||||||
|
)
|
||||||
|
for product in products:
|
||||||
|
code = str(product.get("product_code"))
|
||||||
|
if code in generated:
|
||||||
|
product["reason"] = generated[code]
|
||||||
|
product["reason_source"] = "llm"
|
||||||
|
else:
|
||||||
|
product["reason_source"] = "rule"
|
||||||
|
return products
|
||||||
|
|
||||||
async def _graph_context(self, customer_id: int) -> dict[str, object]:
|
async def _graph_context(self, customer_id: int) -> dict[str, object]:
|
||||||
if self.relationship_service is None:
|
if self.relationship_service is None:
|
||||||
return {"degraded": True, "reason": "graph_not_configured"}
|
return {"degraded": True, "reason": "graph_not_configured"}
|
||||||
@@ -349,6 +452,63 @@ class ProductRecommendationService:
|
|||||||
operation,
|
operation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
self, content_id: int, context: RequestContext, key: str | None
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""删除推荐方案(投顾工作台「历史方案记录」里的「删除」)。
|
||||||
|
|
||||||
|
## 为什么只允许删推荐方案
|
||||||
|
|
||||||
|
投资方案书(`BOOK_CONTENT_TYPE`)被 `advisor_investment_goal.goal_book_content_id`
|
||||||
|
以 `NO ACTION` 外键引用,且该列 **`NOT NULL`** —— 硬删方案书必然撞外键(1451)。
|
||||||
|
方案书有自己的 `goal_no` 生命周期,不走这里。
|
||||||
|
|
||||||
|
## 归属
|
||||||
|
|
||||||
|
复用 `_visible_customer_ids`(本人 + 名下归属客户),与 `published` / `history`
|
||||||
|
同一把尺子:不是自己能看的客户,方案也删不得。
|
||||||
|
"""
|
||||||
|
await AuthorizationService.require(context, "product-recommendation:delete")
|
||||||
|
|
||||||
|
async def operation(session: Any) -> dict[str, object]:
|
||||||
|
content = await session.get(ClientFacingContent, content_id, with_for_update=True)
|
||||||
|
if content is None or content.content_type != self.CONTENT_TYPE:
|
||||||
|
raise GenericResourceNotFoundError("推荐方案不存在")
|
||||||
|
if content.customer_id not in self._visible_customer_ids(context):
|
||||||
|
raise ForbiddenAgentError("无权操作该客户的方案")
|
||||||
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
await session.delete(content)
|
||||||
|
# 删除也留痕:`interaction_audit` 与 `client_facing_content` 无外键,
|
||||||
|
# 方案没了审计仍在(合规要求「删了什么、谁删的」可追)。
|
||||||
|
session.add(
|
||||||
|
InteractionAudit(
|
||||||
|
actor_type="user",
|
||||||
|
actor_id=int(context.user_id),
|
||||||
|
target_customer_id=content.customer_id,
|
||||||
|
portal=context.portal,
|
||||||
|
action_type="advisor.recommendation_deleted",
|
||||||
|
detail={
|
||||||
|
"content_id": str(content_id),
|
||||||
|
"content_type": self.CONTENT_TYPE,
|
||||||
|
"trace_id": context.trace_id,
|
||||||
|
},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.flush()
|
||||||
|
return {
|
||||||
|
"data": {"content_id": str(content_id), "status": "deleted"},
|
||||||
|
"meta": {"trace_id": context.trace_id},
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ApiTransactionService().execute(
|
||||||
|
context,
|
||||||
|
f"advisor:recommendations:{content_id}:delete",
|
||||||
|
key,
|
||||||
|
{"delete": True},
|
||||||
|
operation,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _visible_customer_ids(context: RequestContext) -> tuple[int, ...]:
|
def _visible_customer_ids(context: RequestContext) -> tuple[int, ...]:
|
||||||
"""可查看的客户 id:本人 + 名下归属客户。
|
"""可查看的客户 id:本人 + 名下归属客户。
|
||||||
@@ -395,6 +555,83 @@ class ProductRecommendationService:
|
|||||||
"meta": {"trace_id": context.trace_id},
|
"meta": {"trace_id": context.trace_id},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def my_published(self, context: RequestContext) -> dict[str, object]:
|
||||||
|
"""客户视角:**自己**已被审核发布的投顾交付物(`/api/v1/users/me/advisor-contents`)。
|
||||||
|
|
||||||
|
与 `published()`(投顾侧)的区别在**范围**:这里只看 `customer_id == 自己`,
|
||||||
|
不掺 `sys_customer_assignment` —— 那是投顾的归属概念,客户没有归属客户。
|
||||||
|
只返回 `published_at` 非空的:投顾「发送给客户」之前,客户看不到。
|
||||||
|
"""
|
||||||
|
await AuthorizationService.require(context, "product-recommendation:read:self")
|
||||||
|
customer_id = int(context.user_id)
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
rows = list(
|
||||||
|
await session.scalars(
|
||||||
|
select(ClientFacingContent)
|
||||||
|
.where(
|
||||||
|
ClientFacingContent.customer_id == customer_id,
|
||||||
|
ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES),
|
||||||
|
ClientFacingContent.review_status.in_(self.PUBLISHED_STATES),
|
||||||
|
ClientFacingContent.published_at.is_not(None),
|
||||||
|
)
|
||||||
|
.order_by(ClientFacingContent.published_at.desc())
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"content_id": str(row.id),
|
||||||
|
"customer_id": str(row.customer_id),
|
||||||
|
"content_type": row.content_type,
|
||||||
|
"plan": row.draft_content,
|
||||||
|
"published_at": row.published_at.isoformat() if row.published_at else None,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
"meta": {"trace_id": context.trace_id},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def history(self, context: RequestContext) -> dict[str, object]:
|
||||||
|
"""投顾本人的方案留档:本人 + 名下归属客户的**全部状态**方案与方案书。
|
||||||
|
|
||||||
|
与 `published()` 的唯一差别是**状态口径**:`published` 只给已发布(供客户看),
|
||||||
|
`history` 给全部(含 `pending_review`/`pending` 待审、`rejected` 已驳回),
|
||||||
|
供投顾在工作台回看"以前生成过什么"。归属过滤复用 `_visible_customer_ids`,
|
||||||
|
与 `published` 保持同一把尺子。
|
||||||
|
"""
|
||||||
|
await AuthorizationService.require(context, "product-recommendation:read:self")
|
||||||
|
customer_ids = self._visible_customer_ids(context)
|
||||||
|
if not customer_ids:
|
||||||
|
return {"data": [], "meta": {"trace_id": context.trace_id}}
|
||||||
|
async with self.session_factory() as session:
|
||||||
|
rows = list(
|
||||||
|
await session.scalars(
|
||||||
|
select(ClientFacingContent)
|
||||||
|
.where(
|
||||||
|
ClientFacingContent.customer_id.in_(customer_ids),
|
||||||
|
ClientFacingContent.content_type.in_(self.CLIENT_CONTENT_TYPES),
|
||||||
|
)
|
||||||
|
.order_by(ClientFacingContent.created_at.desc())
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"content_id": str(row.id),
|
||||||
|
"customer_id": str(row.customer_id),
|
||||||
|
"content_type": row.content_type,
|
||||||
|
"review_status": row.review_status,
|
||||||
|
"plan": row.draft_content,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"reviewed_at": row.reviewed_at.isoformat() if row.reviewed_at else None,
|
||||||
|
"published_at": row.published_at.isoformat() if row.published_at else None,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
"meta": {"trace_id": context.trace_id},
|
||||||
|
}
|
||||||
|
|
||||||
async def pending_reviews(self, context: RequestContext) -> dict[str, object]:
|
async def pending_reviews(self, context: RequestContext) -> dict[str, object]:
|
||||||
"""管理面复核队列:待审核的推荐方案与投资方案书。
|
"""管理面复核队列:待审核的推荐方案与投资方案书。
|
||||||
@@ -468,3 +705,26 @@ async def product_recommendation_tool(
|
|||||||
return await ProductRecommendationService(enforce_profile_governance=True).generate(
|
return await ProductRecommendationService(enforce_profile_governance=True).generate(
|
||||||
arguments, context, None
|
arguments, context, None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _interval_return(values: list[float], trading_days: int) -> float | None:
|
||||||
|
"""近 N 个交易日的区间收益(%);数据不足或基准为 0 时返回 None。"""
|
||||||
|
if len(values) < 2:
|
||||||
|
return None
|
||||||
|
base = values[max(0, len(values) - 1 - trading_days)]
|
||||||
|
if not base:
|
||||||
|
return None
|
||||||
|
return (values[-1] - base) / base * 100
|
||||||
|
|
||||||
|
|
||||||
|
def _max_drawdown_pct(values: list[float]) -> float | None:
|
||||||
|
"""区间最大回撤(%,负值);数据不足返回 None。"""
|
||||||
|
if len(values) < 2:
|
||||||
|
return None
|
||||||
|
peak = values[0]
|
||||||
|
worst = 0.0
|
||||||
|
for value in values:
|
||||||
|
peak = max(peak, value)
|
||||||
|
if peak:
|
||||||
|
worst = min(worst, (value - peak) / peak * 100)
|
||||||
|
return worst
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
// 投顾推荐方案的「可视化」渲染 —— 投顾工作台结果区与客户「我的投顾方案」页**共用**。
|
||||||
|
//
|
||||||
|
// 为什么单独抽一个模块:这两处展示的是同一份 `advisor_recommendation_plan`,
|
||||||
|
// 各写一套必然漂移(改了一边忘了另一边)。本模块保持**纯渲染 + 一次注解式取数**:
|
||||||
|
// · `renderPlanProducts(products)` —— 同步产出骨架(只用方案自带字段);
|
||||||
|
// · `hydratePlanView(root, apiClient, products)` —— 取行情/净值后填图表与指标。
|
||||||
|
//
|
||||||
|
// ⚠️ 不 import `api-client.js`:投顾页与客户页引入的 `apiClient` 版本不同,
|
||||||
|
// 在共享模块里再 import 一份会多出一个实例(`ENDPOINTS` 注册表也就有了两份)。
|
||||||
|
// 所以 apiClient 由调用方当参数传进来(依赖注入)。
|
||||||
|
//
|
||||||
|
// 数据来源(都已在 `api-client.js` 注册):
|
||||||
|
// · P001 `GET /api/v1/products` → 最新净值/当日涨跌/基金经理/费率
|
||||||
|
// · P002 `GET /api/v1/products/{code}/nav-history` → 近 N 个交易日净值序列(画折线)
|
||||||
|
//
|
||||||
|
// 颜色口径:**涨红跌绿**(A 股习惯)。样式里用 `--plan-up` / `--plan-down` 两个变量
|
||||||
|
// 集中定义(`common/advisor-plan.css`),要与产品列表页的“绿涨红跌”一致时对调即可。
|
||||||
|
|
||||||
|
import { escapeHtml } from '/static/portal/common/formatters.js';
|
||||||
|
|
||||||
|
//: 从净值序列里取最近多少个交易日画走势。
|
||||||
|
const SPARK_DAYS = 180;
|
||||||
|
|
||||||
|
//: 卡片上展示的区间涨跌(交易日数)。
|
||||||
|
const INTERVALS = Object.freeze([
|
||||||
|
['近 20 日', 20],
|
||||||
|
['近 60 日', 60],
|
||||||
|
]);
|
||||||
|
|
||||||
|
//: 环形图配色(最多 6 色循环)。用固定色值而不是主题变量:环形切片需要彼此可区分。
|
||||||
|
const SLICE_COLORS = Object.freeze([
|
||||||
|
'#2d6f67', '#c99a2e', '#4a7fb5', '#a6573f', '#7a6bab', '#5f8f52',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ 这里必须显式挡 `null` / `undefined` / `''`。
|
||||||
|
* 直接 `Number(null)` 会得到 **0** —— 于是"没有数据"被渲染成 `0.00%`(曾真出过这个 bug:
|
||||||
|
* 近 20/60 日与管理费全显示 0.00%,看起来像"真的是 0")。`Number('')` 同理。
|
||||||
|
*/
|
||||||
|
function num(value) {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 涨跌幅文本;拿不到就 `--`,绝不渲染成 `+0.00%`(会把"不知道"说成"平盘")。 */
|
||||||
|
function pctText(value) {
|
||||||
|
const parsed = num(value);
|
||||||
|
if (parsed === null) return '--';
|
||||||
|
return `${parsed > 0 ? '+' : ''}${parsed.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 涨跌配色类;`null` 用中性色。 */
|
||||||
|
function pctClass(value) {
|
||||||
|
const parsed = num(value);
|
||||||
|
if (parsed === null) return 'advisor-plan__value--flat';
|
||||||
|
if (parsed > 0) return 'advisor-plan__value--up';
|
||||||
|
if (parsed < 0) return 'advisor-plan__value--down';
|
||||||
|
return 'advisor-plan__value--flat';
|
||||||
|
}
|
||||||
|
|
||||||
|
function navValues(points) {
|
||||||
|
return points.map((point) => num(point.nav)).filter((value) => value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function intervalChange(values, tradingDays) {
|
||||||
|
if (values.length < 2) return null;
|
||||||
|
const baseIndex = Math.max(0, values.length - 1 - tradingDays);
|
||||||
|
const base = values[baseIndex];
|
||||||
|
if (!base) return null;
|
||||||
|
return ((values[values.length - 1] - base) / base) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metric(label, value, className = '') {
|
||||||
|
return `<div class="advisor-plan__metric"><dt>${escapeHtml(label)}</dt>`
|
||||||
|
+ `<dd class="${className}">${escapeHtml(value)}</dd></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 折线图(带坐标轴/网格;内联 SVG,不引图表库) ----
|
||||||
|
|
||||||
|
function shortDate(iso) {
|
||||||
|
return String(iso || '').slice(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用折线图。`series` 是 `[{date, value}]`;`formatter` 决定 y 轴刻度文本。
|
||||||
|
* 缺数据(<2 点)返回空串,由调用方给"暂无数据"文案。
|
||||||
|
*/
|
||||||
|
function lineChartSvg(series, { formatter, ariaLabel }) {
|
||||||
|
if (series.length < 2) return '';
|
||||||
|
const width = 640;
|
||||||
|
const height = 220;
|
||||||
|
const left = 58;
|
||||||
|
const right = 16;
|
||||||
|
const top = 14;
|
||||||
|
const bottom = 30;
|
||||||
|
const values = series.map((point) => point.value);
|
||||||
|
let min = Math.min(...values);
|
||||||
|
let max = Math.max(...values);
|
||||||
|
if (min === max) {
|
||||||
|
min -= 0.01;
|
||||||
|
max += 0.01;
|
||||||
|
}
|
||||||
|
const padding = (max - min) * 0.08;
|
||||||
|
min -= padding;
|
||||||
|
max += padding;
|
||||||
|
const xAt = (index) => left + (index * (width - left - right)) / (series.length - 1);
|
||||||
|
const yAt = (value) => top + (1 - (value - min) / (max - min)) * (height - top - bottom);
|
||||||
|
|
||||||
|
const rows = 4;
|
||||||
|
const grid = Array.from({ length: rows + 1 }, (_, index) => {
|
||||||
|
const value = min + ((max - min) * index) / rows;
|
||||||
|
const y = yAt(value).toFixed(1);
|
||||||
|
return `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}"/>`
|
||||||
|
+ `<text x="${left - 8}" y="${(Number(y) + 3.5).toFixed(1)}" text-anchor="end">`
|
||||||
|
+ `${escapeHtml(formatter(value))}</text>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const labelIndexes = [0, Math.floor((series.length - 1) / 2), series.length - 1];
|
||||||
|
const xLabels = labelIndexes.map((index, position) => {
|
||||||
|
const anchor = position === 0 ? 'start' : (position === 2 ? 'end' : 'middle');
|
||||||
|
return `<text x="${xAt(index).toFixed(1)}" y="${height - 9}" text-anchor="${anchor}">`
|
||||||
|
+ `${escapeHtml(shortDate(series[index].date))}</text>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const coords = series
|
||||||
|
.map((point, index) => `${xAt(index).toFixed(1)},${yAt(point.value).toFixed(1)}`)
|
||||||
|
.join(' ');
|
||||||
|
const rising = values[values.length - 1] >= values[0];
|
||||||
|
return `<svg class="advisor-plan__chart-svg" viewBox="0 0 ${width} ${height}" role="img"`
|
||||||
|
+ ` aria-label="${escapeHtml(ariaLabel)}">`
|
||||||
|
+ `<g class="advisor-plan__chart-grid">${grid}</g>`
|
||||||
|
+ `<g class="advisor-plan__axis">${xLabels}</g>`
|
||||||
|
+ `<polyline class="advisor-plan__line advisor-plan__line--${rising ? 'up' : 'down'}"`
|
||||||
|
+ ` points="${coords}"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把一条净值序列归一成"相对首日的涨跌%"(组合合成的原料)。 */
|
||||||
|
function normalizedPct(points) {
|
||||||
|
const cleaned = points
|
||||||
|
.map((point) => ({ date: String(point.nav_date), value: num(point.nav) }))
|
||||||
|
.filter((point) => point.value !== null);
|
||||||
|
if (cleaned.length < 2) return [];
|
||||||
|
const base = cleaned[0].value;
|
||||||
|
if (!base) return [];
|
||||||
|
return cleaned.map((point) => ({ date: point.date, value: (point.value / base - 1) * 100 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等权组合:各基金按共同交易日对齐后取均值。 */
|
||||||
|
function equalWeightSeries(navsByCode) {
|
||||||
|
const series = Object.values(navsByCode)
|
||||||
|
.map((points) => normalizedPct(points))
|
||||||
|
.filter((pts) => pts.length >= 2);
|
||||||
|
if (series.length < 2) return [];
|
||||||
|
let common = new Set(series[0].map((point) => point.date));
|
||||||
|
series.slice(1).forEach((pts) => {
|
||||||
|
const dates = new Set(pts.map((point) => point.date));
|
||||||
|
common = new Set([...common].filter((date) => dates.has(date)));
|
||||||
|
});
|
||||||
|
const dates = [...common].sort();
|
||||||
|
if (dates.length < 2) return [];
|
||||||
|
return dates.map((date) => ({
|
||||||
|
date,
|
||||||
|
value: series.reduce((sum, pts) => {
|
||||||
|
const hit = pts.find((point) => point.date === date);
|
||||||
|
return sum + (hit ? hit.value : 0);
|
||||||
|
}, 0) / series.length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxDrawdownPct(series) {
|
||||||
|
let peak = -Infinity;
|
||||||
|
let worst = 0;
|
||||||
|
series.forEach((point) => {
|
||||||
|
peak = Math.max(peak, point.value);
|
||||||
|
worst = Math.min(worst, point.value - peak);
|
||||||
|
});
|
||||||
|
return worst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 环形图(资产配置比例) ----
|
||||||
|
|
||||||
|
function donutSvg(entries) {
|
||||||
|
const total = entries.reduce((sum, entry) => sum + entry.weight, 0) || 1;
|
||||||
|
const radius = 54;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
let offset = 0;
|
||||||
|
const slices = entries.map((entry, index) => {
|
||||||
|
const length = (entry.weight / total) * circumference;
|
||||||
|
const slice = `<circle class="advisor-plan__slice" cx="70" cy="70" r="${radius}"`
|
||||||
|
+ ` stroke="${SLICE_COLORS[index % SLICE_COLORS.length]}"`
|
||||||
|
+ ` stroke-dasharray="${length.toFixed(2)} ${(circumference - length).toFixed(2)}"`
|
||||||
|
+ ` stroke-dashoffset="${(-offset).toFixed(2)}" transform="rotate(-90 70 70)"/>`;
|
||||||
|
offset += length;
|
||||||
|
return slice;
|
||||||
|
}).join('');
|
||||||
|
return `<svg class="advisor-plan__donut" viewBox="0 0 140 140" role="img" aria-label="组合配置比例">`
|
||||||
|
+ slices
|
||||||
|
+ '<text class="advisor-plan__donut-title" x="70" y="66" text-anchor="middle">等权组合</text>'
|
||||||
|
+ `<text class="advisor-plan__donut-sub" x="70" y="84" text-anchor="middle">${entries.length} 只</text>`
|
||||||
|
+ '</svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 方案骨架 ----
|
||||||
|
|
||||||
|
function riskOf(product) {
|
||||||
|
const evidence = product.recommendation_evidence_card || {};
|
||||||
|
return ((evidence.suitability || {}).risk_level) || product.risk_level || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHtml(product) {
|
||||||
|
const code = String(product.product_code || '');
|
||||||
|
return '<article class="advisor-plan__card" data-plan-card="' + escapeHtml(code) + '">'
|
||||||
|
+ '<header class="advisor-plan__head">'
|
||||||
|
+ `<div><h4 class="advisor-plan__name">${escapeHtml(product.product_name || '--')}</h4>`
|
||||||
|
+ `<p class="advisor-plan__sub">${escapeHtml(code)}`
|
||||||
|
+ ` · ${escapeHtml(product.product_category || '--')}</p></div>`
|
||||||
|
+ '<div class="advisor-plan__badges">'
|
||||||
|
+ `<span class="advisor-plan__risk">${escapeHtml(riskOf(product))}</span>`
|
||||||
|
+ `<span class="advisor-plan__score">评分 ${escapeHtml(String(product.score ?? '--'))}</span>`
|
||||||
|
+ '</div></header>'
|
||||||
|
+ `<div class="advisor-plan__chart" data-plan-chart="${escapeHtml(code)}">`
|
||||||
|
+ '<p class="advisor-plan__chart-pending">走势加载中…</p></div>'
|
||||||
|
+ `<dl class="advisor-plan__metrics" data-plan-metrics="${escapeHtml(code)}">`
|
||||||
|
+ metric('风险等级', riskOf(product)) + metric('AI 评分', String(product.score ?? '--'))
|
||||||
|
+ '</dl>'
|
||||||
|
+ `<ul class="advisor-plan__highlights" data-plan-highlights="${escapeHtml(code)}"></ul>`
|
||||||
|
// 标注文案来源:模型写的必须让人一眼看出来(与风控页"大模型生成"的标注惯例一致)。
|
||||||
|
+ '<p class="advisor-plan__reason"><strong>推荐依据'
|
||||||
|
+ (product.reason_source === 'llm'
|
||||||
|
? '<span class="advisor-plan__tag">AI 生成</span>'
|
||||||
|
: '<span class="advisor-plan__tag advisor-plan__tag--rule">规则生成</span>')
|
||||||
|
+ `</strong>${escapeHtml(product.reason || '--')}</p>`
|
||||||
|
+ '</article>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步骨架:只用方案自带字段,行情/净值随后由 `hydratePlanView` 补。 */
|
||||||
|
export function renderPlanProducts(products) {
|
||||||
|
if (!products.length) return '';
|
||||||
|
const codes = products.map((product) => String(product.product_code || '')).join(',');
|
||||||
|
const entries = products.map((product) => ({
|
||||||
|
label: product.product_name || product.product_code || '--',
|
||||||
|
code: String(product.product_code || ''),
|
||||||
|
weight: 1,
|
||||||
|
}));
|
||||||
|
const legend = entries.map((entry, index) => {
|
||||||
|
const share = Math.round((entry.weight / entries.length) * 100);
|
||||||
|
return `<li><i style="background:${SLICE_COLORS[index % SLICE_COLORS.length]}"></i>`
|
||||||
|
+ `<span class="advisor-plan__legend-name">${escapeHtml(entry.label)}</span>`
|
||||||
|
+ `<span class="advisor-plan__legend-code">${escapeHtml(entry.code)}</span>`
|
||||||
|
+ `<b>${share}%</b></li>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div class="advisor-plan" data-plan-view data-codes="${escapeHtml(codes)}">`
|
||||||
|
+ '<section class="advisor-plan__block">'
|
||||||
|
+ '<h4>组合业绩(等权合成)</h4>'
|
||||||
|
+ '<div class="advisor-plan__stats" data-plan-combo-stats></div>'
|
||||||
|
+ '<div class="advisor-plan__chart" data-plan-combo>'
|
||||||
|
+ '<p class="advisor-plan__chart-pending">组合走势加载中…</p></div>'
|
||||||
|
+ '</section>'
|
||||||
|
+ '<section class="advisor-plan__block">'
|
||||||
|
+ '<h4>资产配置比例(等权)</h4>'
|
||||||
|
+ '<div class="advisor-plan__config">'
|
||||||
|
+ `<div class="advisor-plan__donut-wrap">${donutSvg(entries)}</div>`
|
||||||
|
+ `<ul class="advisor-plan__legend">${legend}</ul>`
|
||||||
|
+ '</div></section>'
|
||||||
|
+ `<div class="advisor-plan__grid">${products.map(cardHtml).join('')}</div>`
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 注解式补水 ----
|
||||||
|
|
||||||
|
async function fetchNav(apiClient, code) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('P002', {
|
||||||
|
pathParams: { productCode: code },
|
||||||
|
query: { days: SPARK_DAYS },
|
||||||
|
});
|
||||||
|
const points = response?.data?.points;
|
||||||
|
return Array.isArray(points) ? points : [];
|
||||||
|
} catch {
|
||||||
|
// 单只基金取不到净值不该拖垮整块:留空,卡片显示“暂无净值数据”。
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCardProducts(products) {
|
||||||
|
return new Map(products.map((product) => [String(product.product_code || ''), product]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichCard(block, code, product, facts, points) {
|
||||||
|
const chartNode = block.querySelector(`[data-plan-chart="${code}"]`);
|
||||||
|
const metricsNode = block.querySelector(`[data-plan-metrics="${code}"]`);
|
||||||
|
const highlightsNode = block.querySelector(`[data-plan-highlights="${code}"]`);
|
||||||
|
const values = navValues(points);
|
||||||
|
|
||||||
|
if (chartNode) {
|
||||||
|
const series = points
|
||||||
|
.map((point) => ({ date: String(point.nav_date), value: num(point.nav) }))
|
||||||
|
.filter((point) => point.value !== null);
|
||||||
|
chartNode.innerHTML = series.length >= 2
|
||||||
|
? lineChartSvg(series, {
|
||||||
|
formatter: (value) => value.toFixed(3),
|
||||||
|
ariaLabel: `${product?.product_name || code} 近 ${series.length} 个交易日净值走势`,
|
||||||
|
})
|
||||||
|
: '<p class="advisor-plan__chart-empty">暂无净值数据'
|
||||||
|
+ '(可运行 tools/sync_nav_history.py 同步)</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metricsNode) {
|
||||||
|
const latestNav = values.length ? values[values.length - 1] : num(facts?.current_nav);
|
||||||
|
const rows = [
|
||||||
|
metric('最新净值', latestNav === null ? '--' : latestNav.toFixed(4)),
|
||||||
|
metric('当日涨跌', pctText(facts?.change_pct), pctClass(facts?.change_pct)),
|
||||||
|
];
|
||||||
|
INTERVALS.forEach(([label, days]) => {
|
||||||
|
const change = intervalChange(values, days);
|
||||||
|
rows.push(metric(label, pctText(change), pctClass(change)));
|
||||||
|
});
|
||||||
|
rows.push(metric('基金经理', facts?.fund_manager || '--'));
|
||||||
|
const fee = num(facts?.management_fee_rate);
|
||||||
|
// `management_fee_rate` 在库里可能是 NULL 或 0(未维护)——显示成「0.00%/年」
|
||||||
|
// 会被读成"这只基金不要管理费",与事实相反,所以按"没数据"处理。
|
||||||
|
rows.push(metric('管理费', fee === null || fee <= 0 ? '--' : `${(fee * 100).toFixed(2)}%/年`));
|
||||||
|
metricsNode.innerHTML = rows.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (highlightsNode) {
|
||||||
|
const items = [];
|
||||||
|
const c60 = intervalChange(values, 60);
|
||||||
|
const c20 = intervalChange(values, 20);
|
||||||
|
if (c60 !== null) items.push(`近 60 个交易日区间收益 ${pctText(c60)}`);
|
||||||
|
if (c20 !== null) items.push(`近 20 日区间收益 ${pctText(c20)}`);
|
||||||
|
if (values.length >= 2) {
|
||||||
|
items.push(`区间最大回撤 ${pctText(Math.min(0, maxDrawdownPct(
|
||||||
|
values.map((value) => ({ date: '', value })),
|
||||||
|
)))}`);
|
||||||
|
}
|
||||||
|
items.push(`风险等级 ${riskOf(product)},已按你的风险测评与投资期限做匹配校验`);
|
||||||
|
if (facts?.fund_manager) items.push(`基金经理 ${facts.fund_manager}`);
|
||||||
|
highlightsNode.innerHTML = items.map((text) => `<li>${escapeHtml(text)}</li>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCombo(block, navsByCode) {
|
||||||
|
const chartNode = block.querySelector('[data-plan-combo]');
|
||||||
|
const statsNode = block.querySelector('[data-plan-combo-stats]');
|
||||||
|
const series = equalWeightSeries(navsByCode);
|
||||||
|
if (!chartNode) return;
|
||||||
|
if (series.length < 2) {
|
||||||
|
chartNode.innerHTML = '<p class="advisor-plan__chart-empty">成分基金净值不足,'
|
||||||
|
+ '暂无法合成组合走势。</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const total = series[series.length - 1].value;
|
||||||
|
const drawdown = maxDrawdownPct(series);
|
||||||
|
chartNode.innerHTML = lineChartSvg(series, {
|
||||||
|
formatter: (value) => `${value.toFixed(1)}%`,
|
||||||
|
ariaLabel: `等权组合近 ${series.length} 个交易日累计收益走势`,
|
||||||
|
});
|
||||||
|
if (statsNode) {
|
||||||
|
statsNode.innerHTML = metric('区间收益', pctText(total), pctClass(total))
|
||||||
|
+ metric('最大回撤', pctText(drawdown), pctClass(drawdown))
|
||||||
|
+ metric('成分基金', `${Object.keys(navsByCode).length} 只`)
|
||||||
|
+ metric('区间', `${series[0].date} ~ ${series[series.length - 1].date}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注解式补水:取 P001(一次,含全部产品)+ P002(每只一次),
|
||||||
|
* 填组合走势、卡片折线图、指标与看点。任何一步失败都只降级为「暂无数据」,不抛错。
|
||||||
|
*/
|
||||||
|
export async function hydratePlanView(root, apiClient, products = []) {
|
||||||
|
const block = root?.querySelector?.('[data-plan-view]');
|
||||||
|
if (!block || typeof apiClient?.get !== 'function') return;
|
||||||
|
const codes = String(block.dataset.codes || '').split(',').filter(Boolean);
|
||||||
|
if (!codes.length) return;
|
||||||
|
const byCode = fillCardProducts(products);
|
||||||
|
|
||||||
|
const productsByCode = new Map();
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('P001');
|
||||||
|
(response?.data?.products || []).forEach((item) => {
|
||||||
|
productsByCode.set(String(item.product_code), item);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 产品清单取不到:仍能画净值走势,只是基金经理/费率显示 `--`。
|
||||||
|
}
|
||||||
|
|
||||||
|
const navs = await Promise.all(codes.map((code) => fetchNav(apiClient, code)));
|
||||||
|
const navsByCode = {};
|
||||||
|
codes.forEach((code, index) => { navsByCode[code] = navs[index] || []; });
|
||||||
|
|
||||||
|
fillCombo(block, navsByCode);
|
||||||
|
codes.forEach((code, index) => {
|
||||||
|
enrichCard(block, code, byCode.get(code), productsByCode.get(code), navs[index] || []);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/* 推荐方案「可视化」样式 —— 投顾工作台结果区与客户「我的投顾方案」页共用。
|
||||||
|
*
|
||||||
|
* 颜色口径:**涨红跌绿**(A 股习惯)。
|
||||||
|
* ⚠️ 仓库里 `formatters.js` 的 `value--positive` 是**绿涨红跌**(产品列表页/收益明细沿用)。
|
||||||
|
* 若要与那几页统一,把下面两个变量对调即可(只需改这一处)。
|
||||||
|
*/
|
||||||
|
.advisor-plan {
|
||||||
|
--plan-up: var(--danger); /* 涨 → 红 */
|
||||||
|
--plan-down: var(--success); /* 跌 → 绿 */
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 区块(组合业绩 / 资产配置比例) ---- */
|
||||||
|
.advisor-plan__block {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.advisor-plan__block h4 { margin: 0; font-size: var(--fs-body); }
|
||||||
|
|
||||||
|
/* ---- 组合统计(区间收益 / 最大回撤 / 成分 / 区间) ---- */
|
||||||
|
.advisor-plan__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 配置环形图 + 图例 ---- */
|
||||||
|
.advisor-plan__config { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
|
||||||
|
.advisor-plan__donut-wrap { flex: 0 0 auto; width: 140px; }
|
||||||
|
.advisor-plan__donut { width: 140px; height: 140px; display: block; }
|
||||||
|
.advisor-plan__slice { fill: none; stroke-width: 24; }
|
||||||
|
.advisor-plan__donut-title { fill: var(--ink); font-size: 13px; font-weight: 700; }
|
||||||
|
.advisor-plan__donut-sub { fill: var(--muted); font-size: 11px; }
|
||||||
|
.advisor-plan__legend { margin: 0; padding: 0; flex: 1 1 240px; display: grid; gap: var(--space-1); list-style: none; }
|
||||||
|
.advisor-plan__legend li { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto auto; align-items: center; gap: var(--space-2); font-size: var(--fs-small); }
|
||||||
|
.advisor-plan__legend i { width: 10px; height: 10px; border-radius: 2px; }
|
||||||
|
.advisor-plan__legend-name { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.advisor-plan__legend-code { color: var(--muted); }
|
||||||
|
.advisor-plan__legend b { color: var(--ink-soft); }
|
||||||
|
|
||||||
|
/* ---- 折线图 ---- */
|
||||||
|
.advisor-plan__chart { display: grid; gap: var(--space-1); }
|
||||||
|
.advisor-plan__chart-svg { width: 100%; height: auto; display: block; }
|
||||||
|
.advisor-plan__chart-grid line { stroke: var(--line); stroke-width: 1; }
|
||||||
|
.advisor-plan__axis text, .advisor-plan__chart-grid text { fill: var(--muted); font-size: 11px; }
|
||||||
|
.advisor-plan__line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||||
|
.advisor-plan__line--up { stroke: var(--plan-up); }
|
||||||
|
.advisor-plan__line--down { stroke: var(--plan-down); }
|
||||||
|
.advisor-plan__chart-pending, .advisor-plan__chart-empty { margin: 0; color: var(--muted); font-size: var(--fs-small); }
|
||||||
|
|
||||||
|
/* ---- 产品卡片 ---- */
|
||||||
|
.advisor-plan__grid { display: grid; gap: var(--space-3); }
|
||||||
|
.advisor-plan__card {
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.advisor-plan__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||||
|
.advisor-plan__name { margin: 0; font-size: var(--fs-title); font-weight: 700; }
|
||||||
|
.advisor-plan__sub { margin: var(--space-1) 0 0; color: var(--muted); font-size: var(--fs-small); }
|
||||||
|
.advisor-plan__badges { display: inline-flex; align-items: center; gap: var(--space-2); }
|
||||||
|
.advisor-plan__risk {
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--fs-small);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.advisor-plan__score {
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
color: var(--brand-dark);
|
||||||
|
background: var(--brand-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--fs-small);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 指标 ---- */
|
||||||
|
.advisor-plan__metrics {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.advisor-plan__metric { padding: var(--space-2); background: var(--surface-soft); border-radius: var(--radius-sm); }
|
||||||
|
.advisor-plan__metric dt { color: var(--muted); font-size: var(--fs-small); }
|
||||||
|
.advisor-plan__metric dd { margin: var(--space-1) 0 0; font-weight: 700; }
|
||||||
|
.advisor-plan__value--up { color: var(--plan-up); }
|
||||||
|
.advisor-plan__value--down { color: var(--plan-down); }
|
||||||
|
.advisor-plan__value--flat { color: var(--ink-soft); }
|
||||||
|
|
||||||
|
/* ---- 看点 ---- */
|
||||||
|
.advisor-plan__highlights { margin: 0; padding-left: 1.1em; display: grid; gap: var(--space-1); color: var(--ink-soft); font-size: var(--fs-small); line-height: 1.6; }
|
||||||
|
.advisor-plan__highlights:empty { display: none; }
|
||||||
|
|
||||||
|
.advisor-plan__reason { margin: 0; color: var(--ink-soft); font-size: var(--fs-small); line-height: 1.7; }
|
||||||
|
.advisor-plan__reason strong { display: block; margin-bottom: var(--space-1); color: var(--ink); }
|
||||||
|
/* 文案来源标注:AI 生成的必须可辨识。 */
|
||||||
|
.advisor-plan__tag {
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
padding: 1px var(--space-2);
|
||||||
|
color: var(--brand-dark);
|
||||||
|
background: var(--brand-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.advisor-plan__tag--rule { color: var(--muted); background: var(--surface-soft); }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiClient } from '/static/portal/common/api-client.js?v=20260913';
|
import { apiClient } from '/static/portal/common/api-client.js?v=20260916-plan1';
|
||||||
import { requireCustomer } from '/static/portal/common/auth.js?v=20260913';
|
import { requireCustomer } from '/static/portal/common/auth.js?v=20260913';
|
||||||
import { mountShell } from '/static/portal/common/layout/app-shell.js';
|
import { mountShell } from '/static/portal/common/layout/app-shell.js';
|
||||||
import { renderError, renderLoading } from '/static/portal/common/state-view.js';
|
import { renderError, renderLoading } from '/static/portal/common/state-view.js';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const PUBLIC_LINKS = [
|
|||||||
|
|
||||||
const CUSTOMER_LINKS = [
|
const CUSTOMER_LINKS = [
|
||||||
['dashboard', '资产总览', '/portal/customer/dashboard/'],
|
['dashboard', '资产总览', '/portal/customer/dashboard/'],
|
||||||
|
['advisor-plans', '我的投顾方案', '/portal/customer/advisor-plans/'],
|
||||||
['holdings', '我的持仓', '/portal/customer/holdings/'],
|
['holdings', '我的持仓', '/portal/customer/holdings/'],
|
||||||
['profit-loss', '收益明细', '/portal/customer/profit-loss/'],
|
['profit-loss', '收益明细', '/portal/customer/profit-loss/'],
|
||||||
['orders', '交易记录', '/portal/customer/orders/'],
|
['orders', '交易记录', '/portal/customer/orders/'],
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/* 「我的投顾方案」页面样式。
|
||||||
|
*
|
||||||
|
* 只写本页自己的东西(交付物卡片列表);表格沿用 common/base.css 的 `.data-table`。
|
||||||
|
* 取色/取间距一律用 tokens.css 的变量,不写死色值与 px。
|
||||||
|
*/
|
||||||
|
.advisor-plan-card {
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.advisor-plan-card:last-child { border-bottom: 0; }
|
||||||
|
.advisor-plan-card__title { margin: 0; font-size: var(--fs-title); font-weight: 700; }
|
||||||
|
.advisor-plan-card__no { color: var(--muted); font-size: var(--fs-small); font-weight: 500; }
|
||||||
|
.advisor-plan-card__meta { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
|
||||||
|
.advisor-plan-card .button--quiet { justify-self: start; }
|
||||||
|
.advisor-plan-card__detail { margin-top: var(--space-3); display: grid; gap: var(--space-2); }
|
||||||
|
.advisor-plan-card__note { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.65; }
|
||||||
|
.advisor-plan-card__json {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.advisor-plan-card .data-table-wrap { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
|
/* ---- 申报表单(`.form-field` / `.form-alert` 来自 base.css,这里只排布) ---- */
|
||||||
|
.advisor-request-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.advisor-request-form__wide { grid-column: 1 / -1; }
|
||||||
|
.advisor-request-form .button { justify-self: start; }
|
||||||
|
.advisor-request-form .form-alert { grid-column: 1 / -1; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.advisor-request-form { grid-template-columns: 1fr; }
|
||||||
|
.advisor-request-form__wide { grid-column: auto; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// 「我的投顾方案」——客户侧的接收页。
|
||||||
|
//
|
||||||
|
// 现状背景:投顾工作台的「发送给客户」原先**没有落点**(只把 `published_at` 置上),
|
||||||
|
// 客户端门户没有任何页面/接口能读到已发布交付物。本页 + `MY_ADVISOR_CONTENTS`
|
||||||
|
// 端点是那条链路的接收端:投顾发布 → 客户登录这里就能看到。
|
||||||
|
//
|
||||||
|
// 数据口径(后端 `ProductRecommendationService.my_published`):
|
||||||
|
// **本人**(`customer_id == 登录客户`)、`review_status ∈ {approved, published}` 且
|
||||||
|
// `published_at` 非空的两类内容 —— 推荐方案与投资方案书。
|
||||||
|
//
|
||||||
|
// 推荐明细的**可视化**(走势图/指标/组合构成)与投顾工作台结果区共用
|
||||||
|
// `common/advisor-plan-view.js`:同一份方案在两处渲染成同一个样子。
|
||||||
|
// `apiClient` 用页面统一的那份(`?v=20260916-plan1`,与 `customer-list-page.js` 一致),
|
||||||
|
// 由调用方传给 `hydratePlanView`(共享模块内不 import,避免多出一个实例)。
|
||||||
|
|
||||||
|
import { setupCustomerListPage } from '/static/portal/common/customer-list-page.js';
|
||||||
|
import { requireCustomer } from '/static/portal/common/auth.js?v=20260913';
|
||||||
|
import { apiClient } from '/static/portal/common/api-client.js?v=20260916-plan1';
|
||||||
|
import { showToast } from '/static/portal/common/notifications.js';
|
||||||
|
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
|
||||||
|
import { renderEmpty } from '/static/portal/common/state-view.js';
|
||||||
|
import {
|
||||||
|
hydratePlanView,
|
||||||
|
renderPlanProducts,
|
||||||
|
} from '/static/portal/common/advisor-plan-view.js?v=20260916-plan3';
|
||||||
|
|
||||||
|
//: 内容类型文案。与投顾侧 `advisor-config.js` 的 `CONTENT_TYPE_LABELS` 同源口径。
|
||||||
|
const TYPE_LABELS = Object.freeze({
|
||||||
|
advisor_recommendation_plan: '产品推荐方案',
|
||||||
|
investment_goal_book: '投资目标方案书',
|
||||||
|
});
|
||||||
|
|
||||||
|
function typeLabel(type) {
|
||||||
|
return TYPE_LABELS[type] || '投顾方案';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailHtml(item) {
|
||||||
|
const plan = item.plan && typeof item.plan === 'object' ? item.plan : {};
|
||||||
|
const products = Array.isArray(plan.products) ? plan.products : [];
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (products.length) {
|
||||||
|
html += renderPlanProducts(products);
|
||||||
|
const summary = plan.selection_summary;
|
||||||
|
if (summary) {
|
||||||
|
html += '<p class="advisor-plan-card__note">'
|
||||||
|
+ `候选 ${escapeHtml(String(summary.candidate_count ?? '--'))} 只:`
|
||||||
|
+ `入选 ${escapeHtml(String(summary.selected_count ?? '--'))} 只、`
|
||||||
|
+ `排除 ${escapeHtml(String(summary.excluded_count ?? '--'))} 只。</p>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 方案书等结构不固定的内容:原样呈现,不臆造字段。
|
||||||
|
html += `<pre class="advisor-plan-card__json">${escapeHtml(JSON.stringify(plan, null, 2))}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
(Array.isArray(plan.disclosures) ? plan.disclosures : []).forEach((text) => {
|
||||||
|
html += `<p class="advisor-plan-card__note">${escapeHtml(String(text))}</p>`;
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHtml(item) {
|
||||||
|
const plan = item.plan && typeof item.plan === 'object' ? item.plan : {};
|
||||||
|
const products = Array.isArray(plan.products) ? plan.products : [];
|
||||||
|
const names = products.map((product) => product.product_name || product.product_code || '--').slice(0, 5);
|
||||||
|
return '<article class="advisor-plan-card">'
|
||||||
|
+ `<h3 class="advisor-plan-card__title">${escapeHtml(typeLabel(item.content_type))}`
|
||||||
|
+ ` <span class="advisor-plan-card__no">编号 ${escapeHtml(item.content_id)}</span></h3>`
|
||||||
|
+ `<p class="advisor-plan-card__meta">发布于 ${escapeHtml(formatDateTime(item.published_at))}</p>`
|
||||||
|
+ (names.length
|
||||||
|
? `<p class="advisor-plan-card__meta">推荐产品:${escapeHtml(names.join('、'))}${products.length > names.length ? ' 等' : ''}</p>`
|
||||||
|
: '')
|
||||||
|
+ `<button class="button button--quiet" type="button" data-plan-detail="${escapeHtml(item.content_id)}">查看详情</button>`
|
||||||
|
+ '<div class="advisor-plan-card__detail" data-plan-body hidden></div>'
|
||||||
|
+ '</article>';
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCustomerListPage({
|
||||||
|
active: 'advisor-plans',
|
||||||
|
endpointId: 'MY_ADVISOR_CONTENTS',
|
||||||
|
// 后端不做游标分页(一次最多 50 条),恒返回空游标 ⇒ 「下一页」按钮自动禁用。
|
||||||
|
getNextCursor: () => null,
|
||||||
|
render(container, items) {
|
||||||
|
if (!items.length) {
|
||||||
|
renderEmpty(container, '暂无投顾方案', '投顾审核通过并发送给你的方案会显示在这里。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const byId = new Map(items.map((item) => [String(item.content_id), item]));
|
||||||
|
container.innerHTML = items.map(cardHtml).join('');
|
||||||
|
container.querySelectorAll('[data-plan-detail]').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const body = button.parentElement.querySelector('[data-plan-body]');
|
||||||
|
const item = byId.get(String(button.dataset.planDetail));
|
||||||
|
if (!body || !item) return;
|
||||||
|
if (!body.hidden) {
|
||||||
|
body.hidden = true;
|
||||||
|
button.textContent = '查看详情';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = detailHtml(item);
|
||||||
|
body.hidden = false;
|
||||||
|
button.textContent = '收起';
|
||||||
|
// 走势图/组合曲线/指标是异步补的;失败只降级为"暂无净值数据"。
|
||||||
|
const plan = item.plan && typeof item.plan === 'object' ? item.plan : {};
|
||||||
|
void hydratePlanView(body, apiClient, Array.isArray(plan.products) ? plan.products : []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 主动申报投顾方案 ------------------------------------------------------
|
||||||
|
//
|
||||||
|
// 客户在这里提交申报(金额/期限/风险偏好/备注)→ 后端落 `advisor_service_request`
|
||||||
|
// (status=pending)→ 投顾工作台「客户申报」受理后**自动出方案草稿** →
|
||||||
|
// 投顾审核通过并发送,方案才会出现在上面的「已收到的方案」里。
|
||||||
|
//
|
||||||
|
// 服务端会再校验一次风险测评有效性(FM-03),前端不做前置拦截:
|
||||||
|
// 拦截只是体验层,真正的边界在服务端。
|
||||||
|
|
||||||
|
//: 申报单状态 → 文案 / 标签色。`delivered` 由后端**推导**(关联方案已发布)。
|
||||||
|
const REQUEST_STATUS_LABELS = Object.freeze({
|
||||||
|
pending: '待受理',
|
||||||
|
accepted: '已受理 · 方案待审核',
|
||||||
|
delivered: '已发送方案',
|
||||||
|
rejected: '已驳回',
|
||||||
|
});
|
||||||
|
const REQUEST_STATUS_TONES = Object.freeze({
|
||||||
|
pending: 'medium',
|
||||||
|
accepted: 'active',
|
||||||
|
delivered: 'low',
|
||||||
|
rejected: 'failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
function requestCardHtml(item) {
|
||||||
|
const label = REQUEST_STATUS_LABELS[item.status] || item.status;
|
||||||
|
const tone = REQUEST_STATUS_TONES[item.status] || 'neutral';
|
||||||
|
return '<article class="advisor-plan-card">'
|
||||||
|
+ `<h3 class="advisor-plan-card__title">申报单 ${escapeHtml(item.request_no)}`
|
||||||
|
+ ` <span class="status-tag status-tag--${tone}">${escapeHtml(label)}</span></h3>`
|
||||||
|
+ `<p class="advisor-plan-card__meta">${escapeHtml(String(item.amount_wan))} 万元`
|
||||||
|
+ ` · 期限 ${escapeHtml(item.horizon || '--')}`
|
||||||
|
+ ` · 风险偏好 ${escapeHtml(item.risk_preference || '--')}`
|
||||||
|
+ ` · 提交于 ${escapeHtml(formatDateTime(item.created_at))}</p>`
|
||||||
|
+ (item.note ? `<p class="advisor-plan-card__meta">备注:${escapeHtml(item.note)}</p>` : '')
|
||||||
|
+ (item.advisor_note
|
||||||
|
? `<p class="advisor-plan-card__note">投顾意见:${escapeHtml(item.advisor_note)}</p>`
|
||||||
|
: '')
|
||||||
|
+ '</article>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initRequestSection() {
|
||||||
|
const form = document.querySelector('[data-request-form]');
|
||||||
|
const listNode = document.querySelector('[data-request-list]');
|
||||||
|
const alertNode = document.querySelector('[data-request-alert]');
|
||||||
|
const submit = document.querySelector('[data-request-submit]');
|
||||||
|
if (!form || !listNode) return;
|
||||||
|
|
||||||
|
function showAlert(message, tone = 'error') {
|
||||||
|
if (!alertNode) return;
|
||||||
|
alertNode.textContent = message || '';
|
||||||
|
alertNode.classList.toggle('form-alert--visible', Boolean(message));
|
||||||
|
alertNode.classList.toggle('form-alert--info', tone === 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRequests() {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('MY_ADVISOR_REQUESTS');
|
||||||
|
const rows = Array.isArray(response.data) ? response.data : [];
|
||||||
|
listNode.innerHTML = rows.length
|
||||||
|
? rows.map(requestCardHtml).join('')
|
||||||
|
: '<p class="advisor-plan-card__note">还没有提交过申报。填好上面的表单点「提交申报」即可。</p>';
|
||||||
|
} catch (error) {
|
||||||
|
apiClient.reportError(error);
|
||||||
|
listNode.innerHTML = '<p class="advisor-plan-card__note">申报记录加载失败,请稍后刷新。</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
showAlert('');
|
||||||
|
const amount = Number(form.querySelector('[data-request-amount]').value);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
showAlert('请填写大于 0 的投资金额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const note = form.querySelector('[data-request-note]').value.trim();
|
||||||
|
submit.disabled = true;
|
||||||
|
submit.textContent = '提交中…';
|
||||||
|
try {
|
||||||
|
await apiClient.post('ADVISOR_REQUEST_CREATE', {
|
||||||
|
amount_wan: amount,
|
||||||
|
horizon: form.querySelector('[data-request-horizon]').value,
|
||||||
|
risk_preference: form.querySelector('[data-request-risk]').value,
|
||||||
|
note: note || null,
|
||||||
|
});
|
||||||
|
showToast('申报已提交,投顾受理后会为你出具方案');
|
||||||
|
form.querySelector('[data-request-note]').value = '';
|
||||||
|
await loadRequests();
|
||||||
|
} catch (error) {
|
||||||
|
apiClient.reportError(error);
|
||||||
|
// 后端的业务拒绝(例如"请先完成风险测评")要原样给客户看到,否则不知道卡在哪。
|
||||||
|
showAlert(error.message || '提交失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
submit.textContent = '提交申报';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void loadRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireCustomer()) initRequestSection();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>我的投顾方案 · 南方财富</title><link rel="stylesheet" href="/static/portal/common/base.css"><link rel="stylesheet" href="/static/portal/common/advisor-plan.css?v=20260916-plan3"><link rel="stylesheet" href="/static/portal/customer/advisor-plans/advisor-plans.css?v=20260916-plan3"></head><body><main id="main-content" class="page-shell"><div class="page-heading"><div><h1 class="page-heading__title">我的投顾方案</h1><p class="page-heading__description">可在下方申报投顾方案(需已完成且在有效期内的风险测评);投顾受理并发送后,方案会出现在「已收到的方案」里。</p></div><div class="page-heading__actions"><button class="button" type="button" data-reload>刷新</button></div></div><section class="panel"><div class="panel__header"><h2 class="panel__title">申报投顾方案</h2></div><div class="panel__body"><form class="advisor-request-form" data-request-form><label class="form-field"><span class="form-field__label">投资金额(万元)</span><input class="form-field__input" data-request-amount type="number" min="1" step="1" value="500" required></label><label class="form-field"><span class="form-field__label">投资期限</span><select class="form-field__input" data-request-horizon><option><1年</option><option>1-3年</option><option selected>3-5年</option><option>>5年</option></select></label><label class="form-field"><span class="form-field__label">风险偏好</span><select class="form-field__input" data-request-risk><option>稳健</option><option selected>平衡</option><option>进取</option></select></label><label class="form-field advisor-request-form__wide"><span class="form-field__label">补充说明(可选)</span><input class="form-field__input" data-request-note maxlength="200" placeholder="例如:希望偏债、不想承受太大波动"></label><button class="button button--primary" type="submit" data-request-submit>提交申报</button><p class="form-alert" data-request-alert></p></form></div></section><section class="panel panel--flush"><div class="panel__header"><h2 class="panel__title">我的申报</h2></div><div class="panel__body" data-request-list></div></section><section class="panel panel--flush"><div class="panel__header"><h2 class="panel__title">已收到的方案</h2></div><div class="panel__body" data-list-content></div></section></main><script type="module" src="/static/portal/customer/advisor-plans/advisor-plans.js?v=20260916-plan3"></script></body></html>
|
||||||
@@ -13,6 +13,12 @@
|
|||||||
|
|
||||||
import { apiClient } from '/static/portal/common/api-client.js?v=20260914-5';
|
import { apiClient } from '/static/portal/common/api-client.js?v=20260914-5';
|
||||||
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913';
|
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913';
|
||||||
|
// 推荐方案的可视化卡片(走势图 / 指标 / 组合构成)。与客户「我的投顾方案」页**共用**,
|
||||||
|
// 避免两处各写一套后漂移。取数用的是本模块的 apiClient(依赖注入,模块内不 import)。
|
||||||
|
import {
|
||||||
|
hydratePlanView,
|
||||||
|
renderPlanProducts,
|
||||||
|
} from '/static/portal/common/advisor-plan-view.js?v=20260916-plan3';
|
||||||
import {
|
import {
|
||||||
ACTION_DESCRIPTIONS,
|
ACTION_DESCRIPTIONS,
|
||||||
ACTION_ENDPOINTS,
|
ACTION_ENDPOINTS,
|
||||||
@@ -256,15 +262,9 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon
|
|||||||
'info',
|
'info',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
html += table(['产品', '风险级', '评分', '推荐依据'], products.map((product) => {
|
// 可视化卡片:每只基金一张卡(走势图 + 净值/区间涨跌/基金经理/费率/评分 + 推荐依据),
|
||||||
const evidence = product.recommendation_evidence_card || {};
|
// 外加组合构成条。行情与净值是**异步**补进去的(见 show() 里的 hydratePlanView)。
|
||||||
return [
|
html += renderPlanProducts(products);
|
||||||
`<span class="data-table__primary">${escapeHtml(product.product_name)}</span><span class="data-table__secondary">${escapeHtml(product.product_code)} · ${escapeHtml(product.product_category)}</span>`,
|
|
||||||
escapeHtml((evidence.suitability || {}).risk_level || '--'),
|
|
||||||
escapeHtml(String(product.score ?? '--')),
|
|
||||||
escapeHtml(product.reason || '--'),
|
|
||||||
];
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
(data.disclosures || []).forEach((text) => { html += disclaimer(text); });
|
(data.disclosures || []).forEach((text) => { html += disclaimer(text); });
|
||||||
return html;
|
return html;
|
||||||
@@ -399,6 +399,8 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon
|
|||||||
animate(blockedIndex, () => {
|
animate(blockedIndex, () => {
|
||||||
output.innerHTML = markupFor(action, data, source);
|
output.innerHTML = markupFor(action, data, source);
|
||||||
bindRecommendReviewActions();
|
bindRecommendReviewActions();
|
||||||
|
// 结果里若有推荐卡片骨架,异步补走势图、组合曲线与指标(失败只降级为"暂无数据")。
|
||||||
|
void hydratePlanView(output, apiClient, (data && data.products) || []);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* 分层:common/base.css(设计令牌 + 通用组件)→ common/operations.css(工作台共享层)
|
* 分层:common/base.css(设计令牌 + 通用组件)→ common/operations.css(工作台共享层)
|
||||||
* → 本文件(只写投顾页自己的东西)。
|
* → 本文件(只写投顾页自己的东西)。
|
||||||
*
|
*
|
||||||
* 构图:**左栏(336px)客户与动作 + 主区(流水线 / 结果 / 已发布)**。
|
* 构图:**左栏(336px)客户与动作 + 主区(流水线 / 结果 / 历史方案记录)**。
|
||||||
* 约定:
|
* 约定:
|
||||||
* · 只用 tokens.css 的变量取色/取间距,不写死色值与 px 间距;
|
* · 只用 tokens.css 的变量取色/取间距,不写死色值与 px 间距;
|
||||||
* · 类名用 BEM(`block__element--modifier`),状态用修饰类而不是内联样式;
|
* · 类名用 BEM(`block__element--modifier`),状态用修饰类而不是内联样式;
|
||||||
@@ -169,12 +169,25 @@
|
|||||||
.advisor-inline-form .form-field { flex: 1 1 180px; }
|
.advisor-inline-form .form-field { flex: 1 1 180px; }
|
||||||
.advisor-output .form-alert { margin: var(--space-3) 0; }
|
.advisor-output .form-alert { margin: var(--space-3) 0; }
|
||||||
|
|
||||||
/* ---- 已发布交付物 ---- */
|
/* ---- 历史方案记录(卡片形态与结果区一致) ---- */
|
||||||
.advisor-card { padding: var(--space-4); display: grid; gap: var(--space-2); border-bottom: 1px solid var(--line); }
|
.advisor-card { padding: var(--space-4); display: grid; gap: var(--space-2); border-bottom: 1px solid var(--line); }
|
||||||
.advisor-card:last-child { border-bottom: 0; }
|
.advisor-card:last-child { border-bottom: 0; }
|
||||||
.advisor-card__title { margin: 0; font-size: 16px; font-weight: 680; }
|
.advisor-card__title { margin: 0; font-size: 16px; font-weight: 680; }
|
||||||
.advisor-card__meta { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
|
.advisor-card__meta { margin: 0; color: var(--muted); font-size: var(--fs-small); line-height: 1.6; }
|
||||||
.advisor-card__content { margin: var(--space-2) 0 0; padding: var(--space-3); color: var(--ink-soft); background: var(--canvas); border-radius: var(--radius-sm); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
|
.advisor-card__content { margin: var(--space-2) 0 0; padding: var(--space-3); color: var(--ink-soft); background: var(--canvas); border-radius: var(--radius-sm); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
|
||||||
|
/* 标题里的状态标签:`operations.css` 只给了 `.status-tag--*` 的配色,这里补布局。 */
|
||||||
|
.advisor-card__title .status-tag { margin-left: var(--space-2); padding: 1px var(--space-2); border-radius: var(--radius-sm); font-size: var(--fs-small); font-weight: 600; vertical-align: middle; }
|
||||||
|
/* 卡片可点开详情:给出可点击的视觉与键盘焦点反馈。 */
|
||||||
|
.advisor-card--clickable { cursor: pointer; transition: background 160ms ease; }
|
||||||
|
.advisor-card--clickable:hover { background: var(--surface-soft); }
|
||||||
|
.advisor-card--clickable:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
|
||||||
|
.advisor-card__hint { margin: var(--space-2) 0 0; color: var(--brand-dark); font-size: var(--fs-small); font-weight: 600; }
|
||||||
|
/* 四个操作按钮:换行排列,状态不适用时禁用(口径与后端状态机一致)。 */
|
||||||
|
.advisor-card__actions { margin-top: var(--space-3); display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||||
|
.advisor-card__actions .button[disabled] { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* 详情弹窗:表格沿用结果区的边框样式(`.advisor-output .data-table-wrap` 只作用于结果区)。 */
|
||||||
|
.advisor-history-dialog .data-table-wrap { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
/* ---- 执行日志 ---- */
|
/* ---- 执行日志 ---- */
|
||||||
.log-item { font-size: var(--fs-small); line-height: 1.6; overflow-wrap: anywhere; }
|
.log-item { font-size: var(--fs-small); line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// 历史方案记录(列表 + 详情弹窗 + 四个操作按钮)。
|
||||||
|
//
|
||||||
|
// 与已移除的「已发布交付物」面板不同:这里列**全部状态**的方案与方案书
|
||||||
|
// (含 `pending_review` / `pending` 待审、`rejected` 已驳回),用于回看
|
||||||
|
// 「以前生成过什么」。数据口径见后端 `GET /api/v1/advisor/recommendations/history`。
|
||||||
|
//
|
||||||
|
// 推荐方案(`advisor_recommendation_plan`)卡片带四个操作:审核通过 / 驳回 /
|
||||||
|
// 发送给客户 / 删除 —— 对应 ADVISOR_REVIEW_RECOMMENDATION、ADVISOR_PUBLISH_RECOMMENDATION
|
||||||
|
// 与 ADVISOR_DELETE_RECOMMENDATION 三个端点。**投资方案书不挂这四个按钮**:
|
||||||
|
// 它按 `goal_no` 走自己的确认/审核/发布链路,且被 `advisor_investment_goal` 外键引用,删不掉。
|
||||||
|
//
|
||||||
|
// 刷新时机:本模块自己操作后会 `load()`;此外 `actions-module` 在结果区审核/发布成功后
|
||||||
|
// 会派发 `advisor:published-refresh`,这里一并监听(事件名沿用旧约定)。
|
||||||
|
|
||||||
|
import { apiClient } from '/static/portal/common/api-client.js?v=20260916-history3';
|
||||||
|
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js?v=20260913';
|
||||||
|
import { showToast } from '/static/portal/common/notifications.js';
|
||||||
|
import { renderEmpty, renderError, renderLoading } from '/static/portal/common/state-view.js?v=20260913';
|
||||||
|
import { BOOK_STATUS_LABELS, CONTENT_TYPE_LABELS } from './advisor-config.js?v=20260914-advisor9';
|
||||||
|
|
||||||
|
//: 只有推荐方案有下面这四个动作。
|
||||||
|
const PLAN_CONTENT_TYPE = 'advisor_recommendation_plan';
|
||||||
|
|
||||||
|
//: 动作 → 按钮文案(顺序即展示顺序)。
|
||||||
|
const PLAN_ACTIONS = Object.freeze([
|
||||||
|
['approve', '审核通过'],
|
||||||
|
['reject', '驳回'],
|
||||||
|
['publish', '发送给客户'],
|
||||||
|
['delete', '删除'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
//: 两套状态表之外的补充:推荐方案的「待审核」态(`generate` 置 `pending_review`),
|
||||||
|
//: 以及方案书下架后置的 `draft`(既非 `pending` 也非 `approved`,故 `BOOK_STATUS_LABELS`
|
||||||
|
//: 里没有)。其余状态两套共用 `BOOK_STATUS_LABELS`。
|
||||||
|
const EXTRA_STATUS_LABELS = Object.freeze({
|
||||||
|
pending_review: '待审核',
|
||||||
|
draft: '草稿',
|
||||||
|
});
|
||||||
|
|
||||||
|
//: 状态 → 标签色(对齐 common/operations.css 的 `.status-tag--*` 修饰类)。
|
||||||
|
const STATUS_TONES = Object.freeze({
|
||||||
|
pending: 'medium',
|
||||||
|
pending_review: 'medium',
|
||||||
|
draft: 'medium',
|
||||||
|
approved: 'low',
|
||||||
|
published: 'active',
|
||||||
|
rejected: 'failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
//: 动作禁用口径 —— 与后端状态机一致(`ProductRecommendationService.review/publish`):
|
||||||
|
//: 审核只对 `pending_review`;发布只对已审核通过且**尚未发布**的。
|
||||||
|
function actionDisabled(action, row) {
|
||||||
|
if (action === 'approve' || action === 'reject') return row.review_status !== 'pending_review';
|
||||||
|
if (action === 'publish') return row.review_status !== 'approved' || Boolean(row.published_at);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
return EXTRA_STATUS_LABELS[status] || BOOK_STATUS_LABELS[status] || String(status || '--');
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(type) {
|
||||||
|
return CONTENT_TYPE_LABELS[type] || '交付物';
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionButtons(row) {
|
||||||
|
return '<div class="advisor-card__actions">'
|
||||||
|
+ PLAN_ACTIONS.map(([action, label]) => {
|
||||||
|
const disabled = actionDisabled(action, row) ? ' disabled' : '';
|
||||||
|
return `<button class="button table-action" type="button" data-history-action="${action}"${disabled}>`
|
||||||
|
+ `${escapeHtml(label)}</button>`;
|
||||||
|
}).join('')
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
//: 表格标记与 `actions-module.js` 的 `table()` 一致(同一页两套写法会漂移)。
|
||||||
|
function table(headers, rows) {
|
||||||
|
if (!rows.length) return '';
|
||||||
|
return '<div class="data-table-wrap"><table class="data-table"><thead><tr>'
|
||||||
|
+ headers.map((head) => `<th>${escapeHtml(head)}</th>`).join('')
|
||||||
|
+ '</tr></thead><tbody>'
|
||||||
|
+ rows.map((cells) => `<tr>${cells.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('')
|
||||||
|
+ '</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 详情 ----
|
||||||
|
|
||||||
|
function detailFacts(row) {
|
||||||
|
const facts = [
|
||||||
|
['方案编号', `#${row.content_id}`],
|
||||||
|
['类型', typeLabel(row.content_type)],
|
||||||
|
['状态', statusLabel(row.review_status)],
|
||||||
|
['客户', `客户 ${row.customer_id || '--'}`],
|
||||||
|
['生成时间', formatDateTime(row.created_at)],
|
||||||
|
];
|
||||||
|
if (row.reviewed_at) facts.push(['审核时间', formatDateTime(row.reviewed_at)]);
|
||||||
|
if (row.published_at) facts.push(['发布时间', formatDateTime(row.published_at)]);
|
||||||
|
return `<div class="detail-grid">${facts
|
||||||
|
.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`)
|
||||||
|
.join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(row) {
|
||||||
|
const plan = row.plan && typeof row.plan === 'object' ? row.plan : {};
|
||||||
|
const products = Array.isArray(plan.products) ? plan.products : [];
|
||||||
|
let html = detailFacts(row);
|
||||||
|
|
||||||
|
if (products.length) {
|
||||||
|
html += '<h3 class="advisor-output__subtitle">推荐产品</h3>'
|
||||||
|
+ table(['产品', '风险级', '评分', '推荐依据'], products.map((product) => {
|
||||||
|
const evidence = product.recommendation_evidence_card || {};
|
||||||
|
return [
|
||||||
|
`<span class="data-table__primary">${escapeHtml(product.product_name || '--')}</span>`
|
||||||
|
+ `<span class="data-table__secondary">${escapeHtml(product.product_code || '')}`
|
||||||
|
+ ` · ${escapeHtml(product.product_category || '')}</span>`,
|
||||||
|
escapeHtml((evidence.suitability || {}).risk_level || '--'),
|
||||||
|
escapeHtml(String(product.score ?? '--')),
|
||||||
|
escapeHtml(product.reason || '--'),
|
||||||
|
];
|
||||||
|
}));
|
||||||
|
const summary = plan.selection_summary;
|
||||||
|
if (summary) {
|
||||||
|
html += '<p class="advisor-disclaimer">'
|
||||||
|
+ `候选 ${escapeHtml(String(summary.candidate_count ?? '--'))} 只:`
|
||||||
|
+ `入选 ${escapeHtml(String(summary.selected_count ?? '--'))} 只、`
|
||||||
|
+ `排除 ${escapeHtml(String(summary.excluded_count ?? '--'))} 只。</p>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 方案书等非推荐类交付物:结构不固定,原样呈现,避免臆造字段。
|
||||||
|
html += '<h3 class="advisor-output__subtitle">方案内容</h3>'
|
||||||
|
+ `<div class="advisor-card__content">${escapeHtml(JSON.stringify(plan, null, 2))}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.review_comment) {
|
||||||
|
html += '<h3 class="advisor-output__subtitle">审核意见</h3>'
|
||||||
|
+ `<p class="advisor-output__message">${escapeHtml(String(plan.review_comment))}</p>`;
|
||||||
|
}
|
||||||
|
(Array.isArray(plan.disclosures) ? plan.disclosures : []).forEach((text) => {
|
||||||
|
html += `<p class="advisor-disclaimer">${escapeHtml(String(text))}</p>`;
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHistoryModule({ list, dialog }) {
|
||||||
|
let currentRows = [];
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
function rowAt(node) {
|
||||||
|
const card = node.closest('[data-history-index]');
|
||||||
|
if (!card) return null;
|
||||||
|
return currentRows[Number(card.dataset.historyIndex)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(row) {
|
||||||
|
if (!dialog || !row) return;
|
||||||
|
const titleNode = dialog.querySelector('[data-history-dialog-title]');
|
||||||
|
const bodyNode = dialog.querySelector('[data-history-dialog-body]');
|
||||||
|
if (titleNode) titleNode.textContent = `${typeLabel(row.content_type)} · 编号 ${row.content_id}`;
|
||||||
|
if (bodyNode) bodyNode.innerHTML = renderDetail(row);
|
||||||
|
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||||
|
else dialog.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(action, row) {
|
||||||
|
if (busy || !row) return;
|
||||||
|
const contentId = row.content_id;
|
||||||
|
if (action === 'delete' && !window.confirm(`确认删除方案 #${contentId}?删除后不可恢复。`)) return;
|
||||||
|
busy = true;
|
||||||
|
list.setAttribute('aria-busy', 'true');
|
||||||
|
try {
|
||||||
|
if (action === 'approve' || action === 'reject') {
|
||||||
|
await apiClient.post(
|
||||||
|
'ADVISOR_REVIEW_RECOMMENDATION',
|
||||||
|
{ decision: action === 'approve' ? 'approved' : 'rejected', comment: '' },
|
||||||
|
{ pathParams: { contentId } },
|
||||||
|
);
|
||||||
|
showToast(action === 'approve' ? `方案 #${contentId} 已审核通过` : `方案 #${contentId} 已驳回`);
|
||||||
|
} else if (action === 'publish') {
|
||||||
|
await apiClient.post('ADVISOR_PUBLISH_RECOMMENDATION', undefined, { pathParams: { contentId } });
|
||||||
|
showToast(`方案 #${contentId} 已发送给客户`);
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
await apiClient.del('ADVISOR_DELETE_RECOMMENDATION', { pathParams: { contentId } });
|
||||||
|
showToast(`方案 #${contentId} 已删除`);
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
apiClient.reportError(error);
|
||||||
|
showToast(error.message || '操作未完成', 'error');
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
list.removeAttribute('aria-busy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list.addEventListener('click', (event) => {
|
||||||
|
const button = event.target.closest('[data-history-action]');
|
||||||
|
if (button) {
|
||||||
|
const row = rowAt(button);
|
||||||
|
if (row) runAction(button.dataset.historyAction, row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = rowAt(event.target);
|
||||||
|
if (row) openDetail(row);
|
||||||
|
});
|
||||||
|
list.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
// 按钮自身会派发 click,别再当成「卡片激活」重复处理。
|
||||||
|
if (event.target.closest('[data-history-action]')) return;
|
||||||
|
const row = rowAt(event.target);
|
||||||
|
if (!row) return;
|
||||||
|
event.preventDefault();
|
||||||
|
openDetail(row);
|
||||||
|
});
|
||||||
|
if (dialog) {
|
||||||
|
dialog.querySelector('[data-history-dialog-close]')?.addEventListener('click', () => dialog.close());
|
||||||
|
// 点遮罩(事件目标就是 dialog 本身)关闭。
|
||||||
|
dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
renderLoading(list, 3);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('ADVISOR_HISTORY');
|
||||||
|
const rows = Array.isArray(response.data) ? response.data : [];
|
||||||
|
currentRows = rows;
|
||||||
|
if (!rows.length) {
|
||||||
|
renderEmpty(list, '暂无历史方案', '当前账号名下的客户还没有生成过方案或方案书;生成并保存一份推荐方案后,这里会留档。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = rows.map((row, index) => {
|
||||||
|
const tone = STATUS_TONES[row.review_status] || 'active';
|
||||||
|
const products = Array.isArray(row.plan?.products) ? row.plan.products : [];
|
||||||
|
const names = products.map((item) => item.product_name || item.product_code || '--').slice(0, 5);
|
||||||
|
return `<article class="advisor-card advisor-card--clickable" data-history-index="${index}"`
|
||||||
|
+ ' tabindex="0" role="button">'
|
||||||
|
+ `<h3 class="advisor-card__title">${escapeHtml(typeLabel(row.content_type))}`
|
||||||
|
+ ` · 编号 ${escapeHtml(row.content_id)}`
|
||||||
|
+ ` <span class="status-tag status-tag--${tone}">${escapeHtml(statusLabel(row.review_status))}</span></h3>`
|
||||||
|
+ `<p class="advisor-card__meta">客户 ${escapeHtml(row.customer_id || '--')}`
|
||||||
|
+ ` · 生成于 ${escapeHtml(formatDateTime(row.created_at))}`
|
||||||
|
+ ` · 最近更新 ${escapeHtml(formatDateTime(row.published_at || row.reviewed_at || row.created_at))}</p>`
|
||||||
|
+ (names.length ? `<p class="advisor-card__meta">产品:${escapeHtml(names.join('、'))}${products.length > names.length ? ' 等' : ''}</p>` : '')
|
||||||
|
+ (row.content_type === PLAN_CONTENT_TYPE ? actionButtons(row) : '')
|
||||||
|
+ '<p class="advisor-card__hint">点击卡片查看详情 ›</p>'
|
||||||
|
+ '</article>';
|
||||||
|
}).join('');
|
||||||
|
} catch (error) {
|
||||||
|
currentRows = [];
|
||||||
|
apiClient.reportError(error);
|
||||||
|
renderError(list, error, load);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 演示模式(未登录)没有后端可查,给一句说明而不是空白。
|
||||||
|
function showPlaceholder() {
|
||||||
|
renderEmpty(list, '历史方案需先登录', '当前为本地演示模式,未连接后端,因此没有历史记录可回放。');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('advisor:published-refresh', load);
|
||||||
|
|
||||||
|
return Object.freeze({ load, showPlaceholder });
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ async def test_portal_root_redirects_to_public_home() -> None:
|
|||||||
"/portal/customer/orders/",
|
"/portal/customer/orders/",
|
||||||
"/portal/customer/transactions/",
|
"/portal/customer/transactions/",
|
||||||
"/portal/customer/cash-ledger/",
|
"/portal/customer/cash-ledger/",
|
||||||
|
"/portal/customer/advisor-plans/",
|
||||||
"/portal/customer/risk-questionnaire/",
|
"/portal/customer/risk-questionnaire/",
|
||||||
"/portal/employee-console/login/",
|
"/portal/employee-console/login/",
|
||||||
"/portal/employee-console/workspace/",
|
"/portal/employee-console/workspace/",
|
||||||
@@ -172,20 +173,59 @@ def test_advisor_workspace_registers_documented_operation_endpoints() -> None:
|
|||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
for endpoint_id in (
|
for endpoint_id in (
|
||||||
"ADVISOR_PUBLISHED", "ADVISOR_GOAL", "ADVISOR_ANALYSIS",
|
"ADVISOR_PUBLISHED", "ADVISOR_HISTORY", "ADVISOR_GOAL", "ADVISOR_ANALYSIS",
|
||||||
"ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL",
|
"ADVISOR_ALLOCATION", "ADVISOR_RECOMMEND", "ADVISOR_CREATE_GOAL",
|
||||||
|
"ADVISOR_REVIEW_RECOMMENDATION", "ADVISOR_PUBLISH_RECOMMENDATION",
|
||||||
|
"ADVISOR_DELETE_RECOMMENDATION",
|
||||||
):
|
):
|
||||||
assert f"{endpoint_id}:" in source
|
assert f"{endpoint_id}:" in source
|
||||||
for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标"):
|
for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标", "历史方案记录"):
|
||||||
assert label in dashboard
|
assert label in dashboard
|
||||||
|
|
||||||
|
|
||||||
|
def test_advisor_plan_view_is_shared_by_both_surfaces() -> None:
|
||||||
|
"""推荐方案的可视化渲染必须**只有一份**,投顾工作台与客户页共用。
|
||||||
|
|
||||||
|
两处都是同一份 `advisor_recommendation_plan`,各写一套必然漂移
|
||||||
|
(改了一边忘了另一边)。这条测试同时守住「共享模块存在」与「两处都在用它」。
|
||||||
|
"""
|
||||||
|
view = (PORTAL / "common" / "advisor-plan-view.js").read_text(encoding="utf-8")
|
||||||
|
assert "export function renderPlanProducts" in view
|
||||||
|
assert "export async function hydratePlanView" in view
|
||||||
|
assert "P002" in view
|
||||||
|
for page in (
|
||||||
|
"employee-advisor/dashboard/actions-module.js",
|
||||||
|
"customer/advisor-plans/advisor-plans.js",
|
||||||
|
):
|
||||||
|
source = (PORTAL / page).read_text(encoding="utf-8")
|
||||||
|
assert "advisor-plan-view.js" in source, page
|
||||||
|
|
||||||
|
|
||||||
|
def test_advisor_history_module_exposes_plan_actions() -> None:
|
||||||
|
"""历史方案记录里,推荐方案卡片要带审核/驳回/发送/删除四个操作。
|
||||||
|
|
||||||
|
四个动作对应三个端点(审核与驳回共用一个 reviews 端点,用 `decision` 区分)。
|
||||||
|
少了 `ADVISOR_DELETE_RECOMMENDATION` 就只剩"能看不能删"。
|
||||||
|
"""
|
||||||
|
source = (
|
||||||
|
PORTAL / "employee-advisor" / "dashboard" / "history-module.js"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
for label in ("审核通过", "驳回", "发送给客户", "删除"):
|
||||||
|
assert label in source
|
||||||
|
for endpoint in (
|
||||||
|
"ADVISOR_REVIEW_RECOMMENDATION",
|
||||||
|
"ADVISOR_PUBLISH_RECOMMENDATION",
|
||||||
|
"ADVISOR_DELETE_RECOMMENDATION",
|
||||||
|
):
|
||||||
|
assert endpoint in source
|
||||||
|
|
||||||
|
|
||||||
def test_advisor_dashboard_is_composed_from_feature_modules() -> None:
|
def test_advisor_dashboard_is_composed_from_feature_modules() -> None:
|
||||||
source = (PORTAL / "employee-advisor" / "dashboard" / "dashboard.js").read_text(
|
source = (PORTAL / "employee-advisor" / "dashboard" / "dashboard.js").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
assert "./actions-module.js" in source
|
assert "./actions-module.js" in source
|
||||||
assert "./published-module.js" in source
|
assert "./history-module.js" in source
|
||||||
config = (PORTAL / "employee-advisor" / "dashboard" / "advisor-config.js").read_text(
|
config = (PORTAL / "employee-advisor" / "dashboard" / "advisor-config.js").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""推荐依据 LLM 增强的守卫测试。
|
||||||
|
|
||||||
|
重点不是"能不能调通模型",而是**三条合规边界**:
|
||||||
|
1. 收益承诺类表述必须被丢弃;
|
||||||
|
2. 未启用 / 缺密钥 / 调用失败一律回退(返回空字典),不得影响推荐主流程;
|
||||||
|
3. 只接受自己请求过的 product_code(模型返回别的代码不得采纳)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.service import advisor_reason_service as module
|
||||||
|
from app.service.advisor_reason_service import (
|
||||||
|
PROHIBITED_PHRASES,
|
||||||
|
AdvisorReasonService,
|
||||||
|
build_prompt,
|
||||||
|
is_compliant,
|
||||||
|
parse_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self._payload = payload
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, response=None, error=None):
|
||||||
|
self.response = response
|
||||||
|
self.error = error
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def post(self, *args, **kwargs):
|
||||||
|
self.calls += 1
|
||||||
|
if self.error is not None:
|
||||||
|
raise self.error
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides):
|
||||||
|
values = {
|
||||||
|
"advisor_reason_llm_enabled": True,
|
||||||
|
"advisor_reason_llm_base_url": "https://example.invalid",
|
||||||
|
"advisor_reason_llm_model": "deepseek-chat",
|
||||||
|
"advisor_reason_llm_timeout_seconds": 5.0,
|
||||||
|
"deepseek_api_key": "sk-test",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return SimpleNamespace(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_compliant_rejects_every_prohibited_phrase() -> None:
|
||||||
|
for phrase in PROHIBITED_PHRASES:
|
||||||
|
text = f"该产品风险等级 R3,与您的风险承受能力匹配,{phrase},可长期持有。"
|
||||||
|
assert not is_compliant(text), phrase
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_compliant_rejects_out_of_range_length() -> None:
|
||||||
|
assert not is_compliant("太短")
|
||||||
|
assert not is_compliant("风" * 200)
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_compliant_accepts_grounded_text() -> None:
|
||||||
|
text = (
|
||||||
|
"该产品为 R3 中等风险,与您的风险承受能力匹配;近 60 个交易日最大回撤 -8.71%,"
|
||||||
|
"建议作为组合的一部分配置。"
|
||||||
|
)
|
||||||
|
assert is_compliant(text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_items_tolerates_code_fence_and_noise() -> None:
|
||||||
|
raw = '结果如下:\n```json\n{"items":[{"product_code":"159329","reason":"x"}]}\n```'
|
||||||
|
assert parse_items(raw) == {"159329": "x"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_items_returns_empty_on_bad_shape() -> None:
|
||||||
|
assert parse_items("not json at all") == {}
|
||||||
|
assert parse_items('{"items": "oops"}') == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_prompt_carries_real_numbers() -> None:
|
||||||
|
prompt = build_prompt(
|
||||||
|
{"risk_level": 4, "horizon_months": 60, "liquidity": "30 日后可使用"},
|
||||||
|
[{
|
||||||
|
"product_code": "159329", "product_name": "沙特ETF南方",
|
||||||
|
"product_category": "ETF", "risk_level": "R5", "score": 0.96,
|
||||||
|
"return_20d_pct": -0.7126, "return_60d_pct": -1.8213,
|
||||||
|
"max_drawdown_60d_pct": -4.9219, "rule_reason": "规则文案",
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
assert "-0.71%" in prompt
|
||||||
|
assert "-4.92%" in prompt
|
||||||
|
assert "30 日后可使用" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enhance_drops_non_compliant_items(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(module, "get_settings", lambda: _settings())
|
||||||
|
client = _FakeClient(_FakeResponse({"choices": [{"message": {"content": (
|
||||||
|
'{"items":['
|
||||||
|
'{"product_code":"159329","reason":"该产品风险等级 R5,与您的风险承受能力匹配,'
|
||||||
|
'建议作为组合分散配置的一部分。"},'
|
||||||
|
'{"product_code":"159382","reason":"这只保证收益,稳赚,放心买。"},'
|
||||||
|
'{"product_code":"999999","reason":"该产品风险等级 R3,与您的风险承受能力匹配,'
|
||||||
|
'建议作为组合的一部分配置。"}'
|
||||||
|
']}'
|
||||||
|
)}}]}))
|
||||||
|
accepted = await AdvisorReasonService(client=client).enhance(
|
||||||
|
customer={}, products=[{"product_code": "159329"}, {"product_code": "159382"}]
|
||||||
|
)
|
||||||
|
# 命中收益承诺的 159382 被丢弃;未请求过的 999999 也不采纳。
|
||||||
|
assert set(accepted) == {"159329"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enhance_returns_empty_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module, "get_settings", lambda: _settings(advisor_reason_llm_enabled=False)
|
||||||
|
)
|
||||||
|
client = _FakeClient(_FakeResponse({"choices": []}))
|
||||||
|
result = await AdvisorReasonService(client=client).enhance(
|
||||||
|
customer={}, products=[{"product_code": "159329"}]
|
||||||
|
)
|
||||||
|
assert result == {}
|
||||||
|
assert client.calls == 0 # 未启用时**根本不发请求**
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enhance_returns_empty_without_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(module, "get_settings", lambda: _settings(deepseek_api_key=""))
|
||||||
|
client = _FakeClient(_FakeResponse({"choices": []}))
|
||||||
|
assert await AdvisorReasonService(client=client).enhance(
|
||||||
|
customer={}, products=[{"product_code": "159329"}]
|
||||||
|
) == {}
|
||||||
|
assert client.calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enhance_returns_empty_on_upstream_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(module, "get_settings", lambda: _settings())
|
||||||
|
client = _FakeClient(error=RuntimeError("boom"))
|
||||||
|
assert await AdvisorReasonService(client=client).enhance(
|
||||||
|
customer={}, products=[{"product_code": "159329"}]
|
||||||
|
) == {}
|
||||||
@@ -1,66 +1,115 @@
|
|||||||
@echo off
|
@echo off
|
||||||
chcp 936 >nul
|
chcp 936 >nul
|
||||||
setlocal
|
setlocal EnableDelayedExpansion
|
||||||
title 金融 Agent 平台 · 后端服务(API)
|
title ???? Agent ?? ?? ??????(API)
|
||||||
|
|
||||||
rem ============================================================
|
rem ============================================================
|
||||||
rem 双击本文件启动后端 API(FastAPI + uvicorn,127.0.0.1:8000)。
|
rem ?????????????? API??FastAPI + uvicorn??127.0.0.1:8000????
|
||||||
rem 只起接口与前端页面;Agent Worker(客服对话/知识向量同步/
|
rem ????????????棻Agent Worker????????/?????????/
|
||||||
rem 风控扫描)不含在内,需要时请另外启动。
|
rem ?????裩????????????????????????
|
||||||
rem PROJ 自动取本文件所在目录(项目根),仓库复制到哪都能跑。
|
rem PROJ ????????????????????????????????????????
|
||||||
rem 可选参数:启动后端.bat 8100 指定端口(默认 8000)
|
rem ???????????????.bat 8100 ?????????? 8000??
|
||||||
|
rem
|
||||||
|
rem ??????????? start.ps1 ??????£???
|
||||||
|
rem 1) ????????? .venv
|
||||||
|
rem 2) ???? conda ???? jr_py313
|
||||||
|
rem 3) py -3.13 ?????
|
||||||
|
rem 4) PATH ??? python????? 3.11+??
|
||||||
|
rem ?????? import fastapi/sqlalchemy/asyncmy/pydantic?????????
|
||||||
|
rem ????????汾????????????????????
|
||||||
rem ============================================================
|
rem ============================================================
|
||||||
|
|
||||||
set "PROJ=%~dp0"
|
set "PROJ=%~dp0"
|
||||||
set "HOST=127.0.0.1"
|
set "HOST=127.0.0.1"
|
||||||
set "PORT=%~1"
|
set "PORT=%~1"
|
||||||
if "%PORT%"=="" set "PORT=8000"
|
if "%PORT%"=="" set "PORT=8000"
|
||||||
set "PY=%PROJ%.venv\Scripts\python.exe"
|
set "PY="
|
||||||
|
|
||||||
echo ============================================================
|
rem ---------- 1) ????????? / conda ??????????? exe ·????----------
|
||||||
echo 金融 Agent 平台 · 后端服务
|
for %%C in (
|
||||||
echo ============================================================
|
"%PROJ%.venv\Scripts\python.exe"
|
||||||
echo.
|
"D:\conda\envs\jr_py313\python.exe"
|
||||||
|
"%USERPROFILE%\miniconda3\envs\jr_py313\python.exe"
|
||||||
|
"%USERPROFILE%\anaconda3\envs\jr_py313\python.exe"
|
||||||
|
) do (
|
||||||
|
if not defined PY (
|
||||||
|
if exist %%C (
|
||||||
|
%%C -c "import fastapi, sqlalchemy, asyncmy, pydantic" >nul 2>&1
|
||||||
|
if !errorlevel!==0 set "PY=%%~C"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if not exist "%PY%" (
|
rem ---------- 2) py -3.13 ??????????????? exe ·????----------
|
||||||
echo [错误] 找不到虚拟环境解释器:
|
if not defined PY (
|
||||||
echo %PY%
|
for /f "delims=" %%X in ('py -3.13 -c "import sys;print(sys.executable)" 2^>nul') do (
|
||||||
echo.
|
set "PY=%%X"
|
||||||
echo 请先在项目根执行一次环境安装:
|
)
|
||||||
echo py -3.13 -m venv .venv
|
if defined PY (
|
||||||
echo .venv\Scripts\python.exe -m pip install -e .
|
"!PY!" -c "import fastapi, sqlalchemy, asyncmy, pydantic" >nul 2>&1
|
||||||
echo.
|
if not !errorlevel!==0 set "PY="
|
||||||
pause
|
)
|
||||||
exit /b 1
|
)
|
||||||
|
|
||||||
|
rem ---------- 3) PATH ??? python?????????? exe ·????У?? >=3.11??----------
|
||||||
|
if not defined PY (
|
||||||
|
for /f "delims=" %%X in ('python -c "import sys;print(sys.executable)" 2^>nul') do (
|
||||||
|
set "PY=%%X"
|
||||||
|
)
|
||||||
|
if defined PY (
|
||||||
|
"!PY!" -c "import sys;v=sys.version_info;assert (v[0],v[1])>=(3,11);import fastapi, sqlalchemy, asyncmy, pydantic" >nul 2>&1
|
||||||
|
if not !errorlevel!==0 set "PY="
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not defined PY (
|
||||||
|
echo [????] ??????????б?????? Python ??????
|
||||||
|
echo ??? 3.11+ ????? fastapi / sqlalchemy / asyncmy / pydantic??
|
||||||
|
echo.
|
||||||
|
echo ??????????????????????????У???
|
||||||
|
echo py -3.13 -m venv .venv
|
||||||
|
echo .venv\Scripts\python.exe -m pip install -e .
|
||||||
|
echo.
|
||||||
|
echo ??????? conda ???? jr_py313???????????????λ??????
|
||||||
|
echo D:\conda\envs\jr_py313\python.exe
|
||||||
|
echo %USERPROFILE%\miniconda3\envs\jr_py313\python.exe
|
||||||
|
echo %USERPROFILE%\anaconda3\envs\jr_py313\python.exe
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
if not exist "%PROJ%app\main.py" (
|
if not exist "%PROJ%app\main.py" (
|
||||||
echo [错误] 未找到 app\main.py,本 .bat 必须放在项目根目录。
|
echo [????] δ??? app\main.py???? .bat ????????????????
|
||||||
echo 当前 PROJ = %PROJ%
|
echo ??? PROJ = %PROJ%
|
||||||
echo.
|
echo.
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
cd /d "%PROJ%"
|
cd /d "%PROJ%"
|
||||||
|
|
||||||
echo 项目目录 : %PROJ%
|
echo ============================================================
|
||||||
echo 解释器 : %PY%
|
echo ???? Agent ?? ?? ??????
|
||||||
echo 监听地址 : http://%HOST%:%PORT%
|
echo ============================================================
|
||||||
echo 前端入口 : http://%HOST%:%PORT%/portal/
|
|
||||||
echo 健康检查 : http://%HOST%:%PORT%/health
|
|
||||||
echo.
|
echo.
|
||||||
echo 提示:请确认 MySQL(127.0.0.1:3306) 已启动。
|
echo ????? : %PROJ%
|
||||||
echo 按 Ctrl+C 可停止服务。
|
echo ?????? : !PY!
|
||||||
|
echo ??????? : http://%HOST%:%PORT%
|
||||||
|
echo ?????? : http://%HOST%:%PORT%/portal/
|
||||||
|
echo ??????? : http://%HOST%:%PORT%/health
|
||||||
|
echo.
|
||||||
|
echo ?????????? MySQL(127.0.0.1:3306) ???????
|
||||||
|
echo ?? Ctrl+C ????????
|
||||||
echo ============================================================
|
echo ============================================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
"%PY%" -m uvicorn app.main:app --host %HOST% --port %PORT% --log-level info
|
"!PY!" -m uvicorn app.main:app --host %HOST% --port %PORT% --log-level info
|
||||||
|
|
||||||
set "RC=%ERRORLEVEL%"
|
set "RC=%ERRORLEVEL%"
|
||||||
echo.
|
echo.
|
||||||
echo ============================================================
|
echo ============================================================
|
||||||
echo 服务已退出(退出码 %RC%)。
|
echo ???????????????? %RC%????
|
||||||
echo ============================================================
|
echo ============================================================
|
||||||
pause
|
pause
|
||||||
endlocal
|
endlocal
|
||||||
|
|||||||
Reference in New Issue
Block a user