"""客户 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 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": 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 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": 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 = 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}