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)
|
||||
+30
-18
@@ -62,7 +62,19 @@ R4=7、R5=1;资产分类成功 18 个,1 个因合同证据不足跳过。每
|
||||
通过,MyPy(116 个源文件)通过。数据库审计工具已执行,但默认 `.env` 连接的是旧
|
||||
`jr_agent` 库,结构审计发现旧库的场外表和基线约束差异;阶段四已验证独立库
|
||||
`jr_agent_qyqy_migration` 的迁移结构,不能将旧库结果作为新底座验收结果。
|
||||
阶段六提交:待提交。
|
||||
阶段六提交:`acb9175`。
|
||||
|
||||
### 阶段七:投资目标和目标书
|
||||
|
||||
已完成投资目标结构化采集、目标书草稿生成、客户确认、投顾审核和发布闭环。目标
|
||||
创建后状态为 `pending_confirmation`,目标书使用基线 `client_facing_content` 的
|
||||
`pending -> approved -> published` 审核状态;未经审核不能发布。目标查询工具只向
|
||||
Agent 暴露客户最新的 `confirmed` 目标,未确认目标不会进入后续推荐和配置输入。
|
||||
目标收益范围、最大回撤、流动性要求、期限、业绩比较基准和备注均有服务端校验,
|
||||
目标书含收益非承诺、非交易指令披露;写操作统一使用 `Idempotency-Key` 并写入审计。
|
||||
|
||||
阶段七测试结果:专项测试 `5 passed`,全量单元测试 `468 passed, 3 warnings`,Ruff
|
||||
通过,MyPy(122 个源文件)通过。阶段七提交:待提交。
|
||||
|
||||
## 一、迁移准备
|
||||
|
||||
@@ -220,23 +232,23 @@ python tools/audit_constraints.py
|
||||
|
||||
## 七、投资目标和目标书
|
||||
|
||||
- [ ] 迁移投资目标创建接口。
|
||||
- [ ] 迁移收益目标下限和上限。
|
||||
- [ ] 迁移最大回撤字段。
|
||||
- [ ] 迁移流动性要求字段。
|
||||
- [ ] 迁移投资期限字段。
|
||||
- [ ] 迁移业绩比较基准字段。
|
||||
- [ ] 迁移目标备注字段。
|
||||
- [ ] 迁移目标书生成。
|
||||
- [ ] 迁移客户确认流程。
|
||||
- [ ] 迁移投顾审核流程。
|
||||
- [ ] 迁移当前目标查询工具。
|
||||
- [ ] 迁移目标缺口状态。
|
||||
- [ ] 确认目标创建后为 `pending_confirmation`。
|
||||
- [ ] 确认未确认目标不能用于推荐和配置。
|
||||
- [ ] 确认目标书未审核不能对客发布。
|
||||
- [ ] 确认收益目标不被表述为收益承诺。
|
||||
- [ ] 完成投资目标提交 `advisor/investment-goal`。
|
||||
- [x] 迁移投资目标创建接口。
|
||||
- [x] 迁移收益目标下限和上限。
|
||||
- [x] 迁移最大回撤字段。
|
||||
- [x] 迁移流动性要求字段。
|
||||
- [x] 迁移投资期限字段。
|
||||
- [x] 迁移业绩比较基准字段。
|
||||
- [x] 迁移目标备注字段。
|
||||
- [x] 迁移目标书生成。
|
||||
- [x] 迁移客户确认流程。
|
||||
- [x] 迁移投顾审核流程。
|
||||
- [x] 迁移当前目标查询工具。
|
||||
- [x] 迁移目标缺口状态。
|
||||
- [x] 确认目标创建后为 `pending_confirmation`。
|
||||
- [x] 确认未确认目标不能用于推荐和配置。
|
||||
- [x] 确认目标书未审核不能对客发布。
|
||||
- [x] 确认收益目标不被表述为收益承诺。
|
||||
- [x] 完成投资目标提交 `advisor/investment-goal`。(专项 `5 passed`;全量单元 `468 passed`)
|
||||
|
||||
## 八、持仓分析
|
||||
|
||||
|
||||
@@ -18,5 +18,5 @@ def test_advisor_is_registered_through_the_new_base_factory() -> None:
|
||||
assert isinstance(agent, BaseAgent)
|
||||
assert isinstance(agent, AdvisorAgent)
|
||||
assert agent.definition == definition
|
||||
assert definition.allowed_tools == ("query_fund_quote",)
|
||||
assert definition.supported_intents == ("fund_quote",)
|
||||
assert definition.allowed_tools == ("query_fund_quote", "query_investment_goal")
|
||||
assert definition.supported_intents == ("fund_quote", "investment_goal")
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.contracts import RequestContext
|
||||
from app.core.errors import InvalidStateError, ValidationAgentError
|
||||
from app.core.investment_goal_contracts import (
|
||||
InvestmentGoalBookPublish,
|
||||
InvestmentGoalBookReview,
|
||||
InvestmentGoalCreate,
|
||||
)
|
||||
from app.model.investment_goal import AdvisorInvestmentGoal, ClientFacingContent
|
||||
from app.service.api_transaction_service import ApiTransactionService
|
||||
from app.service.investment_goal_service import InvestmentGoalService
|
||||
|
||||
|
||||
def context(*permissions: str, roles: tuple[str, ...] = ("customer",)) -> RequestContext:
|
||||
return RequestContext(user_id="7", trace_id="goal-test", roles=roles, permissions=permissions)
|
||||
|
||||
|
||||
def payload(**overrides: object) -> InvestmentGoalCreate:
|
||||
values: dict[str, object] = {
|
||||
"annualized_return_lower_pct": "4.0",
|
||||
"annualized_return_upper_pct": "6.0",
|
||||
"max_drawdown_pct": "8.0",
|
||||
"liquidity_requirement": "within_30_days",
|
||||
"investment_horizon_months": 36,
|
||||
"benchmark_name": "中证全指收益率",
|
||||
}
|
||||
values.update(overrides)
|
||||
return InvestmentGoalCreate.model_validate(values)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, goal: AdvisorInvestmentGoal | None = None) -> None:
|
||||
self.goal = goal
|
||||
self.content: ClientFacingContent | None = None
|
||||
self.items: list[object] = []
|
||||
self.executed: list[object] = []
|
||||
|
||||
def add(self, item: object) -> None:
|
||||
self.items.append(item)
|
||||
if isinstance(item, ClientFacingContent):
|
||||
self.content = item
|
||||
|
||||
async def flush(self) -> None:
|
||||
assert self.content is not None
|
||||
self.content.id = 101
|
||||
|
||||
async def scalar(self, _statement: object) -> AdvisorInvestmentGoal | None:
|
||||
return self.goal
|
||||
|
||||
async def get(self, _model: object, _identity: int) -> ClientFacingContent | None:
|
||||
return self.content
|
||||
|
||||
async def execute(self, statement: object) -> None:
|
||||
self.executed.append(statement)
|
||||
|
||||
|
||||
async def run_operation(
|
||||
monkeypatch: pytest.MonkeyPatch, session: FakeSession, method: str, *args: object
|
||||
) -> dict[str, Any]:
|
||||
async def execute(
|
||||
_self: ApiTransactionService,
|
||||
_context: RequestContext,
|
||||
_scope: str,
|
||||
_key: str | None,
|
||||
_body: Any,
|
||||
operation: Any,
|
||||
) -> dict[str, Any]:
|
||||
return cast(dict[str, Any], await operation(session))
|
||||
|
||||
monkeypatch.setattr(ApiTransactionService, "execute", execute)
|
||||
result = await getattr(InvestmentGoalService(), method)(*args)
|
||||
return cast(dict[str, Any], result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_writes_pending_goal_and_reviewable_book(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = FakeSession()
|
||||
result = await run_operation(
|
||||
monkeypatch, session, "create", payload(), context("investment-goal:write:self"),
|
||||
"goal-create-key-0001",
|
||||
)
|
||||
goal = next(item for item in session.items if isinstance(item, AdvisorInvestmentGoal))
|
||||
assert goal.status == "pending_confirmation"
|
||||
assert session.content is not None
|
||||
assert session.content.review_status == "pending"
|
||||
assert cast(dict[str, Any], result["data"])["goal_gap_status"] == "awaiting_confirmation"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirmation_activates_goal_but_does_not_publish_book(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
created_at = datetime(2026, 9, 10)
|
||||
goal = AdvisorInvestmentGoal(
|
||||
id=1, goal_no="IG-confirmation-test", customer_id=7, status="pending_confirmation",
|
||||
annualized_return_lower_pct=Decimal("4"), annualized_return_upper_pct=Decimal("6"),
|
||||
max_drawdown_pct=Decimal("8"), liquidity_requirement="daily", investment_horizon_months=24,
|
||||
benchmark_name="中证全指收益率", notes=None, source="customer", goal_book_content_id=101,
|
||||
created_by=7, confirmed_by=None, confirmed_at=None,
|
||||
created_at=created_at, updated_at=created_at,
|
||||
)
|
||||
session = FakeSession(goal)
|
||||
session.content = ClientFacingContent(
|
||||
id=101, customer_id=7, content_type="investment_goal_book", draft_content={},
|
||||
generated_by_portal="api", review_status="pending", reviewer_user_id=None,
|
||||
reviewed_at=None, published_at=None, created_at=created_at, updated_at=created_at,
|
||||
)
|
||||
result = await run_operation(
|
||||
monkeypatch, session, "confirm", goal.goal_no,
|
||||
context("investment-goal:confirm:self"), "goal-confirm-key-0001",
|
||||
)
|
||||
assert goal.status == "confirmed"
|
||||
assert session.content.published_at is None
|
||||
assert cast(dict[str, Any], result["data"])["goal_book"]["review_status"] == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_then_publish_requires_approved_book(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
created_at = datetime(2026, 9, 10)
|
||||
goal = AdvisorInvestmentGoal(
|
||||
id=1, goal_no="IG-review-test", customer_id=7, status="confirmed",
|
||||
annualized_return_lower_pct=Decimal("4"), annualized_return_upper_pct=Decimal("6"),
|
||||
max_drawdown_pct=Decimal("8"), liquidity_requirement="daily", investment_horizon_months=24,
|
||||
benchmark_name="中证全指收益率", notes=None, source="customer", goal_book_content_id=101,
|
||||
created_by=7, confirmed_by=7, confirmed_at=created_at,
|
||||
created_at=created_at, updated_at=created_at,
|
||||
)
|
||||
session = FakeSession(goal)
|
||||
session.content = ClientFacingContent(
|
||||
id=101, customer_id=7, content_type="investment_goal_book", draft_content={},
|
||||
generated_by_portal="api", review_status="pending", reviewer_user_id=None,
|
||||
reviewed_at=None, published_at=None, created_at=created_at, updated_at=created_at,
|
||||
)
|
||||
|
||||
async def allow(*_args: object, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("app.service.investment_goal_service.AuthorizationService.require", allow)
|
||||
with pytest.raises(InvalidStateError, match="审核通过"):
|
||||
await run_operation(
|
||||
monkeypatch, session, "publish_book", goal.goal_no,
|
||||
InvestmentGoalBookPublish(publish=True),
|
||||
context("investment-goal:publish", roles=("admin",)), "goal-publish-key-0000",
|
||||
)
|
||||
await run_operation(
|
||||
monkeypatch, session, "review_book", goal.goal_no,
|
||||
InvestmentGoalBookReview(decision="approved"),
|
||||
context("investment-goal:review", roles=("admin",)), "goal-review-key-0001",
|
||||
)
|
||||
assert session.content.review_status == "approved"
|
||||
await run_operation(
|
||||
monkeypatch, session, "publish_book", goal.goal_no,
|
||||
InvestmentGoalBookPublish(publish=True),
|
||||
context("investment-goal:publish", roles=("admin",)), "goal-publish-key-0001",
|
||||
)
|
||||
assert session.content.review_status == "published"
|
||||
assert session.content.published_at is not None
|
||||
|
||||
|
||||
def test_goal_constraints_and_disclosures_are_validated() -> None:
|
||||
with pytest.raises(ValidationError, match="lower bound"):
|
||||
payload(annualized_return_lower_pct="8", annualized_return_upper_pct="6")
|
||||
with pytest.raises(ValidationError):
|
||||
payload(liquidity_requirement="tomorrow")
|
||||
with pytest.raises(ValidationAgentError, match="收益承诺"):
|
||||
InvestmentGoalService._validate_notes("希望保证收益")
|
||||
book = InvestmentGoalService._goal_book("IG-1", payload())
|
||||
assert "不构成收益承诺" in cast(list[str], book["disclosures"])[0]
|
||||
@@ -30,6 +30,7 @@ from app.model import ( # noqa: E402,F401
|
||||
configuration,
|
||||
conversation,
|
||||
fund,
|
||||
investment_goal,
|
||||
memory,
|
||||
platform,
|
||||
session,
|
||||
|
||||
Reference in New Issue
Block a user