feat: migrate advisor investment goals
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""Investment-goal collection and goal-book review endpoints."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, status
|
||||
|
||||
from app.api.dependencies.auth import build_request_context
|
||||
from app.api.dependencies.rate_limit import enforce_rate_limit
|
||||
from app.api.schemas.investment_goals import (
|
||||
InvestmentGoalBookPublish,
|
||||
InvestmentGoalBookReview,
|
||||
InvestmentGoalConfirmation,
|
||||
InvestmentGoalCreate,
|
||||
)
|
||||
from app.core.contracts import RequestContext
|
||||
from app.service.investment_goal_service import InvestmentGoalService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/advisor", tags=["advisor-investment-goals"],
|
||||
dependencies=[Depends(enforce_rate_limit)],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/investment-goals", status_code=status.HTTP_201_CREATED)
|
||||
async def create_investment_goal(
|
||||
payload: InvestmentGoalCreate,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, object]:
|
||||
return await InvestmentGoalService().create(payload, context, key)
|
||||
|
||||
|
||||
@router.get("/investment-goals/current")
|
||||
async def current_own_investment_goal(
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
return await InvestmentGoalService().current(int(context.user_id), context)
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/investment-goals/current")
|
||||
async def current_customer_investment_goal(
|
||||
customer_id: int,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
) -> dict[str, object]:
|
||||
return await InvestmentGoalService().current(customer_id, context)
|
||||
|
||||
|
||||
@router.post("/investment-goals/{goal_no}/confirmations")
|
||||
async def confirm_investment_goal(
|
||||
goal_no: str,
|
||||
payload: InvestmentGoalConfirmation,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, object]:
|
||||
return await InvestmentGoalService().confirm(goal_no, context, key)
|
||||
|
||||
|
||||
@router.get("/investment-goals/{goal_no}/goal-book")
|
||||
async def investment_goal_book(
|
||||
goal_no: str,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
return await InvestmentGoalService().goal_book(goal_no, context)
|
||||
|
||||
|
||||
@router.post("/investment-goals/{goal_no}/goal-book/reviews")
|
||||
async def review_investment_goal_book(
|
||||
goal_no: str,
|
||||
payload: InvestmentGoalBookReview,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, object]:
|
||||
return await InvestmentGoalService().review_book(goal_no, payload, context, key)
|
||||
|
||||
|
||||
@router.post("/investment-goals/{goal_no}/goal-book/publications")
|
||||
async def publish_investment_goal_book(
|
||||
goal_no: str,
|
||||
payload: InvestmentGoalBookPublish,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, object]:
|
||||
return await InvestmentGoalService().publish_book(goal_no, payload, context, key)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""HTTP DTOs for investment-goal collection and goal-book review."""
|
||||
|
||||
from app.core.investment_goal_contracts import (
|
||||
InvestmentGoalBookPublish,
|
||||
InvestmentGoalBookReview,
|
||||
InvestmentGoalConfirmation,
|
||||
InvestmentGoalCreate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"InvestmentGoalBookPublish",
|
||||
"InvestmentGoalBookReview",
|
||||
"InvestmentGoalConfirmation",
|
||||
"InvestmentGoalCreate",
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Contracts for structured investment-goal collection."""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
LiquidityRequirement = Literal[
|
||||
"daily",
|
||||
"within_7_days",
|
||||
"within_30_days",
|
||||
"over_30_days",
|
||||
]
|
||||
|
||||
|
||||
class InvestmentGoalCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
customer_id: int | None = Field(default=None, gt=0)
|
||||
annualized_return_lower_pct: Decimal = Field(ge=0, le=100, max_digits=7, decimal_places=4)
|
||||
annualized_return_upper_pct: Decimal = Field(ge=0, le=100, max_digits=7, decimal_places=4)
|
||||
max_drawdown_pct: Decimal = Field(ge=0, le=100, max_digits=7, decimal_places=4)
|
||||
liquidity_requirement: LiquidityRequirement
|
||||
investment_horizon_months: int = Field(ge=1, le=600)
|
||||
benchmark_name: str = Field(min_length=1, max_length=128)
|
||||
notes: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@field_validator("benchmark_name", "notes")
|
||||
@classmethod
|
||||
def normalize_text(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("text must not be blank")
|
||||
if any(ord(char) < 32 and char not in "\n\t" for char in normalized):
|
||||
raise ValueError("text must not contain control characters")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_return_range(self) -> "InvestmentGoalCreate":
|
||||
if self.annualized_return_lower_pct > self.annualized_return_upper_pct:
|
||||
raise ValueError("annualized return lower bound must not exceed upper bound")
|
||||
return self
|
||||
|
||||
|
||||
class InvestmentGoalConfirmation(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
confirmed: Literal[True]
|
||||
|
||||
|
||||
class InvestmentGoalBookReview(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
decision: Literal["approved", "rejected"]
|
||||
comment: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@field_validator("comment")
|
||||
@classmethod
|
||||
def normalize_comment(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
class InvestmentGoalBookPublish(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
publish: Literal[True]
|
||||
|
||||
|
||||
class InvestmentGoalQuery(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
@@ -5,6 +5,7 @@ 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.conversations import router as conversations_router
|
||||
from app.api.controllers.health import router as health_router
|
||||
from app.api.controllers.investment_goals import router as investment_goals_router
|
||||
from app.api.controllers.knowledge import router as knowledge_router
|
||||
from app.api.controllers.onboarding import router as onboarding_router
|
||||
from app.api.controllers.public_platform import router as public_platform_router
|
||||
@@ -47,6 +48,7 @@ def create_app() -> FastAPI:
|
||||
application.include_router(knowledge_router)
|
||||
application.include_router(health_router)
|
||||
application.include_router(onboarding_router)
|
||||
application.include_router(investment_goals_router)
|
||||
application.include_router(admin_router)
|
||||
return application
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Persistence models for goals and their client-facing goal books."""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, BigInteger, DateTime, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.model.base import Base
|
||||
|
||||
|
||||
class ClientFacingContent(Base):
|
||||
"""Unchanged baseline content-review resource used by goal books."""
|
||||
|
||||
__tablename__ = "client_facing_content"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
content_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
draft_content: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
generated_by_portal: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
review_status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
reviewer_user_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
|
||||
class AdvisorInvestmentGoal(Base):
|
||||
"""Collected goal version; only a confirmed version feeds advisory tools."""
|
||||
|
||||
__tablename__ = "advisor_investment_goal"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
goal_no: Mapped[str] = mapped_column(String(36), unique=True, nullable=False)
|
||||
customer_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||
annualized_return_lower_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False)
|
||||
annualized_return_upper_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False)
|
||||
max_drawdown_pct: Mapped[Decimal] = mapped_column(Numeric(7, 4), nullable=False)
|
||||
liquidity_requirement: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
investment_horizon_months: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
benchmark_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
source: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
goal_book_content_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True)
|
||||
created_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
confirmed_by: Mapped[int | None] = mapped_column(BigInteger)
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Repository for investment goals and reviewed goal-book content."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent
|
||||
|
||||
|
||||
class InvestmentGoalRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
def add_goal(self, goal: AdvisorInvestmentGoal) -> None:
|
||||
self.session.add(goal)
|
||||
|
||||
def add_goal_book(self, content: ClientFacingContent) -> None:
|
||||
self.session.add(content)
|
||||
|
||||
async def goal(self, goal_no: str, *, lock: bool = False) -> AdvisorInvestmentGoal | None:
|
||||
query = select(AdvisorInvestmentGoal).where(AdvisorInvestmentGoal.goal_no == goal_no)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
return cast(AdvisorInvestmentGoal | None, await self.session.scalar(query))
|
||||
|
||||
async def latest_for_customer(self, customer_id: int) -> AdvisorInvestmentGoal | None:
|
||||
return cast(AdvisorInvestmentGoal | None, await self.session.scalar(
|
||||
select(AdvisorInvestmentGoal)
|
||||
.where(
|
||||
AdvisorInvestmentGoal.customer_id == customer_id,
|
||||
AdvisorInvestmentGoal.status != "superseded",
|
||||
)
|
||||
.order_by(AdvisorInvestmentGoal.created_at.desc(), AdvisorInvestmentGoal.id.desc())
|
||||
.limit(1)
|
||||
))
|
||||
|
||||
async def goal_book(self, content_id: int) -> ClientFacingContent | None:
|
||||
return await self.session.get(ClientFacingContent, content_id)
|
||||
|
||||
async def supersede_confirmed(
|
||||
self, customer_id: int, except_goal_no: str, now: datetime
|
||||
) -> None:
|
||||
await self.session.execute(
|
||||
update(AdvisorInvestmentGoal)
|
||||
.where(
|
||||
AdvisorInvestmentGoal.customer_id == customer_id,
|
||||
AdvisorInvestmentGoal.status == "confirmed",
|
||||
AdvisorInvestmentGoal.goal_no != except_goal_no,
|
||||
)
|
||||
.values(status="superseded", updated_at=now)
|
||||
)
|
||||
@@ -20,6 +20,7 @@ TABLES = frozenset(
|
||||
"svc_handover_ticket",
|
||||
"fin_knowledge_meta",
|
||||
"profile_snapshots",
|
||||
"client_facing_content",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import RecoverableAgentError
|
||||
from app.core.fund_contracts import FundQuoteQuery
|
||||
from app.core.investment_goal_contracts import InvestmentGoalQuery
|
||||
from app.infrastructure.fund_quote_cache import FundQuoteCache
|
||||
from app.infrastructure.memory_cache import MemoryCacheAdapter
|
||||
from app.infrastructure.vector_memory import VectorMemoryAdapter
|
||||
@@ -16,6 +17,7 @@ from app.service.agent.implementations.advisor import AdvisorAgent
|
||||
from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent
|
||||
from app.service.fund_quote_service import query_fund_quote_tool
|
||||
from app.service.intent_classifier import IntentClassifier
|
||||
from app.service.investment_goal_service import investment_goal_query_tool
|
||||
from app.service.memory_recall_service import MemoryRecallService
|
||||
from app.service.model_gateway import (
|
||||
DatabaseModelEndpointResolver,
|
||||
@@ -144,6 +146,13 @@ def get_agent_factory() -> AgentFactory:
|
||||
# (12s)与重试预算,多代码查询必然先撞工具超时。
|
||||
timeout_seconds=15,
|
||||
))
|
||||
registry.register(ToolDefinition(
|
||||
name="query_investment_goal",
|
||||
input_model=InvestmentGoalQuery,
|
||||
handler=cast(Any, investment_goal_query_tool),
|
||||
required_permission="investment-goal:read:self",
|
||||
allowed_roles=("customer", "advisor", "operator", "admin"),
|
||||
))
|
||||
model_service = get_model_service()
|
||||
endpoint_resolver = DatabaseModelEndpointResolver()
|
||||
factory = AgentFactory(
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""投顾 Agent 的迁移骨架。
|
||||
"""投顾 Agent 的公共底座实现。"""
|
||||
|
||||
当前阶段只接入新底座已有的场内基金行情只读能力;后续业务模块会按迁移 TODO
|
||||
逐项扩展 definition、工具白名单和处理逻辑。
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
from app.core.contracts import AgentDefinition
|
||||
from app.core.contracts import AgentDefinition, AgentRequest, CoreResult, RequestContext
|
||||
from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent
|
||||
|
||||
|
||||
@@ -16,6 +14,29 @@ class AdvisorAgent(FundQueryDemoAgent):
|
||||
version="0.1.0",
|
||||
allowed_roles=("customer", "advisor", "operator", "admin"),
|
||||
allowed_portals=("api",),
|
||||
allowed_tools=("query_fund_quote",),
|
||||
supported_intents=("fund_quote",),
|
||||
allowed_tools=("query_fund_quote", "query_investment_goal"),
|
||||
supported_intents=("fund_quote", "investment_goal"),
|
||||
)
|
||||
|
||||
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
|
||||
if (
|
||||
self._classified_intent is not None
|
||||
and self._classified_intent.intent == "investment_goal"
|
||||
):
|
||||
output = await self.call_tool(
|
||||
"query_investment_goal", {}, intent="investment_goal", context=context
|
||||
)
|
||||
if not isinstance(output, dict):
|
||||
return CoreResult(text="当前没有已确认的投资目标,暂不能用于配置或产品推荐。")
|
||||
return CoreResult(text=self._describe_goal(output))
|
||||
return await super().handle(request, context)
|
||||
|
||||
@staticmethod
|
||||
def _describe_goal(goal: dict[str, Any]) -> str:
|
||||
return (
|
||||
f"当前投资目标:年化收益目标 {goal['annualized_return_lower_pct']}%-"
|
||||
f"{goal['annualized_return_upper_pct']}%,最大回撤 {goal['max_drawdown_pct']}%,"
|
||||
f"流动性要求 {goal['liquidity_requirement']},投资期限 "
|
||||
f"{goal['investment_horizon_months']} 个月,业绩比较基准 {goal['benchmark_name']}。"
|
||||
"以上为目标采集结果,不构成收益承诺或交易指令。"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""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:
|
||||
await AuthorizationService.require(context, "investment-goal:read:self")
|
||||
async with SessionFactory() as session:
|
||||
repository = InvestmentGoalRepository(session)
|
||||
goal = await repository.latest_for_customer(int(context.user_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)
|
||||
Reference in New Issue
Block a user