174 lines
6.0 KiB
Python
174 lines
6.0 KiB
Python
"""申购服务:适当性校验 → 建单(待确认) → 风控检测 → 落账/挂起(单事务原子)。"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from decimal import ROUND_HALF_UP, Decimal
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from model.fin_risk_alert import FinRiskAlert
|
|
from model.fin_transaction import FinTransaction
|
|
from model.sys_user import SysUser
|
|
from model.trade_order import TradeOrder
|
|
from repositories.fin_account import FinAccountRepo
|
|
from repositories.fin_customer_profile import FinCustomerProfileRepo
|
|
from repositories.fin_holdings import FinHoldingsRepo
|
|
from repositories.fin_product import FinProductRepo
|
|
from repositories.trade_order import TradeOrderRepo
|
|
from schemas.holdings import HoldingResp
|
|
from schemas.purchase import PurchaseResp
|
|
from service.risk.engine import RiskEngine, summarize
|
|
from service.risk.settle import settle
|
|
from utils.exceptions import (
|
|
ForbiddenError,
|
|
NotFoundError,
|
|
NotSuitableError,
|
|
ParamError,
|
|
)
|
|
from utils.order_no import gen_order_no
|
|
|
|
_MONEY = Decimal("0.01")
|
|
_SHARES = Decimal("0.0001")
|
|
|
|
# 风险等级 → 序号。兼容两套口径:R1~R5 与 保守~激进(同一映射)。
|
|
_RISK_RANK = {
|
|
"R1": 1, "R2": 2, "R3": 3, "R4": 4, "R5": 5,
|
|
"保守": 1, "稳健": 2, "平衡": 3, "进取": 4, "激进": 5,
|
|
}
|
|
|
|
|
|
def _risk_rank(level: str | None) -> int | None:
|
|
"""客户画像 / 产品的风险等级统一转序号;未知返回 None。"""
|
|
if not level:
|
|
return None
|
|
return _RISK_RANK.get(level.strip())
|
|
|
|
|
|
def _holding_resp(holding) -> HoldingResp:
|
|
return HoldingResp(
|
|
id=holding.id,
|
|
customer_id=holding.customer_id,
|
|
product_id=holding.product_id,
|
|
shares=holding.shares,
|
|
cost_amount=holding.cost_amount,
|
|
current_value=holding.current_value,
|
|
profit_loss=holding.profit_loss,
|
|
profit_ratio=holding.profit_ratio,
|
|
status=holding.status,
|
|
)
|
|
|
|
|
|
async def purchase(
|
|
db: AsyncSession, user: SysUser, product_id: int, amount: Decimal
|
|
) -> PurchaseResp:
|
|
"""申购基金:适当性校验 → 建单 → 风控检测 → 落账/挂起,全程单事务。
|
|
|
|
- 仅客户可申购;
|
|
- 产品须在售且净值非空;
|
|
- 适当性不匹配(1005)在建单前直接拒绝,不生成订单;
|
|
- 未命中规则:落账 + 写成交流水,订单「已确认」;
|
|
- 命中规则:生成预警,订单「风控挂起」,账户与持仓不变。
|
|
"""
|
|
if user.user_type != "CUSTOMER":
|
|
raise ForbiddenError("仅客户账号可申购")
|
|
|
|
amount = amount.quantize(_MONEY, rounding=ROUND_HALF_UP)
|
|
|
|
product = await FinProductRepo(db).get(product_id)
|
|
if product is None:
|
|
raise NotFoundError("产品不存在")
|
|
if product.status != "在售":
|
|
raise ParamError("产品不在售")
|
|
if product.nav is None:
|
|
raise ParamError("产品暂无净值,无法申购")
|
|
|
|
profile = await FinCustomerProfileRepo(db).get_by_customer_id(user.id)
|
|
customer_rank = _risk_rank(profile.risk_level if profile else None)
|
|
if customer_rank is None:
|
|
raise NotSuitableError("客户无风险等级,无法申购")
|
|
product_rank = _risk_rank(product.risk_level)
|
|
if product_rank is None or customer_rank < product_rank:
|
|
raise NotSuitableError()
|
|
|
|
shares = (amount / product.nav).quantize(_SHARES, rounding=ROUND_HALF_UP)
|
|
|
|
now = datetime.now()
|
|
order = TradeOrder(
|
|
order_no=gen_order_no("PO"),
|
|
customer_id=user.id,
|
|
product_id=product_id,
|
|
advisor_id=None,
|
|
order_type="申购",
|
|
amount=amount,
|
|
shares=shares,
|
|
nav=product.nav,
|
|
fee=Decimal("0"),
|
|
status="待确认",
|
|
create_time=now,
|
|
)
|
|
db.add(order)
|
|
await db.flush() # 生成 order.id,供流水/预警关联
|
|
|
|
hits = await RiskEngine(db).detect(order, user)
|
|
account_repo = FinAccountRepo(db)
|
|
holdings_repo = FinHoldingsRepo(db)
|
|
|
|
try:
|
|
if not hits:
|
|
# 未命中:落账 + 写流水 + 订单确认
|
|
await settle(db, order)
|
|
db.add(
|
|
FinTransaction(
|
|
transaction_no=gen_order_no("TR"),
|
|
order_id=order.id,
|
|
customer_id=user.id,
|
|
product_id=product_id,
|
|
operator_id=None,
|
|
transaction_type="申购",
|
|
amount=amount,
|
|
shares=shares,
|
|
nav=product.nav,
|
|
fee=Decimal("0"),
|
|
status="已确认",
|
|
create_time=now,
|
|
)
|
|
)
|
|
await TradeOrderRepo(db).update_status(
|
|
order.id, status="已确认", confirm_time=now
|
|
)
|
|
await db.commit()
|
|
account = await account_repo.get_by_customer_id(user.id)
|
|
holding = await holdings_repo.get_by_customer_product(user.id, product_id)
|
|
return PurchaseResp(
|
|
order_no=order.order_no,
|
|
status="已确认",
|
|
balance=account.balance,
|
|
holding=_holding_resp(holding),
|
|
)
|
|
|
|
# 命中:生成预警 + 订单挂起
|
|
summary = summarize(hits)
|
|
alert = FinRiskAlert(
|
|
customer_id=user.id,
|
|
order_id=order.id,
|
|
alert_type=summary.alert_type,
|
|
alert_level=summary.alert_level,
|
|
trigger_detail=summary.trigger_detail,
|
|
transaction_ids=summary.transaction_ids or None,
|
|
confidence=summary.confidence,
|
|
status="未处理",
|
|
create_time=now,
|
|
)
|
|
db.add(alert)
|
|
await db.flush() # 生成 alert.id,供订单回填 risk_alert_id
|
|
await TradeOrderRepo(db).update_status(
|
|
order.id, status="风控挂起", risk_alert_id=alert.id
|
|
)
|
|
await db.commit()
|
|
return PurchaseResp(
|
|
order_no=order.order_no, status="风控挂起", alert_id=alert.id
|
|
)
|
|
except Exception:
|
|
await db.rollback()
|
|
raise
|