Files
Mutual_Fund/service/advisor/customers.py
T

177 lines
6.6 KiB
Python

"""客户 360 全景管理(只读 + 数据权限 + 脱敏 + 签约/结束服务写入)。
PRD §4.2:投顾仅可查看自身名下客户;敏感信息两层脱敏;画像只读。
数据权限以 customer_relation.advisor_id = current_user.id 硬过滤。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from common_const import (
AUDIT_CUSTOMER_SIGN,
AUDIT_VIEW_SENSITIVE,
CUSTOMER_REL_STATUS_CLOSED,
CUSTOMER_REL_STATUS_SIGNED,
RELATION_ACTION_SIGN,
)
from model.sys_user import SysUser
from repositories.advisor_report import AdvisorReportRepo
from repositories.customer_relation import CustomerRelationRepo
from repositories.fin_holdings import FinHoldingsRepo
from repositories.fin_product import FinProductRepo
from repositories.risk_assessment import CustomerProfileRepo
from repositories.sys_user import SysUserRepo
from schemas.advisor import RelationReq
from service.advisor.audit_writer import write_audit
from service.advisor.masking import mask_name, mask_phone
from service.advisor.permissions import ensure_customer_owned, require_owned_relation
from utils.exceptions import ForbiddenError, NotFoundError
# 持仓中状态(与 service/holdings.py 口径一致)
_HOLDING_STATUS = "持有中"
_AUDIT_MODULE = "advisor"
def _fmt_holding(h, product) -> dict:
"""持仓 + 产品信息拼装(金额/份额按字符串输出,避免浮点精度)。"""
return {
"product_id": h.product_id,
"product_code": product.product_code if product else None,
"product_name": product.product_name if product else None,
"risk_level": product.risk_level if product else None,
"shares": f"{h.shares:.4f}",
"cost_amount": f"{h.cost_amount:.2f}",
"current_value": f"{h.current_value:.2f}",
"profit_loss": f"{h.profit_loss:.2f}",
"profit_ratio": f"{h.profit_ratio:.4f}",
"status": h.status,
}
async def list_customers(
db: AsyncSession,
user: SysUser,
*,
status: str | None = None,
keyword: str | None = None,
page: int = 1,
page_size: int = 20,
) -> dict:
repo = CustomerRelationRepo(db)
rows = await repo.list_customer_rows(
advisor_id=user.id, status=status, keyword=keyword,
limit=page_size, offset=(page - 1) * page_size,
)
total = await repo.count_customer_rows(advisor_id=user.id, status=status, keyword=keyword)
items = []
for rel, account, profile in rows:
items.append(
{
"customer_id": rel.customer_id,
"real_name": mask_name(account.real_name),
"phone": mask_phone(account.phone),
"risk_level": profile.risk_level if profile else None,
"customer_level": account.customer_level,
"relation_status": rel.status,
"total_assets": float(profile.total_assets)
if profile and profile.total_assets is not None
else None,
}
)
return {"total": total, "page": page, "page_size": page_size, "items": items}
async def get_customer(
db: AsyncSession, user: SysUser, customer_id: int, *, unmask: bool = False
) -> dict:
rel = await require_owned_relation(db, user.id, customer_id)
account = await SysUserRepo(db).get(customer_id)
if account is None:
raise NotFoundError("客户不存在")
profile = await CustomerProfileRepo(db).get_by_customer(customer_id)
# 常规敏感(手机号)默认掩码;unmask=true 时返回完整值并记审计留痕。
# 强敏感(身份证/银行卡):sys_user 当前无对应字段,V1.0 无数据可暴露,规则保留。
phone = mask_phone(account.phone)
if unmask:
phone = account.phone
await write_audit(
db, user_id=user.id, username=user.username, module=_AUDIT_MODULE,
action=AUDIT_VIEW_SENSITIVE, target=str(customer_id),
detail={"field": "phone"},
)
return {
"customer_id": customer_id,
"real_name": mask_name(account.real_name),
"phone": phone,
"risk_level": profile.risk_level if profile else None,
"risk_score": profile.risk_score if profile else None,
"customer_level": account.customer_level,
"total_assets": float(profile.total_assets)
if profile and profile.total_assets is not None
else None,
"relation_status": rel.status,
"signed_time": rel.signed_time.isoformat() if rel.signed_time else None,
"assigned_time": rel.assign_time.isoformat() if rel.assign_time else None,
}
async def get_customer_holdings(
db: AsyncSession, user: SysUser, customer_id: int
) -> list[dict]:
await ensure_customer_owned(db, user.id, customer_id)
holdings = await FinHoldingsRepo(db).list_by_customer(customer_id, _HOLDING_STATUS)
product_repo = FinProductRepo(db)
items = []
for h in holdings:
product = await product_repo.get(h.product_id)
items.append(_fmt_holding(h, product))
return items
async def get_customer_reports(
db: AsyncSession, user: SysUser, customer_id: int, *, page: int = 1, page_size: int = 20
) -> dict:
await ensure_customer_owned(db, user.id, customer_id)
repo = AdvisorReportRepo(db)
items = await repo.list_by_customer(
customer_id, limit=page_size, offset=(page - 1) * page_size
)
return {
"total": await repo.count_by_advisor(advisor_id=user.id, customer_id=customer_id),
"items": [
{
"report_id": r.report_id,
"intent": r.intent,
"title": r.title,
"send_status": r.send_status,
"send_time": r.send_time.isoformat() if r.send_time else None,
}
for r in items
],
}
async def update_relation(
db: AsyncSession, user: SysUser, customer_id: int, req: RelationReq
) -> dict:
"""签约 / 结束服务(customer_relation.status 唯一写入方为工作台)。"""
rel = await require_owned_relation(db, user.id, customer_id)
if req.action == RELATION_ACTION_SIGN:
rel.status = CUSTOMER_REL_STATUS_SIGNED
rel.signed_time = datetime.now()
rel.reason = None
else: # close
rel.status = CUSTOMER_REL_STATUS_CLOSED
rel.end_time = datetime.now()
rel.reason = req.reason
await db.commit()
await write_audit(
db, user_id=user.id, username=user.username, module=_AUDIT_MODULE,
action=AUDIT_CUSTOMER_SIGN, target=str(customer_id), detail={"action": req.action},
)
return {"customer_id": customer_id, "status": rel.status}