feat: migrate advisor investment goals

This commit is contained in:
Windows
2026-09-11 13:11:58 +08:00
parent acb9175e31
commit 60c4a49b9b
14 changed files with 903 additions and 27 deletions
@@ -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]