Files
group_fqcd_jr/app/service/recommendation_plan_service.py
T

186 lines
7.8 KiB
Python

"""Generate, review, and publish client-facing advisory recommendation plans."""
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.core.errors import ConflictAgentError, ResourceNotFoundError
from app.core.product_recommendation_contracts import ProductRecommendationQuery
from app.core.recommendation_plan_contracts import (
RecommendationPlanGenerate,
RecommendationPlanReview,
)
from app.infrastructure.db import SessionFactory
from app.model.advisor import ClientFacingContent
from app.model.audit import InteractionAudit
from app.repository.recommendation_plan_repository import CONTENT_TYPE, RecommendationPlanRepository
from app.service.api_transaction_service import ApiTransactionService
from app.service.authorization_service import AuthorizationService
from app.service.product_recommendation_service import ProductRecommendationService
class RecommendationPlanService:
async def generate(
self, payload: RecommendationPlanGenerate, context: RequestContext, key: str | None
) -> dict[str, object]:
await AuthorizationService.require(context, "recommendation-plan:generate:self")
result = await ProductRecommendationService().recommend_for_agent(
ProductRecommendationQuery(limit=payload.limit), context
)
if result.get("status") != "ready":
return {"data": result, "meta": {"trace_id": context.trace_id}}
products = result.get("products")
if not isinstance(products, list):
return {
"data": {"status": "recommendation_result_invalid"},
"meta": {"trace_id": context.trace_id},
}
excluded_candidates = result.get("excluded_candidates")
if not isinstance(excluded_candidates, list):
excluded_candidates = []
selection_summary = result.get("selection_summary")
if not isinstance(selection_summary, dict):
selection_summary = {}
async def operation(session: AsyncSession) -> dict[str, Any]:
now = _now()
customer_id = int(context.user_id)
content = ClientFacingContent(
customer_id=customer_id,
content_type=CONTENT_TYPE,
draft_content={
"document_type": "advisor_recommendation_plan",
"document_version": "1.0",
"generated_at": now.isoformat() + "Z",
"products": products,
"excluded_candidates": excluded_candidates,
"selection_summary": selection_summary,
"graph_degraded": result["graph_degraded"],
"analysis_only": True,
"disclosures": [
"本方案仅供场内基金模拟交易分析参考,不构成收益承诺或交易指令。",
"推荐候选已经过适当性硬过滤,仍须经持证人员审核后才可对客发布。",
],
},
generated_by_portal=context.portal,
review_status="pending_review",
reviewer_user_id=None,
reviewed_at=None,
published_at=None,
created_at=now,
updated_at=now,
)
RecommendationPlanRepository(session).add(content)
await session.flush()
self._audit(
session,
context,
customer_id,
"advisor.recommendation_plan_generated",
{"content_id": content.id, "product_count": len(products)},
)
return {"data": self._view(content), "meta": {"trace_id": context.trace_id}}
return await ApiTransactionService().execute(
context, "advisor:recommendation-plans", key, payload.model_dump(mode="json"), operation
)
async def review(
self, content_id: int, payload: RecommendationPlanReview, context: RequestContext,
key: str | None,
) -> dict[str, object]:
await AuthorizationService.require(context, "recommendation-plan:review", admin=True)
async def operation(session: AsyncSession) -> dict[str, Any]:
plan = await RecommendationPlanRepository(session).plan(content_id, lock=True)
if plan is None:
raise ResourceNotFoundError("recommendation plan not found")
if plan.review_status != "pending_review":
raise ConflictAgentError("RECOMMENDATION_PLAN_NOT_PENDING_REVIEW")
now = _now()
plan.review_status = payload.decision
plan.reviewer_user_id = int(context.user_id)
plan.reviewed_at = now
plan.published_at = now if payload.decision == "approved" else None
plan.updated_at = now
plan.draft_content = {
**plan.draft_content,
"review": {
"decision": payload.decision,
"comment": payload.comment or None,
"reviewed_at": _timestamp(now),
},
}
self._audit(
session,
context,
plan.customer_id,
f"advisor.recommendation_plan_{payload.decision}",
{"content_id": plan.id, "comment": payload.comment or None},
)
return {"data": self._view(plan), "meta": {"trace_id": context.trace_id}}
return await ApiTransactionService().execute(
context, f"advisor:recommendation-plans:{content_id}:review", key,
payload.model_dump(mode="json"), operation,
)
async def pending(self, context: RequestContext, *, limit: int) -> dict[str, object]:
await AuthorizationService.require(context, "recommendation-plan:review", admin=True)
async with SessionFactory() as session:
plans = await RecommendationPlanRepository(session).pending(limit=limit)
return {
"data": [self._view(plan) for plan in plans],
"meta": {"trace_id": context.trace_id},
}
async def published(self, context: RequestContext, *, limit: int) -> dict[str, object]:
await AuthorizationService.require(context, "recommendation-plan:read:self")
async with SessionFactory() as session:
plans = await RecommendationPlanRepository(session).published_for_customer(
int(context.user_id), limit=limit
)
return {
"data": [self._view(plan) for plan in plans],
"meta": {"trace_id": context.trace_id},
}
@staticmethod
def _view(plan: ClientFacingContent) -> dict[str, object]:
return {
"content_id": str(plan.id),
"customer_id": str(plan.customer_id),
"review_status": plan.review_status,
"content": plan.draft_content,
"reviewed_at": _timestamp(plan.reviewed_at),
"published_at": _timestamp(plan.published_at),
"created_at": _timestamp(plan.created_at),
}
@staticmethod
def _audit(
session: AsyncSession, context: RequestContext, customer_id: int, action: 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,
detail={**detail, "trace_id": context.trace_id},
created_at=_now(),
)
)
def _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