185 lines
6.8 KiB
Python
185 lines
6.8 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_phone
|
|
from service.advisor.permissions import ensure_customer_owned, require_owned_relation
|
|
from utils.exceptions import ForbiddenError, NotFoundError
|
|
from utils.pagination import normalize_pagination, pagination_result
|
|
|
|
# 持仓中状态(与 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 = 10,
|
|
) -> dict:
|
|
page, page_size, offset = normalize_pagination(page, page_size)
|
|
repo = CustomerRelationRepo(db)
|
|
rows = await repo.list_customer_rows(
|
|
advisor_id=user.id, status=status, keyword=keyword,
|
|
limit=page_size, offset=offset,
|
|
)
|
|
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": account.real_name or "",
|
|
"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 pagination_result(items, total, page=page, page_size=page_size)
|
|
|
|
|
|
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": account.real_name or "",
|
|
"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 = 10
|
|
) -> dict:
|
|
page, page_size, offset = normalize_pagination(page, page_size)
|
|
await ensure_customer_owned(db, user.id, customer_id)
|
|
repo = AdvisorReportRepo(db)
|
|
items = await repo.list_by_customer(
|
|
customer_id,
|
|
advisor_id=user.id,
|
|
limit=page_size,
|
|
offset=offset,
|
|
)
|
|
return pagination_result(
|
|
[
|
|
{
|
|
"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
|
|
],
|
|
await repo.count_by_advisor(advisor_id=user.id, customer_id=customer_id),
|
|
page=page,
|
|
page_size=page_size,
|
|
)
|
|
|
|
|
|
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}
|