feat:新增接口
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
"""资金账户服务:余额查询 + 余额加减(路由层只编排,不碰数据/逻辑)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from model.fin_account import FinAccount
|
||||
from model.sys_user import SysUser
|
||||
from repositories.fin_account import FinAccountRepo
|
||||
from schemas.account import BalanceResp
|
||||
from utils.exceptions import ForbiddenError, NotFoundError, ParamError
|
||||
|
||||
_ZERO = Decimal("0.00")
|
||||
_DEFAULT_CURRENCY = "CNY"
|
||||
|
||||
|
||||
def _build_balance_resp(account: FinAccount) -> BalanceResp:
|
||||
return BalanceResp(
|
||||
customer_id=account.customer_id,
|
||||
balance=account.balance,
|
||||
available_balance=account.balance - account.frozen_amount,
|
||||
frozen_amount=account.frozen_amount,
|
||||
currency=account.currency,
|
||||
status=account.status,
|
||||
update_time=account.update_time,
|
||||
)
|
||||
|
||||
|
||||
async def get_balance(db: AsyncSession, user: SysUser) -> BalanceResp:
|
||||
"""查询当前用户现金余额。
|
||||
|
||||
- 仅客户账号可查(员工共用 sys_user,但无资金账户);
|
||||
- 未开资金户时按零余额返回,不报错。
|
||||
"""
|
||||
if user.user_type != "CUSTOMER":
|
||||
raise ForbiddenError("仅客户账号可查询资金余额")
|
||||
|
||||
account = await FinAccountRepo(db).get_by_customer_id(user.id)
|
||||
if account is None:
|
||||
return BalanceResp(
|
||||
customer_id=user.id,
|
||||
balance=_ZERO,
|
||||
available_balance=_ZERO,
|
||||
frozen_amount=_ZERO,
|
||||
currency=_DEFAULT_CURRENCY,
|
||||
status="正常",
|
||||
)
|
||||
|
||||
return _build_balance_resp(account)
|
||||
|
||||
|
||||
async def adjust_balance(
|
||||
db: AsyncSession, user: SysUser, direction: str, amount: Decimal
|
||||
) -> BalanceResp:
|
||||
"""加减余额:direction=add 入账 / sub 出账。
|
||||
|
||||
- add:balance += amount,未开资金户时自动开户入账;
|
||||
- sub:balance -= amount,可用余额(balance - frozen_amount)不足时报错。
|
||||
"""
|
||||
if user.user_type != "CUSTOMER":
|
||||
raise ForbiddenError("仅客户账号可调整余额")
|
||||
|
||||
repo = FinAccountRepo(db)
|
||||
account = await repo.get_by_customer_id(user.id)
|
||||
|
||||
if direction == "add":
|
||||
if account is None:
|
||||
account = await repo.create(user.id, amount)
|
||||
else:
|
||||
account = await repo.add_balance(user.id, amount)
|
||||
else: # sub
|
||||
if account is None:
|
||||
raise NotFoundError("资金账户不存在")
|
||||
updated = await repo.subtract_balance(user.id, amount)
|
||||
if updated is None:
|
||||
raise ParamError("可用余额不足")
|
||||
account = updated
|
||||
|
||||
return _build_balance_resp(account)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""持仓服务:查询当前客户持仓(路由层只编排,不碰数据/逻辑)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from model.sys_user import SysUser
|
||||
from repositories.fin_holdings import FinHoldingsRepo
|
||||
from schemas.holdings import HoldingResp
|
||||
from utils.exceptions import ForbiddenError
|
||||
|
||||
_HOLDING_STATUS = "持有中"
|
||||
|
||||
|
||||
async def get_holdings(db: AsyncSession, user: SysUser) -> list[HoldingResp]:
|
||||
"""查询当前客户的在持持仓(status=持有中)。
|
||||
|
||||
- 仅客户账号可查(员工共用 sys_user,但无持仓);
|
||||
- 无持仓返回空列表,不报错。
|
||||
"""
|
||||
if user.user_type != "CUSTOMER":
|
||||
raise ForbiddenError("仅客户账号可查询持仓")
|
||||
|
||||
holdings = await FinHoldingsRepo(db).list_by_customer(user.id, _HOLDING_STATUS)
|
||||
return [
|
||||
HoldingResp(
|
||||
id=h.id,
|
||||
customer_id=h.customer_id,
|
||||
product_id=h.product_id,
|
||||
shares=h.shares,
|
||||
cost_amount=h.cost_amount,
|
||||
current_value=h.current_value,
|
||||
profit_loss=h.profit_loss,
|
||||
profit_ratio=h.profit_ratio,
|
||||
status=h.status,
|
||||
)
|
||||
for h in holdings
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""申购服务:风险匹配校验 + 余额扣减 + 持仓加仓(单事务原子)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from model.sys_user import SysUser
|
||||
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 schemas.holdings import HoldingResp
|
||||
from schemas.purchase import PurchaseResp
|
||||
from utils.exceptions import (
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
NotSuitableError,
|
||||
ParamError,
|
||||
)
|
||||
|
||||
_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())
|
||||
|
||||
|
||||
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)
|
||||
|
||||
account_repo = FinAccountRepo(db)
|
||||
holdings_repo = FinHoldingsRepo(db)
|
||||
|
||||
try:
|
||||
if not await account_repo.deduct_balance(user.id, amount):
|
||||
raise ParamError("可用余额不足")
|
||||
await holdings_repo.upsert(user.id, product_id, shares, amount)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
account = await account_repo.get_by_customer_id(user.id)
|
||||
holding = await holdings_repo.get_by_customer_product(user.id, product_id)
|
||||
return PurchaseResp(
|
||||
balance=account.balance,
|
||||
holding=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,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""赎回服务:校验持仓 → 减仓 + 余额入账(单事务原子)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from model.sys_user import SysUser
|
||||
from repositories.fin_account import FinAccountRepo
|
||||
from repositories.fin_holdings import FinHoldingsRepo
|
||||
from repositories.fin_product import FinProductRepo
|
||||
from schemas.holdings import HoldingResp
|
||||
from schemas.redeem import RedeemResp
|
||||
from utils.exceptions import ForbiddenError, NotFoundError, ParamError
|
||||
|
||||
_MONEY = Decimal("0.01")
|
||||
_SHARES = Decimal("0.0001")
|
||||
|
||||
|
||||
async def redeem(
|
||||
db: AsyncSession, user: SysUser, product_id: int, shares: Decimal
|
||||
) -> RedeemResp:
|
||||
"""赎回基金:校验通过后减仓并按净值入账,全程单事务。
|
||||
|
||||
- 仅客户可赎回;
|
||||
- 产品须存在且净值非空;
|
||||
- 持仓须存在且份额 > 0,赎回份额不得超过持仓份额;
|
||||
- 减仓(归 0 时置'已清仓')+ 入账要么都成、要么都回滚。
|
||||
"""
|
||||
if user.user_type != "CUSTOMER":
|
||||
raise ForbiddenError("仅客户账号可赎回")
|
||||
|
||||
shares = shares.quantize(_SHARES, rounding=ROUND_HALF_UP)
|
||||
if shares <= 0:
|
||||
raise ParamError("赎回份额须大于 0")
|
||||
|
||||
product = await FinProductRepo(db).get(product_id)
|
||||
if product is None:
|
||||
raise NotFoundError("产品不存在")
|
||||
if product.nav is None:
|
||||
raise ParamError("产品暂无净值,无法赎回")
|
||||
|
||||
account_repo = FinAccountRepo(db)
|
||||
holdings_repo = FinHoldingsRepo(db)
|
||||
|
||||
holding = await holdings_repo.get_by_customer_product(user.id, product_id)
|
||||
if holding is None or holding.shares <= 0:
|
||||
raise ParamError("无可赎回份额")
|
||||
if holding.shares < shares:
|
||||
raise ParamError("可赎回份额不足")
|
||||
|
||||
credited = (shares * product.nav).quantize(_MONEY, rounding=ROUND_HALF_UP)
|
||||
if credited <= 0:
|
||||
raise ParamError("赎回金额过低")
|
||||
|
||||
try:
|
||||
if not await holdings_repo.redeem(user.id, product_id, shares):
|
||||
raise ParamError("可赎回份额不足")
|
||||
await account_repo.credit_balance(user.id, credited)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
account = await account_repo.get_by_customer_id(user.id)
|
||||
holding = await holdings_repo.get_by_customer_product(user.id, product_id)
|
||||
return RedeemResp(
|
||||
balance=account.balance,
|
||||
holding=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,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user