186 lines
7.8 KiB
Python
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
|