Files
group_fqcd_jr/app/service/investment_goal_service.py
T

374 lines
17 KiB
Python

"""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)