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