diff --git a/app/api/controllers/recommendations.py b/app/api/controllers/recommendations.py index 72ceb26..b4aa03b 100644 --- a/app/api/controllers/recommendations.py +++ b/app/api/controllers/recommendations.py @@ -21,6 +21,15 @@ admin_router = APIRouter( tags=["platform-admin"], 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") @@ -41,6 +50,19 @@ async def published_recommendations( 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 新增)------------------------------------ # # 为什么要有这两个**投顾侧**路由:审核/发布原先只在 `/api/v1/admin/advisor/...` @@ -76,6 +98,20 @@ async def advisor_publish_recommendation( 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( "/advisor/pending-contents", dependencies=[Depends(enforce_advisor_rollout)], @@ -123,3 +159,16 @@ async def publish_recommendation( key: str | None = Header(default=None, alias="Idempotency-Key"), ) -> dict[str, object]: 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) diff --git a/app/core/config.py b/app/core/config.py index 21680b7..4211ced 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -102,6 +102,18 @@ class Settings(BaseSettings): offsite_deepseek_api_key: str = "" offsite_deepseek_model: str = "deepseek-v4-flash" 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_dry_run: bool = True offsite_smtp_host: str = "" diff --git a/app/main.py b/app/main.py index 275d747..6fe97f4 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,12 @@ from fastapi.staticfiles import StaticFiles 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.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.auth import router as auth_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 ( 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.trading import router as trading_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(recommendation_advisor_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(trading_router) static_directory = Path(__file__).resolve().parent / "static" diff --git a/app/service/advisor_reason_service.py b/app/service/advisor_reason_service.py new file mode 100644 index 0000000..5970691 --- /dev/null +++ b/app/service/advisor_reason_service.py @@ -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 diff --git a/app/service/product_recommendation_service.py b/app/service/product_recommendation_service.py index 2b3ce83..f8ecb10 100644 --- a/app/service/product_recommendation_service.py +++ b/app/service/product_recommendation_service.py @@ -1,5 +1,6 @@ """Constraint-first recommendations for the exchange-traded simulation domain.""" +import logging from collections.abc import Callable from datetime import UTC, datetime from typing import Any @@ -8,19 +9,28 @@ from sqlalchemy import select from app.core.config import get_settings 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.infrastructure.db import SessionFactory from app.infrastructure.neo4j_graph_driver import Neo4jGraphDriver from app.model.audit import InteractionAudit +from app.model.fund import FundNavHistory from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent from app.repository.advisor_product_repository import ( AdvisorProductRepository, AuthoritativeProductCandidate, ) +from app.service.advisor_reason_service import AdvisorReasonService from app.service.api_transaction_service import ApiTransactionService from app.service.authorization_service import AuthorizationService 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.profile_governance_service import ProfileGovernanceService from app.service.relationship_service import RelationshipService @@ -31,6 +41,8 @@ from app.service.suitability_service import SuitabilityService #: 推荐方案用 `content_id`,方案书用 `goal_no`。 BOOK_CONTENT_TYPE = "investment_goal_book" +logger = logging.getLogger(__name__) + class ProductRecommendationService: CONTENT_TYPE = "advisor_recommendation_plan" @@ -95,6 +107,12 @@ class ProductRecommendationService: self._view(item, index, goal, graph_context) for index, item in enumerate(selected, start=1) ] + # 「推荐依据」的 LLM 增强:把**已经算出来的真实参数**交给模型,写成客户读得懂的说明。 + # 未启用 / 缺密钥 / 调用失败 / 未过合规守卫时,保留 `_view` 的确定性文案。 + # 注意:模型只改文案,**不参与选品**(候选池与排序在它之前就已固定)。 + products = await self._enhance_reasons( + products, selected, goal, authority.customer_risk_level + ) plan = { "document_type": "advisor_recommendation_plan", "document_version": "1.0", @@ -228,13 +246,24 @@ class ProductRecommendationService: candidate, score = item product = candidate.product 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 { "rank": rank, "product_code": product.product_code, "product_name": product.product_name, "product_category": product.product_category, - "reason": "该产品已通过场内可交易、权威适当性和合同证据校验," - "并与已确认投资目标的期限和流动性要求相匹配。", + "reason": reason, "score": round(score, 4), "recommendation_evidence_card": { "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]: if self.relationship_service is None: return {"degraded": True, "reason": "graph_not_configured"} @@ -349,6 +452,63 @@ class ProductRecommendationService: 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 def _visible_customer_ids(context: RequestContext) -> tuple[int, ...]: """可查看的客户 id:本人 + 名下归属客户。 @@ -395,6 +555,83 @@ class ProductRecommendationService: "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]: """管理面复核队列:待审核的推荐方案与投资方案书。 @@ -468,3 +705,26 @@ async def product_recommendation_tool( return await ProductRecommendationService(enforce_profile_governance=True).generate( 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 diff --git a/app/static/portal/common/advisor-plan-view.js b/app/static/portal/common/advisor-plan-view.js new file mode 100644 index 0000000..bd44aca --- /dev/null +++ b/app/static/portal/common/advisor-plan-view.js @@ -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 `
${escapeHtml(label)}
` + + `
${escapeHtml(value)}
`; +} + +// ---- 折线图(带坐标轴/网格;内联 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 `` + + `` + + `${escapeHtml(formatter(value))}`; + }).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 `` + + `${escapeHtml(shortDate(series[index].date))}`; + }).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 `` + + `${grid}` + + `${xLabels}` + + ``; +} + +/** 把一条净值序列归一成"相对首日的涨跌%"(组合合成的原料)。 */ +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 = ``; + offset += length; + return slice; + }).join(''); + return `` + + slices + + '等权组合' + + `${entries.length} 只` + + ''; +} + +// ---- 方案骨架 ---- + +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 '
' + + '
' + + `

${escapeHtml(product.product_name || '--')}

` + + `

${escapeHtml(code)}` + + ` · ${escapeHtml(product.product_category || '--')}

` + + '
' + + `${escapeHtml(riskOf(product))}` + + `评分 ${escapeHtml(String(product.score ?? '--'))}` + + '
' + + `
` + + '

走势加载中…

' + + `
` + + metric('风险等级', riskOf(product)) + metric('AI 评分', String(product.score ?? '--')) + + '
' + + `
    ` + // 标注文案来源:模型写的必须让人一眼看出来(与风控页"大模型生成"的标注惯例一致)。 + + '

    推荐依据' + + (product.reason_source === 'llm' + ? 'AI 生成' + : '规则生成') + + `${escapeHtml(product.reason || '--')}

    ` + + '
    '; +} + +/** 同步骨架:只用方案自带字段,行情/净值随后由 `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 `
  • ` + + `${escapeHtml(entry.label)}` + + `${escapeHtml(entry.code)}` + + `${share}%
  • `; + }).join(''); + return `
    ` + + '
    ' + + '

    组合业绩(等权合成)

    ' + + '
    ' + + '
    ' + + '

    组合走势加载中…

    ' + + '
    ' + + '
    ' + + '

    资产配置比例(等权)

    ' + + '
    ' + + `
    ${donutSvg(entries)}
    ` + + `
      ${legend}
    ` + + '
    ' + + `
    ${products.map(cardHtml).join('')}
    ` + + '
    '; +} + +// ---- 注解式补水 ---- + +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} 个交易日净值走势`, + }) + : '

    暂无净值数据' + + '(可运行 tools/sync_nav_history.py 同步)

    '; + } + + 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) => `
  • ${escapeHtml(text)}
  • `).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 = '

    成分基金净值不足,' + + '暂无法合成组合走势。

    '; + 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] || []); + }); +} diff --git a/app/static/portal/common/advisor-plan.css b/app/static/portal/common/advisor-plan.css new file mode 100644 index 0000000..cc3304e --- /dev/null +++ b/app/static/portal/common/advisor-plan.css @@ -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); } diff --git a/app/static/portal/common/customer-list-page.js b/app/static/portal/common/customer-list-page.js index 9b7c4a5..8cf2b84 100644 --- a/app/static/portal/common/customer-list-page.js +++ b/app/static/portal/common/customer-list-page.js @@ -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 { mountShell } from '/static/portal/common/layout/app-shell.js'; import { renderError, renderLoading } from '/static/portal/common/state-view.js'; diff --git a/app/static/portal/common/layout/app-shell.js b/app/static/portal/common/layout/app-shell.js index 3d3fb98..6d38203 100644 --- a/app/static/portal/common/layout/app-shell.js +++ b/app/static/portal/common/layout/app-shell.js @@ -15,6 +15,7 @@ const PUBLIC_LINKS = [ const CUSTOMER_LINKS = [ ['dashboard', '资产总览', '/portal/customer/dashboard/'], + ['advisor-plans', '我的投顾方案', '/portal/customer/advisor-plans/'], ['holdings', '我的持仓', '/portal/customer/holdings/'], ['profit-loss', '收益明细', '/portal/customer/profit-loss/'], ['orders', '交易记录', '/portal/customer/orders/'], diff --git a/app/static/portal/customer/advisor-plans/advisor-plans.css b/app/static/portal/customer/advisor-plans/advisor-plans.css new file mode 100644 index 0000000..de0a82c --- /dev/null +++ b/app/static/portal/customer/advisor-plans/advisor-plans.css @@ -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; } +} diff --git a/app/static/portal/customer/advisor-plans/advisor-plans.js b/app/static/portal/customer/advisor-plans/advisor-plans.js new file mode 100644 index 0000000..c4d07f1 --- /dev/null +++ b/app/static/portal/customer/advisor-plans/advisor-plans.js @@ -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 += '

    ' + + `候选 ${escapeHtml(String(summary.candidate_count ?? '--'))} 只:` + + `入选 ${escapeHtml(String(summary.selected_count ?? '--'))} 只、` + + `排除 ${escapeHtml(String(summary.excluded_count ?? '--'))} 只。

    `; + } + } else { + // 方案书等结构不固定的内容:原样呈现,不臆造字段。 + html += `
    ${escapeHtml(JSON.stringify(plan, null, 2))}
    `; + } + + (Array.isArray(plan.disclosures) ? plan.disclosures : []).forEach((text) => { + html += `

    ${escapeHtml(String(text))}

    `; + }); + 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 '
    ' + + `

    ${escapeHtml(typeLabel(item.content_type))}` + + ` 编号 ${escapeHtml(item.content_id)}

    ` + + `

    发布于 ${escapeHtml(formatDateTime(item.published_at))}

    ` + + (names.length + ? `

    推荐产品:${escapeHtml(names.join('、'))}${products.length > names.length ? ' 等' : ''}

    ` + : '') + + `` + + '' + + '
    '; +} + +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 '
    ' + + `

    申报单 ${escapeHtml(item.request_no)}` + + ` ${escapeHtml(label)}

    ` + + `

    ${escapeHtml(String(item.amount_wan))} 万元` + + ` · 期限 ${escapeHtml(item.horizon || '--')}` + + ` · 风险偏好 ${escapeHtml(item.risk_preference || '--')}` + + ` · 提交于 ${escapeHtml(formatDateTime(item.created_at))}

    ` + + (item.note ? `

    备注:${escapeHtml(item.note)}

    ` : '') + + (item.advisor_note + ? `

    投顾意见:${escapeHtml(item.advisor_note)}

    ` + : '') + + '
    '; +} + +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('') + : '

    还没有提交过申报。填好上面的表单点「提交申报」即可。

    '; + } catch (error) { + apiClient.reportError(error); + listNode.innerHTML = '

    申报记录加载失败,请稍后刷新。

    '; + } + } + + 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(); diff --git a/app/static/portal/customer/advisor-plans/index.html b/app/static/portal/customer/advisor-plans/index.html new file mode 100644 index 0000000..c833aab --- /dev/null +++ b/app/static/portal/customer/advisor-plans/index.html @@ -0,0 +1 @@ +我的投顾方案 · 南方财富

    我的投顾方案

    可在下方申报投顾方案(需已完成且在有效期内的风险测评);投顾受理并发送后,方案会出现在「已收到的方案」里。

    申报投顾方案

    我的申报

    已收到的方案

    diff --git a/app/static/portal/employee-advisor/dashboard/actions-module.js b/app/static/portal/employee-advisor/dashboard/actions-module.js index 44bb4e1..a5bcc71 100644 --- a/app/static/portal/employee-advisor/dashboard/actions-module.js +++ b/app/static/portal/employee-advisor/dashboard/actions-module.js @@ -13,6 +13,12 @@ import { apiClient } from '/static/portal/common/api-client.js?v=20260914-5'; 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 { ACTION_DESCRIPTIONS, ACTION_ENDPOINTS, @@ -256,15 +262,9 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon 'info', ); } else { - html += table(['产品', '风险级', '评分', '推荐依据'], products.map((product) => { - const evidence = product.recommendation_evidence_card || {}; - return [ - `${escapeHtml(product.product_name)}${escapeHtml(product.product_code)} · ${escapeHtml(product.product_category)}`, - escapeHtml((evidence.suitability || {}).risk_level || '--'), - escapeHtml(String(product.score ?? '--')), - escapeHtml(product.reason || '--'), - ]; - })); + // 可视化卡片:每只基金一张卡(走势图 + 净值/区间涨跌/基金经理/费率/评分 + 推荐依据), + // 外加组合构成条。行情与净值是**异步**补进去的(见 show() 里的 hydratePlanView)。 + html += renderPlanProducts(products); } (data.disclosures || []).forEach((text) => { html += disclaimer(text); }); return html; @@ -399,6 +399,8 @@ export function createActionsModule({ output, alert, steps, amountInput, horizon animate(blockedIndex, () => { output.innerHTML = markupFor(action, data, source); bindRecommendReviewActions(); + // 结果里若有推荐卡片骨架,异步补走势图、组合曲线与指标(失败只降级为"暂无数据")。 + void hydratePlanView(output, apiClient, (data && data.products) || []); }); } diff --git a/app/static/portal/employee-advisor/dashboard/dashboard.css b/app/static/portal/employee-advisor/dashboard/dashboard.css index 4926872..f5b17ab 100644 --- a/app/static/portal/employee-advisor/dashboard/dashboard.css +++ b/app/static/portal/employee-advisor/dashboard/dashboard.css @@ -3,7 +3,7 @@ * 分层:common/base.css(设计令牌 + 通用组件)→ common/operations.css(工作台共享层) * → 本文件(只写投顾页自己的东西)。 * - * 构图:**左栏(336px)客户与动作 + 主区(流水线 / 结果 / 已发布)**。 + * 构图:**左栏(336px)客户与动作 + 主区(流水线 / 结果 / 历史方案记录)**。 * 约定: * · 只用 tokens.css 的变量取色/取间距,不写死色值与 px 间距; * · 类名用 BEM(`block__element--modifier`),状态用修饰类而不是内联样式; @@ -169,12 +169,25 @@ .advisor-inline-form .form-field { flex: 1 1 180px; } .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:last-child { border-bottom: 0; } .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__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; } diff --git a/app/static/portal/employee-advisor/dashboard/history-module.js b/app/static/portal/employee-advisor/dashboard/history-module.js new file mode 100644 index 0000000..d582fc2 --- /dev/null +++ b/app/static/portal/employee-advisor/dashboard/history-module.js @@ -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 '
    ' + + PLAN_ACTIONS.map(([action, label]) => { + const disabled = actionDisabled(action, row) ? ' disabled' : ''; + return ``; + }).join('') + + '
    '; +} + +//: 表格标记与 `actions-module.js` 的 `table()` 一致(同一页两套写法会漂移)。 +function table(headers, rows) { + if (!rows.length) return ''; + return '
    ' + + headers.map((head) => ``).join('') + + '' + + rows.map((cells) => `${cells.map((cell) => ``).join('')}`).join('') + + '
    ${escapeHtml(head)}
    ${cell}
    '; +} + +// ---- 详情 ---- + +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 `
    ${facts + .map(([label, value]) => `
    ${escapeHtml(label)}
    ${escapeHtml(value)}
    `) + .join('')}
    `; +} + +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 += '

    推荐产品

    ' + + table(['产品', '风险级', '评分', '推荐依据'], products.map((product) => { + const evidence = product.recommendation_evidence_card || {}; + return [ + `${escapeHtml(product.product_name || '--')}` + + `${escapeHtml(product.product_code || '')}` + + ` · ${escapeHtml(product.product_category || '')}`, + escapeHtml((evidence.suitability || {}).risk_level || '--'), + escapeHtml(String(product.score ?? '--')), + escapeHtml(product.reason || '--'), + ]; + })); + const summary = plan.selection_summary; + if (summary) { + html += '

    ' + + `候选 ${escapeHtml(String(summary.candidate_count ?? '--'))} 只:` + + `入选 ${escapeHtml(String(summary.selected_count ?? '--'))} 只、` + + `排除 ${escapeHtml(String(summary.excluded_count ?? '--'))} 只。

    `; + } + } else { + // 方案书等非推荐类交付物:结构不固定,原样呈现,避免臆造字段。 + html += '

    方案内容

    ' + + `
    ${escapeHtml(JSON.stringify(plan, null, 2))}
    `; + } + + if (plan.review_comment) { + html += '

    审核意见

    ' + + `

    ${escapeHtml(String(plan.review_comment))}

    `; + } + (Array.isArray(plan.disclosures) ? plan.disclosures : []).forEach((text) => { + html += `

    ${escapeHtml(String(text))}

    `; + }); + 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 `
    ' + + `

    ${escapeHtml(typeLabel(row.content_type))}` + + ` · 编号 ${escapeHtml(row.content_id)}` + + ` ${escapeHtml(statusLabel(row.review_status))}

    ` + + `

    客户 ${escapeHtml(row.customer_id || '--')}` + + ` · 生成于 ${escapeHtml(formatDateTime(row.created_at))}` + + ` · 最近更新 ${escapeHtml(formatDateTime(row.published_at || row.reviewed_at || row.created_at))}

    ` + + (names.length ? `

    产品:${escapeHtml(names.join('、'))}${products.length > names.length ? ' 等' : ''}

    ` : '') + + (row.content_type === PLAN_CONTENT_TYPE ? actionButtons(row) : '') + + '

    点击卡片查看详情 ›

    ' + + '
    '; + }).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 }); +} diff --git a/tests/unit/api/test_portal_frontend.py b/tests/unit/api/test_portal_frontend.py index 17d64fc..ce3503d 100644 --- a/tests/unit/api/test_portal_frontend.py +++ b/tests/unit/api/test_portal_frontend.py @@ -39,6 +39,7 @@ async def test_portal_root_redirects_to_public_home() -> None: "/portal/customer/orders/", "/portal/customer/transactions/", "/portal/customer/cash-ledger/", + "/portal/customer/advisor-plans/", "/portal/customer/risk-questionnaire/", "/portal/employee-console/login/", "/portal/employee-console/workspace/", @@ -172,20 +173,59 @@ def test_advisor_workspace_registers_documented_operation_endpoints() -> None: encoding="utf-8" ) 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_REVIEW_RECOMMENDATION", "ADVISOR_PUBLISH_RECOMMENDATION", + "ADVISOR_DELETE_RECOMMENDATION", ): assert f"{endpoint_id}:" in source - for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标"): + for label in ("组合分析", "资产配置", "生成推荐方案", "录入客户目标", "历史方案记录"): 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: source = (PORTAL / "employee-advisor" / "dashboard" / "dashboard.js").read_text( encoding="utf-8" ) 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( encoding="utf-8" ) diff --git a/tests/unit/service/test_advisor_reason_service.py b/tests/unit/service/test_advisor_reason_service.py new file mode 100644 index 0000000..5bbb9e5 --- /dev/null +++ b/tests/unit/service/test_advisor_reason_service.py @@ -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"}] + ) == {} diff --git a/启动后端.bat b/启动后端.bat index 2fdf6e5..d4aefed 100644 --- a/启动后端.bat +++ b/启动后端.bat @@ -1,66 +1,115 @@ @echo off chcp 936 >nul -setlocal -title ½ðÈÚ Agent ƽ̨ ¡¤ ºó¶˷þÎñ(API) +setlocal EnableDelayedExpansion +title ???? Agent ?? ?? ??????(API) rem ============================================================ -rem ˫»÷±¾ÎļþÆô¶¯ºó¶Ë API£¨FastAPI + uvicorn£¬127.0.0.1:8000£©¡£ -rem ֻÆð½ӿÚÓëǰ¶ËҳÃ棻Agent Worker£¨¿ͷþ¶Ի°/֪ʶÏòÁ¿ͬ²½/ -rem ·ç¿ØɨÃ裩²»º¬ÔÚÄڣ¬ÐèҪʱÇëÁíÍâÆô¶¯¡£ -rem PROJ ×Զ¯ȡ±¾ÎļþËùÔÚĿ¼£¨ÏîĿ¸ù£©£¬²ֿ⸴ÖƵ½ÄͼÄÜÅܡ£ -rem ¿Éѡ²ÎÊý£ºÆô¶¯ºó¶Ë.bat 8100 ָ¶¨¶˿ڣ¨ĬÈÏ 8000£© +rem ?????????????? API??FastAPI + uvicorn??127.0.0.1:8000???? +rem ????????????—±Agent Worker????????/?????????/ +rem ?????Ñh???????????????????????? +rem PROJ ???????????????????????????????????????? +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 ============================================================ set "PROJ=%~dp0" set "HOST=127.0.0.1" set "PORT=%~1" if "%PORT%"=="" set "PORT=8000" -set "PY=%PROJ%.venv\Scripts\python.exe" +set "PY=" -echo ============================================================ -echo ½ðÈÚ Agent ƽ̨ ¡¤ ºó¶˷þÎñ -echo ============================================================ -echo. +rem ---------- 1) ????????? / conda ??????????? exe ¡¤????---------- +for %%C in ( + "%PROJ%.venv\Scripts\python.exe" + "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%" ( - echo [´íÎó] ÕҲ»µ½ÐéÄ⻷¾³½âÊÍÆ÷£º - echo %PY% - echo. - echo ÇëÏÈÔÚÏîĿ¸ùִÐÐһ´λ·¾³°²װ£º - echo py -3.13 -m venv .venv - echo .venv\Scripts\python.exe -m pip install -e . - echo. - pause - exit /b 1 +rem ---------- 2) py -3.13 ??????????????? exe ¡¤????---------- +if not defined PY ( + for /f "delims=" %%X in ('py -3.13 -c "import sys;print(sys.executable)" 2^>nul') do ( + set "PY=%%X" + ) + if defined PY ( + "!PY!" -c "import fastapi, sqlalchemy, asyncmy, pydantic" >nul 2>&1 + if not !errorlevel!==0 set "PY=" + ) +) + +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" ( - echo [´íÎó] δÕҵ½ app\main.py£¬±¾ .bat ±ØÐë·ÅÔÚÏîĿ¸ùĿ¼¡£ - echo µ±ǰ PROJ = %PROJ% - echo. - pause - exit /b 1 + echo [????] ¦Ä??? app\main.py???? .bat ???????????????? + echo ??? PROJ = %PROJ% + echo. + pause + exit /b 1 ) cd /d "%PROJ%" -echo ÏîĿĿ¼ : %PROJ% -echo ½âÊÍÆ÷ : %PY% -echo ¼àÌýµØַ : http://%HOST%:%PORT% -echo ǰ¶ËÈë¿Ú : http://%HOST%:%PORT%/portal/ -echo ½¡¿µ¼ì²é : http://%HOST%:%PORT%/health +echo ============================================================ +echo ???? Agent ?? ?? ?????? +echo ============================================================ echo. -echo Ìáʾ£ºÇëȷÈÏ MySQL(127.0.0.1:3306) ÒÑÆô¶¯¡£ -echo °´ Ctrl+C ¿Éֹͣ·þÎñ¡£ +echo ????? : %PROJ% +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. -"%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%" echo. echo ============================================================ -echo ·þÎñÒÑÍ˳ö£¨Í˳öÂë %RC%£©¡£ +echo ???????????????? %RC%???? echo ============================================================ pause endlocal