Files
group_fqcd_jr/app/service/investment_goal_service.py
T
lzf_0626 857c106faf 投顾工作台:三接口支持按客户出方案 + 动作按所选客户分流
后端(向后兼容,customer_id 缺省即原行为):
- 三个请求契约新增可选 customer_id(推荐/资产配置/持仓诊断)
- AuthorizationService 新增 require_customer_scope(权限码 + 数据范围)
- 推荐/资产配置/持仓诊断服务支持指定被分析客户
- InvestmentGoalService 新增 current_for_customer

前端(employee-advisor/dashboard/index.html):
- 删除「本人」虚拟条目,客户列表改为 4 位真实客户
- 动作与自然语言入口均按所选客户带 customer_id 调真实后端
- 新增风评超期熔断闸门(FM-03,流水线停在 ② 画像)
- 统一对话入口从硬编码占位改为自然语言意图路由
2026-09-14 18:22:56 +08:00

385 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Application service for investment-goal collection and goal-book workflow."""
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.core.errors import (
GenericResourceNotFoundError,
InvalidStateError,
ResourceAlreadyExistsError,
ValidationAgentError,
)
from app.core.investment_goal_contracts import (
InvestmentGoalBookPublish,
InvestmentGoalBookReview,
InvestmentGoalCreate,
InvestmentGoalQuery,
)
from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit
from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent
from app.repository.investment_goal_repository import InvestmentGoalRepository
from app.service.api_transaction_service import ApiTransactionService
from app.service.authorization_service import AuthorizationService
_LIQUIDITY_LABELS = {
"daily": "可随时使用",
"within_7_days": "7 日内可使用",
"within_30_days": "30 日内可使用",
"over_30_days": "30 日后可使用",
}
_PROHIBITED_GOAL_PHRASES = ("保本", "保证收益", "稳赚", "无风险", "收益承诺")
class InvestmentGoalService:
async def create(
self, payload: InvestmentGoalCreate, context: RequestContext, key: str | None
) -> dict[str, object]:
customer_id = await self._resolve_customer(payload.customer_id, context, "write")
self._validate_notes(payload.notes)
async def operation(session: AsyncSession) -> dict[str, Any]:
now = _utc_now()
goal_no = f"IG-{uuid4().hex[:24]}"
repository = InvestmentGoalRepository(session)
content = ClientFacingContent(
customer_id=customer_id,
content_type="investment_goal_book",
draft_content=self._goal_book(goal_no, payload),
generated_by_portal=context.portal,
review_status="pending",
reviewer_user_id=None,
reviewed_at=None,
published_at=None,
created_at=now,
updated_at=now,
)
repository.add_goal_book(content)
await session.flush()
goal = AdvisorInvestmentGoal(
goal_no=goal_no,
customer_id=customer_id,
status="pending_confirmation",
annualized_return_lower_pct=payload.annualized_return_lower_pct,
annualized_return_upper_pct=payload.annualized_return_upper_pct,
max_drawdown_pct=payload.max_drawdown_pct,
liquidity_requirement=payload.liquidity_requirement,
investment_horizon_months=payload.investment_horizon_months,
benchmark_name=payload.benchmark_name,
notes=payload.notes,
source="customer" if customer_id == int(context.user_id) else "advisor",
goal_book_content_id=content.id,
created_by=int(context.user_id),
confirmed_by=None,
confirmed_at=None,
created_at=now,
updated_at=now,
)
repository.add_goal(goal)
self._audit(session, context, customer_id, "advisor.investment_goal_collected", {
"goal_no": goal_no,
"status": goal.status,
"goal_book_content_id": content.id,
})
return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}}
try:
return await ApiTransactionService().execute(
context,
f"advisor:investment-goals:{customer_id}",
key,
payload.model_dump(mode="json"),
operation,
)
except IntegrityError as exc:
raise ResourceAlreadyExistsError("投资目标创建冲突") from exc
async def confirm(
self, goal_no: str, context: RequestContext, key: str | None
) -> dict[str, object]:
async def operation(session: AsyncSession) -> dict[str, Any]:
repository = InvestmentGoalRepository(session)
goal = await repository.goal(goal_no, lock=True)
if goal is None:
raise GenericResourceNotFoundError("投资目标不存在")
await self._assert_customer_access(goal.customer_id, context, "confirm")
if goal.status != "pending_confirmation":
raise InvalidStateError("当前投资目标不能确认")
now = _utc_now()
goal.status = "confirmed"
goal.confirmed_by = int(context.user_id)
goal.confirmed_at = now
goal.updated_at = now
await repository.supersede_confirmed(goal.customer_id, goal.goal_no, now)
content = await repository.goal_book(goal.goal_book_content_id)
if content is None:
raise GenericResourceNotFoundError("投资目标书草稿不存在")
self._audit(session, context, goal.customer_id, "advisor.investment_goal_confirmed", {
"goal_no": goal.goal_no,
"goal_book_content_id": goal.goal_book_content_id,
"review_status": content.review_status,
})
return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}}
return await ApiTransactionService().execute(
context,
f"advisor:investment-goals:{goal_no}:confirmation",
key,
{"goal_no": goal_no, "confirmed": True},
operation,
)
async def current(self, customer_id: int, context: RequestContext) -> dict[str, object]:
await self._assert_customer_access(customer_id, context, "read")
async with SessionFactory() as session:
repository = InvestmentGoalRepository(session)
goal = await repository.latest_for_customer(customer_id)
if goal is None:
raise GenericResourceNotFoundError("当前投资目标不存在")
content = await repository.goal_book(goal.goal_book_content_id)
if content is None:
raise GenericResourceNotFoundError("投资目标书草稿不存在")
return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}}
async def goal_book(self, goal_no: str, context: RequestContext) -> dict[str, object]:
async with SessionFactory() as session:
repository = InvestmentGoalRepository(session)
goal = await repository.goal(goal_no)
if goal is None:
raise GenericResourceNotFoundError("投资目标不存在")
await self._assert_customer_access(goal.customer_id, context, "read")
content = await repository.goal_book(goal.goal_book_content_id)
if content is None:
raise GenericResourceNotFoundError("投资目标书草稿不存在")
return {
"data": {
"goal_no": goal.goal_no,
"goal_status": goal.status,
"review_status": content.review_status,
"content": content.draft_content,
"published_at": _timestamp(content.published_at),
},
"meta": {"trace_id": context.trace_id},
}
async def review_book(
self, goal_no: str, payload: InvestmentGoalBookReview, context: RequestContext,
key: str | None,
) -> dict[str, object]:
await AuthorizationService.require(context, "investment-goal:review", admin=True)
async def operation(session: AsyncSession) -> dict[str, Any]:
repository = InvestmentGoalRepository(session)
goal = await repository.goal(goal_no, lock=True)
if goal is None:
raise GenericResourceNotFoundError("投资目标不存在")
content = await repository.goal_book(goal.goal_book_content_id)
if content is None:
raise GenericResourceNotFoundError("投资目标书草稿不存在")
if content.review_status not in {"pending", "approved"}:
raise InvalidStateError("目标书当前不能审核")
now = _utc_now()
if payload.decision == "approved":
content.review_status = "approved"
content.reviewer_user_id = int(context.user_id)
content.reviewed_at = now
else:
content.review_status = "pending"
content.reviewer_user_id = None
content.reviewed_at = None
content.updated_at = now
self._audit(
session, context, goal.customer_id, "advisor.investment_goal_book_reviewed",
{
"goal_no": goal.goal_no,
"content_id": content.id,
"decision": payload.decision,
"comment": payload.comment,
},
)
return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}}
return await ApiTransactionService().execute(
context, f"advisor:investment-goals:{goal_no}:book-review", key,
payload.model_dump(mode="json"), operation,
)
async def publish_book(
self, goal_no: str, payload: InvestmentGoalBookPublish, context: RequestContext,
key: str | None,
) -> dict[str, object]:
await AuthorizationService.require(context, "investment-goal:publish", admin=True)
async def operation(session: AsyncSession) -> dict[str, Any]:
repository = InvestmentGoalRepository(session)
goal = await repository.goal(goal_no, lock=True)
if goal is None:
raise GenericResourceNotFoundError("投资目标不存在")
content = await repository.goal_book(goal.goal_book_content_id)
if content is None:
raise GenericResourceNotFoundError("投资目标书不存在")
if content.review_status != "approved":
raise InvalidStateError("目标书审核通过后才能发布")
now = _utc_now()
content.review_status = "published"
content.published_at = now
content.updated_at = now
self._audit(
session, context, goal.customer_id, "advisor.investment_goal_book_published",
{"goal_no": goal.goal_no, "content_id": content.id},
)
return {"data": self._view(goal, content), "meta": {"trace_id": context.trace_id}}
return await ApiTransactionService().execute(
context, f"advisor:investment-goals:{goal_no}:book-publish", key,
payload.model_dump(mode="json"), operation,
)
async def current_for_agent(self, context: RequestContext) -> dict[str, object] | None:
"""登录用户自身的已确认投资目标(原行为,等价于 `current_for_customer(自身)`)。"""
return await self.current_for_customer(int(context.user_id), context)
async def current_for_customer(
self, customer_id: int, context: RequestContext
) -> dict[str, object] | None:
"""按指定客户取「已确认」投资目标;无则返回 None(供代客推荐/配置复用)。
权限与数据范围走 `_assert_customer_access`:客户=自身 → `investment-goal:read:self`;
否则 → `investment-goal:read:customer` + 范围(all / own_customers 命中)。
"""
await self._assert_customer_access(customer_id, context, "read")
async with SessionFactory() as session:
repository = InvestmentGoalRepository(session)
goal = await repository.latest_for_customer(customer_id)
if goal is None or goal.status != "confirmed":
return None
content = await repository.goal_book(goal.goal_book_content_id)
return {
"goal_no": goal.goal_no,
"status": goal.status,
"annualized_return_lower_pct": str(goal.annualized_return_lower_pct),
"annualized_return_upper_pct": str(goal.annualized_return_upper_pct),
"max_drawdown_pct": str(goal.max_drawdown_pct),
"liquidity_requirement": goal.liquidity_requirement,
"investment_horizon_months": goal.investment_horizon_months,
"benchmark_name": goal.benchmark_name,
"goal_book_review_status": content.review_status if content else None,
}
async def _resolve_customer(
self, requested_customer_id: int | None, context: RequestContext, action: str
) -> int:
customer_id = requested_customer_id or int(context.user_id)
await self._assert_customer_access(customer_id, context, action)
return customer_id
async def _assert_customer_access(
self, customer_id: int, context: RequestContext, action: str
) -> None:
own = customer_id == int(context.user_id)
permission = (
f"investment-goal:{action}:self"
if own
else f"investment-goal:{action}:customer"
)
await AuthorizationService.require(context, permission)
if own:
return
scope = context.permission_scopes.get(permission, "self")
if scope != "all" and (
scope != "own_customers" or str(customer_id) not in context.customer_ids
):
raise GenericResourceNotFoundError("客户不可访问")
@staticmethod
def _validate_notes(notes: str | None) -> None:
if notes and any(phrase in notes for phrase in _PROHIBITED_GOAL_PHRASES):
raise ValidationAgentError("投资目标说明不得包含收益承诺或保本表述")
@staticmethod
def _goal_book(goal_no: str, payload: InvestmentGoalCreate) -> dict[str, object]:
return {
"document_type": "investment_goal_book",
"document_version": "1.0",
"goal_no": goal_no,
"sections": {
"investment_objective": {
"annualized_return_expectation_pct": {
"lower": str(payload.annualized_return_lower_pct),
"upper": str(payload.annualized_return_upper_pct),
},
"benchmark_name": payload.benchmark_name,
},
"risk_boundary": {"maximum_drawdown_pct": str(payload.max_drawdown_pct)},
"liquidity": {
"requirement": payload.liquidity_requirement,
"description": _LIQUIDITY_LABELS[payload.liquidity_requirement],
},
"investment_horizon": {"months": payload.investment_horizon_months},
"notes": payload.notes,
},
"disclosures": [
"收益目标为客户期望与业绩比较基准口径,不构成收益承诺或保证。",
"基金投资有风险,过往业绩不预示未来表现。",
"本目标书为待审核草稿,仅作为后续场内基金模拟交易分析的输入,不构成交易指令。",
],
}
@classmethod
def _view(cls, goal: AdvisorInvestmentGoal, content: ClientFacingContent) -> dict[str, object]:
return {
"goal_no": goal.goal_no,
"customer_id": str(goal.customer_id),
"status": goal.status,
"goal_gap_status": (
"awaiting_confirmation" if goal.status == "pending_confirmation" else "none"
),
"annualized_return_lower_pct": str(goal.annualized_return_lower_pct),
"annualized_return_upper_pct": str(goal.annualized_return_upper_pct),
"max_drawdown_pct": str(goal.max_drawdown_pct),
"liquidity_requirement": goal.liquidity_requirement,
"investment_horizon_months": goal.investment_horizon_months,
"benchmark_name": goal.benchmark_name,
"notes": goal.notes,
"source": goal.source,
"goal_book": {
"content_id": str(content.id),
"review_status": content.review_status,
"published_at": _timestamp(content.published_at),
},
"confirmed_at": _timestamp(goal.confirmed_at),
"created_at": _timestamp(goal.created_at),
"updated_at": _timestamp(goal.updated_at),
}
@staticmethod
def _audit(
session: AsyncSession, context: RequestContext, customer_id: int,
action_type: str, detail: dict[str, object],
) -> None:
session.add(InteractionAudit(
actor_type="user", actor_id=int(context.user_id), target_customer_id=customer_id,
portal=context.portal, action_type=action_type,
detail={**detail, "trace_id": context.trace_id}, created_at=_utc_now(),
))
def _utc_now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def _timestamp(value: datetime | None) -> str | None:
return value.isoformat() + "Z" if value is not None else None
async def investment_goal_query_tool(
_arguments: InvestmentGoalQuery, context: RequestContext
) -> dict[str, object] | None:
"""Read-only Agent entry point; only confirmed goals are exposed to the Agent."""
return await InvestmentGoalService().current_for_agent(context)