304 lines
13 KiB
Python
304 lines
13 KiB
Python
"""客户主动申报投顾方案 → 投顾受理审批。
|
||||
|
|
|
|||
|
|
## 流程
|
|||
|
|
|
|||
|
|
客户「我的投顾方案」页提交申报(金额/期限/风险偏好/备注)
|
|||
|
|
→ 落 `advisor_service_request`(status=pending)
|
|||
|
|
→ 投顾工作台「客户申报」看到待办
|
|||
|
|
→ 投顾受理:**自动跑一次推荐**,生成一份 pending_review 的方案草稿,
|
|||
|
|
并把草稿 id 记进 `result_content_id`(status=accepted)
|
|||
|
|
→ 投顾随后走既有的「审核通过 → 发送给客户」
|
|||
|
|
→ 客户在「我的投顾方案」看到方案(status 呈现为 delivered)
|
|||
|
|
|
|||
|
|
## 两条前置/边界
|
|||
|
|
|
|||
|
|
1. **申报前置**:必须已完成风险测评且未失效(FM-03:12 个月)。服务端判,
|
|||
|
|
不依赖前端闸门 —— 前端只是体验层。
|
|||
|
|
2. **归属**:投顾只能处理**名下客户**的申报,复用
|
|||
|
|
`ProductRecommendationService._visible_customer_ids`(本人 + `sys_customer_assignment`),
|
|||
|
|
与已发布方案/历史记录同一把尺子。
|
|||
|
|
|
|||
|
|
## 状态不重复存
|
|||
|
|
|
|||
|
|
「已发送给客户」不单独存状态,而是由 `result_content_id` 指向的方案是否已发布
|
|||
|
|
**推导**(见 `_view`)—— 两处各存一份必然对不上。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
import uuid
|
|||
|
|
from datetime import UTC, datetime, timedelta
|
|||
|
|
from decimal import Decimal
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|||
|
|
from sqlalchemy import select
|
|||
|
|
|
|||
|
|
from app.core.contracts import RequestContext
|
|||
|
|
from app.core.errors import (
|
|||
|
|
ForbiddenAgentError,
|
|||
|
|
GenericResourceNotFoundError,
|
|||
|
|
InvalidStateError,
|
|||
|
|
ValidationAgentError,
|
|||
|
|
)
|
|||
|
|
from app.core.product_recommendation_contracts import ProductRecommendationQuery
|
|||
|
|
from app.infrastructure.db import SessionFactory
|
|||
|
|
from app.model.advisor_service_request import (
|
|||
|
|
ACCEPTED,
|
|||
|
|
DELIVERED,
|
|||
|
|
PENDING,
|
|||
|
|
REJECTED,
|
|||
|
|
AdvisorServiceRequest,
|
|||
|
|
)
|
|||
|
|
from app.model.investment_goal import ClientFacingContent
|
|||
|
|
from app.service.api_transaction_service import ApiTransactionService
|
|||
|
|
from app.service.authorization_service import AuthorizationService
|
|||
|
|
from app.service.product_recommendation_service import ProductRecommendationService
|
|||
|
|
from app.service.suitability_service import SuitabilityService
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
#: 与投顾工作台左栏表单的选项一致;服务端仍要校验一次(前端只是体验层)。
|
|||
|
|
HORIZONS: tuple[str, ...] = ("<1年", "1-3年", "3-5年", ">5年")
|
|||
|
|
RISK_PREFERENCES: tuple[str, ...] = ("稳健", "平衡", "进取")
|
|||
|
|
|
|||
|
|
#: FM-03:风险测评 12 个月失效。
|
|||
|
|
ASSESSMENT_VALID_DAYS = 365
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _naive_utc(value: datetime) -> datetime:
|
|||
|
|
"""归一成 **UTC naive** 再比较。
|
|||
|
|
|
|||
|
|
适配器返回的时间可能带 tz(`valid_until`),而 `now` 是 naive ——
|
|||
|
|
直接比较会 `TypeError: can't compare offset-naive and offset-aware datetimes`。
|
|||
|
|
"""
|
|||
|
|
if value.tzinfo is None:
|
|||
|
|
return value
|
|||
|
|
return value.astimezone(UTC).replace(tzinfo=None)
|
|||
|
|
|
|||
|
|
#: 受理时推荐逻辑的前置失败 → 给投顾看得懂的话(`generate` 的 early return 状态码)。
|
|||
|
|
GENERATE_FAILURES: dict[str, str] = {
|
|||
|
|
"profile_required": "客户尚未完成风险测评,无法生成方案",
|
|||
|
|
"investment_goal_required": "客户暂无已确认投资目标,请先录入并确认目标",
|
|||
|
|
"recommendation_input_invalid": "客户投资目标数据不完整,无法生成方案",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AdvisorServiceRequestCreate(BaseModel):
|
|||
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|||
|
|
|
|||
|
|
amount_wan: float = Field(gt=0, le=1_000_000, description="拟投资金额(万元)")
|
|||
|
|
horizon: str = Field(description="投资期限")
|
|||
|
|
risk_preference: str = Field(description="风险偏好")
|
|||
|
|
note: str | None = Field(default=None, max_length=500, description="补充说明")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AdvisorServiceRequestService:
|
|||
|
|
# ---- 客户侧 ----
|
|||
|
|
|
|||
|
|
async def create(
|
|||
|
|
self, payload: AdvisorServiceRequestCreate, context: RequestContext, key: str | None
|
|||
|
|
) -> dict[str, object]:
|
|||
|
|
await AuthorizationService.require(context, "advisor-request:write:self")
|
|||
|
|
if payload.horizon not in HORIZONS:
|
|||
|
|
raise ValidationAgentError(f"投资期限只能是 {list(HORIZONS)} 之一")
|
|||
|
|
if payload.risk_preference not in RISK_PREFERENCES:
|
|||
|
|
raise ValidationAgentError(f"风险偏好只能是 {list(RISK_PREFERENCES)} 之一")
|
|||
|
|
customer_id = int(context.user_id)
|
|||
|
|
await self._require_valid_assessment(customer_id)
|
|||
|
|
|
|||
|
|
async def operation(session: Any) -> dict[str, object]:
|
|||
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
|||
|
|
# 同一客户同一秒可能连点两次:后缀加短随机码,避免撞 `request_no` 唯一键。
|
|||
|
|
request_no = f"ASR-{customer_id}-{now:%y%m%d%H%M%S}-{uuid.uuid4().hex[:4].upper()}"
|
|||
|
|
row = AdvisorServiceRequest(
|
|||
|
|
request_no=request_no,
|
|||
|
|
customer_id=customer_id,
|
|||
|
|
amount_wan=Decimal(str(payload.amount_wan)),
|
|||
|
|
horizon=payload.horizon,
|
|||
|
|
risk_preference=payload.risk_preference,
|
|||
|
|
note=payload.note,
|
|||
|
|
status=PENDING,
|
|||
|
|
created_at=now,
|
|||
|
|
updated_at=now,
|
|||
|
|
)
|
|||
|
|
session.add(row)
|
|||
|
|
await session.flush()
|
|||
|
|
return {
|
|||
|
|
"data": {"request_no": request_no, "status": PENDING},
|
|||
|
|
"meta": {"trace_id": context.trace_id},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return await ApiTransactionService().execute(
|
|||
|
|
context,
|
|||
|
|
f"advisor-request:create:{customer_id}",
|
|||
|
|
key,
|
|||
|
|
payload.model_dump(mode="json"),
|
|||
|
|
operation,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async def list_mine(self, context: RequestContext) -> dict[str, object]:
|
|||
|
|
await AuthorizationService.require(context, "advisor-request:read:self")
|
|||
|
|
rows = await self._fetch(customer_ids=[int(context.user_id)])
|
|||
|
|
rows.sort(key=lambda row: row.created_at, reverse=True)
|
|||
|
|
return {"data": await self._views(rows), "meta": {"trace_id": context.trace_id}}
|
|||
|
|
|
|||
|
|
# ---- 投顾侧 ----
|
|||
|
|
|
|||
|
|
async def queue(self, context: RequestContext) -> dict[str, object]:
|
|||
|
|
await AuthorizationService.require(context, "advisor-request:read")
|
|||
|
|
customer_ids = list(ProductRecommendationService._visible_customer_ids(context))
|
|||
|
|
rows = await self._fetch(customer_ids=customer_ids)
|
|||
|
|
# 待受理排前面,其余按时间倒序 —— 投顾打开面板先看到"要动手的"。
|
|||
|
|
rows.sort(key=lambda row: (row.status != PENDING, -row.created_at.timestamp()))
|
|||
|
|
return {"data": await self._views(rows), "meta": {"trace_id": context.trace_id}}
|
|||
|
|
|
|||
|
|
async def review(
|
|||
|
|
self,
|
|||
|
|
request_no: str,
|
|||
|
|
decision: str,
|
|||
|
|
comment: str,
|
|||
|
|
context: RequestContext,
|
|||
|
|
key: str | None,
|
|||
|
|
) -> dict[str, object]:
|
|||
|
|
await AuthorizationService.require(context, "advisor-request:review")
|
|||
|
|
if decision not in {"accepted", "rejected"}:
|
|||
|
|
raise ValidationAgentError("decision 必须为 accepted 或 rejected")
|
|||
|
|
|
|||
|
|
async with SessionFactory() as session:
|
|||
|
|
existing = await session.scalar(
|
|||
|
|
select(AdvisorServiceRequest).where(
|
|||
|
|
AdvisorServiceRequest.request_no == request_no
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
if existing is None:
|
|||
|
|
raise GenericResourceNotFoundError("申报单不存在")
|
|||
|
|
visible = set(ProductRecommendationService._visible_customer_ids(context))
|
|||
|
|
if existing.customer_id not in visible:
|
|||
|
|
raise ForbiddenAgentError("无权处理该客户的申报")
|
|||
|
|
if existing.status != PENDING:
|
|||
|
|
raise InvalidStateError("该申报单已被处理")
|
|||
|
|
|
|||
|
|
content_id: int | None = None
|
|||
|
|
if decision == "accepted":
|
|||
|
|
content_id = await self._generate_draft_plan(request_no, existing.customer_id, context)
|
|||
|
|
|
|||
|
|
async def operation(session: Any) -> dict[str, object]:
|
|||
|
|
row = await session.get(AdvisorServiceRequest, existing.id, with_for_update=True)
|
|||
|
|
if row is None or row.status != PENDING:
|
|||
|
|
raise InvalidStateError("该申报单已被处理")
|
|||
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
|||
|
|
row.status = ACCEPTED if decision == "accepted" else REJECTED
|
|||
|
|
row.handled_by = int(context.user_id)
|
|||
|
|
row.handled_at = now
|
|||
|
|
row.advisor_note = comment or None
|
|||
|
|
row.result_content_id = content_id
|
|||
|
|
row.updated_at = now
|
|||
|
|
await session.flush()
|
|||
|
|
return {
|
|||
|
|
"data": {
|
|||
|
|
"request_no": row.request_no,
|
|||
|
|
"status": row.status,
|
|||
|
|
"result_content_id": str(content_id) if content_id else None,
|
|||
|
|
},
|
|||
|
|
"meta": {"trace_id": context.trace_id},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return await ApiTransactionService().execute(
|
|||
|
|
context,
|
|||
|
|
f"advisor-request:{request_no}:review",
|
|||
|
|
key,
|
|||
|
|
{"decision": decision, "comment": comment},
|
|||
|
|
operation,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---- 内部 ----
|
|||
|
|
|
|||
|
|
async def _require_valid_assessment(self, customer_id: int) -> None:
|
|||
|
|
"""申报前置:必须已完成风险测评且未失效(FM-03:12 个月)。"""
|
|||
|
|
profile = await SuitabilityService().authority_for_customer(customer_id)
|
|||
|
|
if profile.customer_risk_level is None:
|
|||
|
|
raise InvalidStateError("请先完成风险测评,再申报投顾方案")
|
|||
|
|
now = datetime.now(UTC).replace(tzinfo=None)
|
|||
|
|
if profile.valid_until is not None:
|
|||
|
|
if _naive_utc(profile.valid_until) < now:
|
|||
|
|
raise InvalidStateError("风险测评已过期,请重新测评后再申报")
|
|||
|
|
return
|
|||
|
|
# 没写有效期时按 FM-03 的 12 个月口径兜底 —— 与前端熔断规则同一口径。
|
|||
|
|
if (
|
|||
|
|
profile.assessed_at is not None
|
|||
|
|
and now - _naive_utc(profile.assessed_at) > timedelta(days=ASSESSMENT_VALID_DAYS)
|
|||
|
|
):
|
|||
|
|
raise InvalidStateError("风险测评已超过 12 个月,请重新测评后再申报")
|
|||
|
|
|
|||
|
|
async def _generate_draft_plan(
|
|||
|
|
self, request_no: str, customer_id: int, context: RequestContext
|
|||
|
|
) -> int:
|
|||
|
|
"""受理时自动出草稿:沿用既有推荐逻辑(硬约束 + 适当性 + 排序 + LLM 依据)。"""
|
|||
|
|
result = await ProductRecommendationService(
|
|||
|
|
enforce_profile_governance=True
|
|||
|
|
).generate(
|
|||
|
|
ProductRecommendationQuery(customer_id=customer_id, limit=3),
|
|||
|
|
context,
|
|||
|
|
f"advisor-request-{request_no}-generate",
|
|||
|
|
)
|
|||
|
|
status = result.get("status")
|
|||
|
|
if isinstance(status, str) and status in GENERATE_FAILURES:
|
|||
|
|
raise InvalidStateError(GENERATE_FAILURES[status])
|
|||
|
|
payload = result.get("data")
|
|||
|
|
content_id = str((payload or {}).get("content_id") or "") if isinstance(payload, dict) else ""
|
|||
|
|
if not content_id.isdigit():
|
|||
|
|
raise InvalidStateError("方案生成未返回方案编号,请稍后重试")
|
|||
|
|
return int(content_id)
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
async def _fetch(customer_ids: list[int]) -> list[AdvisorServiceRequest]:
|
|||
|
|
if not customer_ids:
|
|||
|
|
return []
|
|||
|
|
async with SessionFactory() as session:
|
|||
|
|
return list(
|
|||
|
|
await session.scalars(
|
|||
|
|
select(AdvisorServiceRequest)
|
|||
|
|
.where(AdvisorServiceRequest.customer_id.in_(customer_ids))
|
|||
|
|
.order_by(AdvisorServiceRequest.created_at.desc())
|
|||
|
|
.limit(50)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
async def _views(rows: list[AdvisorServiceRequest]) -> list[dict[str, object]]:
|
|||
|
|
"""补上「方案是否已发布」——`delivered` 是**推导**出来的,不单独存状态。"""
|
|||
|
|
content_ids = [row.result_content_id for row in rows if row.result_content_id]
|
|||
|
|
published: dict[int, bool] = {}
|
|||
|
|
if content_ids:
|
|||
|
|
async with SessionFactory() as session:
|
|||
|
|
found = (
|
|||
|
|
await session.execute(
|
|||
|
|
select(ClientFacingContent.id, ClientFacingContent.published_at).where(
|
|||
|
|
ClientFacingContent.id.in_(content_ids)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
).all()
|
|||
|
|
published = {int(cid): published_at is not None for cid, published_at in found}
|
|||
|
|
views: list[dict[str, object]] = []
|
|||
|
|
for row in rows:
|
|||
|
|
delivered = bool(
|
|||
|
|
row.result_content_id and published.get(int(row.result_content_id))
|
|||
|
|
)
|
|||
|
|
status = DELIVERED if delivered else row.status
|
|||
|
|
views.append({
|
|||
|
|
"request_no": row.request_no,
|
|||
|
|
"customer_id": str(row.customer_id),
|
|||
|
|
"amount_wan": float(row.amount_wan),
|
|||
|
|
"horizon": row.horizon,
|
|||
|
|
"risk_preference": row.risk_preference,
|
|||
|
|
"note": row.note,
|
|||
|
|
"status": status,
|
|||
|
|
"advisor_note": row.advisor_note,
|
|||
|
|
"handled_at": row.handled_at.isoformat() if row.handled_at else None,
|
|||
|
|
"result_content_id": str(row.result_content_id) if row.result_content_id else None,
|
|||
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|||
|
|
})
|
|||
|
|
return views
|