"""商家老板:邀请码、成员、角色、额度、日志、排行。""" import logging import secrets import string from datetime import timedelta from decimal import Decimal from django.db.models import Sum, Q from django.utils import timezone from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from merchant_ops.constants import ( INVITE_STATUS_REVOKED, INVITE_STATUS_UNUSED, INVITE_STATUS_USED, MEMBER_STATUS_ACTIVE, MEMBER_STATUS_DISABLED, MEMBER_STATUS_REMOVED, ) from merchant_ops.models import ( MerchantStaffInvite, MerchantStaffMember, MerchantStaffRole, MerchantStaffRolePermission, MerchantStaffMemberPermission, MerchantStaffWallet, MerchantStaffWalletLedger, MerchantStaffAuditLog, MerchantStaffDailyStats, MerchantStaffPermission, ) from merchant_ops.services.authz import ( StaffAuthError, ensure_owner_merchant, get_member_permissions, is_merchant_owner, resolve_staff_context, get_active_staff_member, member_has_perm as _member_has_perm, ) from merchant_ops.services.order_enrich import staff_display_label, user_avatar_relative from merchant_ops.services.audit import audit_owner from merchant_ops.services.bootstrap import ensure_merchant_roles from merchant_ops.services.wallet import allocate_quota, get_or_create_wallet, revoke_quota from users.models import UserShangjia logger = logging.getLogger(__name__) def _err(e): if isinstance(e, StaffAuthError): return Response({'code': e.code, 'msg': e.msg, 'data': None}) logger.exception(e) return Response({'code': 500, 'msg': '服务器错误', 'data': None}) def _role_prefix(role_code): m = {'CHIEF': 'ZG', 'FINANCE': 'CW', 'DISPATCHER': 'PD'} return m.get(role_code, 'KF') def _role_hint(role_code, tpl_desc=None): hints = { 'CHIEF': '总管:除老板专属外,订单/派单/客服/额度/罚单等均可操作', 'FINANCE': '财务:查看余额与额度、划额度、看全部订单与统计,不可派单结单', 'DISPATCHER': '派单员:派单发单、只看自己派的单,不可结单退款', } if role_code in hints: return hints[role_code] name = (tpl_desc or {}).get(role_code) or role_code return f'自定义角色:{name}' def _resolve_manage_context(request, need_staff_manage=False): """商家老板,或具备 staff_manage 的总管等子客服。""" if is_merchant_owner(request.user): ensure_owner_merchant(request.user.UserUID, request.user) return request.user.UserUID, None member = get_active_staff_member(request.user) if not member: raise StaffAuthError(403, '无权限') if need_staff_manage and not _member_has_perm(member, 'staff_manage'): raise StaffAuthError(403, '无客服管理权限') ensure_merchant_roles(member.merchant_id) return member.merchant_id, member def _resolve_wallet_operator(request, perm_code): """商家老板,或具备 wallet_allocate / wallet_revoke 的子客服(总管/财务)。""" if is_merchant_owner(request.user): ensure_owner_merchant(request.user.UserUID, request.user) return request.user.UserUID, None member = get_active_staff_member(request.user) if not member or not _member_has_perm(member, perm_code): raise StaffAuthError(403, f'无{perm_code}权限') ensure_merchant_roles(member.merchant_id) return member.merchant_id, member def _assert_not_self_target(operator_member, target_member): """子客服不得对自己做管理类操作(划收额度、换角色、封禁等)。""" if operator_member and target_member.id == operator_member.id: raise StaffAuthError(400, '不能对本人执行此操作') class StaffInviteCreateView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: ensure_owner_merchant(request.user.UserUID, request.user) role_id = request.data.get('role_id') if not role_id: return Response({'code': 400, 'msg': '请选择角色', 'data': None}) role = MerchantStaffRole.query.get(id=role_id, merchant_id=request.user.UserUID) prefix = _role_prefix(role.role_code) code = f"{prefix}-{secrets.token_hex(3).upper()}" invite = MerchantStaffInvite.objects.create( merchant_id=request.user.UserUID, invite_code=code, role=role, status=INVITE_STATUS_UNUSED, created_by=request.user.UserUID, expire_at=timezone.now() + timedelta(days=30), ) audit_owner(request.user.UserUID, request.user.UserUID, 'INVITE_CREATE', request, resource_type='invite', resource_id=code) return Response({'code': 0, 'msg': '成功', 'data': {'invite_code': code, 'id': invite.id}}) except Exception as e: return _err(e) class StaffInviteListView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: ensure_owner_merchant(request.user.UserUID, request.user) status_filter = request.data.get('status') # None=all, 0,1,2 qs = MerchantStaffInvite.query.filter(merchant_id=request.user.UserUID).select_related('role') if status_filter is not None: qs = qs.filter(status=int(status_filter)) items = [] for inv in qs.order_by('-CreateTime')[:200]: item = { 'id': inv.id, 'code': inv.invite_code, 'typeName': inv.role.role_name if inv.role else '', 'status': inv.status, 'adddate': inv.CreateTime.strftime('%Y-%m-%d') if inv.CreateTime else '', 'usedate': inv.used_at.strftime('%Y-%m-%d') if inv.used_at else '', 'user': None, } if inv.used_by: from users.business_models import User u = User.query.filter(UserUID=inv.used_by).first() item['user'] = {'nickname': getattr(u, 'Nickname', '') or inv.used_by, 'uid': inv.used_by} items.append(item) return Response({'code': 0, 'msg': '成功', 'data': {'list': items, 'gudingCode': 'XQKF2026'}}) except Exception as e: return _err(e) class StaffInviteRevokeView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: ensure_owner_merchant(request.user.UserUID, request.user) invite_id = request.data.get('id') inv = MerchantStaffInvite.query.get(id=invite_id, merchant_id=request.user.UserUID, status=INVITE_STATUS_UNUSED) inv.status = INVITE_STATUS_REVOKED inv.save(update_fields=['status']) audit_owner(request.user.UserUID, request.user.UserUID, 'INVITE_REVOKE', request, resource_id=inv.invite_code) return Response({'code': 0, 'msg': '已作废', 'data': None}) except MerchantStaffInvite.DoesNotExist: return Response({'code': 404, 'msg': '邀请码不存在或已使用', 'data': None}) except Exception as e: return _err(e) class StaffMemberListView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: from datetime import date from users.business_models import User mid, _ = _resolve_manage_context(request, need_staff_manage=True) today = date.today() members = MerchantStaffMember.query.filter( merchant_id=mid, status__in=[MEMBER_STATUS_ACTIVE, MEMBER_STATUS_DISABLED], ).select_related('role') result = [] for m in members: total_stats = MerchantStaffDailyStats.query.filter(member_id=m.id).aggregate( dispatch_count=Sum('dispatch_count'), dispatch_amount=Sum('dispatch_amount'), settle_count=Sum('settle_count'), settle_amount=Sum('settle_amount'), refund_count=Sum('refund_count'), refund_amount=Sum('refund_amount'), ) today_stat = MerchantStaffDailyStats.query.filter( member_id=m.id, stat_date=today, ).first() wallet = get_or_create_wallet(m) u = User.query.filter(UserUID=m.staff_user_id).first() result.append({ 'id': m.id, 'uid': m.staff_user_id, 'nickname': staff_display_label(m), 'merchant_remark': m.merchant_remark or '', 'display_name': m.display_name or '', 'avatar': user_avatar_relative(u), 'role_name': m.role.role_name if m.role else '', 'isChief': m.role.role_code == 'CHIEF' if m.role else False, 'isFinance': m.role.role_code == 'FINANCE' if m.role else False, 'status': m.status, 'isDisabled': m.status == MEMBER_STATUS_DISABLED, 'todayDispatchCount': today_stat.dispatch_count if today_stat else int(m.today_dispatch_count or 0), 'todayDispatchAmount': str(today_stat.dispatch_amount if today_stat else (m.today_dispatch_amount or '0.00')), 'dispatchCount': total_stats['dispatch_count'] or 0, 'dispatchAmount': str(total_stats['dispatch_amount'] or '0.00'), 'orderCount': total_stats['dispatch_count'] or 0, 'settleCount': total_stats['settle_count'] or 0, 'settleAmount': str(total_stats['settle_amount'] or '0.00'), 'refundCount': total_stats['refund_count'] or 0, 'refundAmount': str(total_stats['refund_amount'] or '0.00'), 'quota_available': str(wallet.quota_available), 'quota_total': str(wallet.quota_total), 'quota_used': str(wallet.quota_used), 'isSelf': m.staff_user_id == request.user.UserUID, }) return Response({'code': 0, 'msg': '成功', 'data': {'list': result}}) except Exception as e: return _err(e) class StaffMemberRemarkUpdateView(APIView): """商家老板/总管:设置客服备注""" permission_classes = [IsAuthenticated] def post(self, request): try: mid, operator = _resolve_manage_context(request, need_staff_manage=True) member_id = request.data.get('member_id') remark = str(request.data.get('merchant_remark', '')).strip()[:64] if not member_id: return Response({'code': 400, 'msg': '缺少 member_id', 'data': None}) m = MerchantStaffMember.query.get(id=member_id, merchant_id=mid) m.merchant_remark = remark m.save(update_fields=['merchant_remark']) audit_owner(mid, request.user.UserUID, 'MEMBER_UPDATE_REMARK', request, resource_type='member', resource_id=member_id, value_after={'merchant_remark': remark}) return Response({ 'code': 0, 'msg': '备注已保存', 'data': {'member_id': m.id, 'merchant_remark': remark, 'nickname': staff_display_label(m)}, }) except MerchantStaffMember.DoesNotExist: return Response({'code': 404, 'msg': '客服不存在', 'data': None}) except Exception as e: return _err(e) class StaffMemberUpdateRoleView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: mid, operator = _resolve_manage_context(request, need_staff_manage=True) member_id = request.data.get('member_id') role_id = request.data.get('role_id') m = MerchantStaffMember.query.get( id=member_id, merchant_id=mid, status=MEMBER_STATUS_ACTIVE, ) _assert_not_self_target(operator, m) role = MerchantStaffRole.query.get(id=role_id, merchant_id=mid, status=1) old = m.role_id m.role = role m.save(update_fields=['role']) audit_owner(mid, request.user.UserUID, 'MEMBER_UPDATE_ROLE', request, resource_type='member', resource_id=member_id, value_before={'role_id': old}, value_after={'role_id': role_id}) perms = list(role.role_permissions.values_list('perm_code', flat=True)) return Response({'code': 0, 'msg': '角色已更新', 'data': { 'member_id': m.id, 'role_id': role.id, 'role_code': role.role_code, 'role_name': role.role_name, 'permissions': perms, }}) except Exception as e: return _err(e) class StaffMemberRemoveView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: ensure_owner_merchant(request.user.UserUID, request.user) member_id = request.data.get('member_id') m = MerchantStaffMember.query.get(id=member_id, merchant_id=request.user.UserUID) m.status = MEMBER_STATUS_REMOVED m.revoked_at = timezone.now() m.save(update_fields=['status', 'revoked_at']) audit_owner(request.user.UserUID, request.user.UserUID, 'MEMBER_REMOVE', request, resource_type='member', resource_id=member_id) return Response({'code': 0, 'msg': '已移除', 'data': None}) except Exception as e: return _err(e) class StaffMemberDisableView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: mid, operator = _resolve_manage_context(request, need_staff_manage=True) member_id = request.data.get('member_id') disable = request.data.get('disable', True) m = MerchantStaffMember.query.get(id=member_id, merchant_id=mid) _assert_not_self_target(operator, m) m.status = MEMBER_STATUS_DISABLED if disable else MEMBER_STATUS_ACTIVE m.save(update_fields=['status']) action = 'MEMBER_DISABLE' if disable else 'MEMBER_ENABLE' audit_owner(mid, request.user.UserUID, action, request, resource_id=member_id) return Response({'code': 0, 'msg': '成功', 'data': None}) except Exception as e: return _err(e) class StaffRoleListView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: from merchant_ops.constants import PERMISSION_DEFINITIONS, SYSTEM_ROLE_TEMPLATES from merchant_ops.services.bootstrap import seed_global_permissions seed_global_permissions() if is_merchant_owner(request.user): mid = request.user.UserUID ensure_owner_merchant(mid, request.user) else: mid, _ = _resolve_manage_context(request, need_staff_manage=True) roles = MerchantStaffRole.query.filter(merchant_id=mid, status=1) tpl_desc = {t['role_code']: t['role_name'] for t in SYSTEM_ROLE_TEMPLATES} data = [] for r in roles: perms = list(r.role_permissions.values_list('perm_code', flat=True)) perm_labels = [ dict(PERMISSION_DEFINITIONS).get(p, p) for p in perms ] data.append({ 'id': r.id, 'role_code': r.role_code, 'role_name': r.role_name, 'is_system': r.is_system, 'permissions': perms, 'permission_labels': perm_labels, 'role_hint': _role_hint(r.role_code, tpl_desc), }) all_perms = list(MerchantStaffPermission.query.filter(status=1).order_by('sort_order').values( 'perm_code', 'perm_name')) if not all_perms: all_perms = [{'perm_code': c, 'perm_name': n} for c, n in PERMISSION_DEFINITIONS] return Response({'code': 0, 'msg': '成功', 'data': {'roles': data, 'all_permissions': all_perms}}) except Exception as e: return _err(e) class StaffRoleSetPermsView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: ensure_owner_merchant(request.user.UserUID, request.user) role_id = request.data.get('role_id') permissions = request.data.get('permissions', []) role = MerchantStaffRole.query.get(id=role_id, merchant_id=request.user.UserUID) MerchantStaffRolePermission.query.filter(role=role).delete() for p in permissions: MerchantStaffRolePermission.objects.create(role=role, perm_code=p) audit_owner(request.user.UserUID, request.user.UserUID, 'ROLE_SET_PERMS', request, resource_id=role_id) return Response({'code': 0, 'msg': '成功', 'data': None}) except Exception as e: return _err(e) class StaffRoleCreateView(APIView): """商家自定义角色(自由勾选权限)。""" permission_classes = [IsAuthenticated] def post(self, request): try: mid = request.user.UserUID ensure_owner_merchant(mid, request.user) ensure_merchant_roles(mid) role_name = (request.data.get('role_name') or '').strip() if not role_name: return Response({'code': 400, 'msg': '角色名称不能为空', 'data': None}) role_code = (request.data.get('role_code') or '').strip() if not role_code: role_code = 'CUSTOM_' + secrets.token_hex(3).upper() if MerchantStaffRole.query.filter(merchant_id=mid, role_code=role_code).exists(): return Response({'code': 400, 'msg': '角色码已存在', 'data': None}) permissions = request.data.get('permissions', []) role = MerchantStaffRole.objects.create( club_id='xq', merchant_id=mid, role_code=role_code, role_name=role_name, is_system=False, sort_order=50, status=1, ) valid = set(MerchantStaffPermission.query.filter(status=1).values_list('perm_code', flat=True)) for p in permissions: if p in valid: MerchantStaffRolePermission.objects.create(role=role, perm_code=p) audit_owner(mid, mid, 'ROLE_CREATE', request, resource_id=role.id) return Response({'code': 0, 'msg': '创建成功', 'data': {'id': role.id, 'role_code': role.role_code}}) except Exception as e: return _err(e) class StaffMemberUpdatePermsView(APIView): """成员级权限加减(覆盖角色默认权限)。""" permission_classes = [IsAuthenticated] def post(self, request): try: mid = request.user.UserUID ensure_owner_merchant(mid, request.user) member_id = request.data.get('member_id') overrides = request.data.get('overrides', []) m = MerchantStaffMember.query.get(id=member_id, merchant_id=mid, status=MEMBER_STATUS_ACTIVE) MerchantStaffMemberPermission.query.filter(member=m).delete() valid = set(MerchantStaffPermission.query.filter(status=1).values_list('perm_code', flat=True)) for item in overrides: if isinstance(item, dict): code = item.get('perm_code') granted = bool(item.get('granted', True)) else: code = item granted = True if code not in valid: continue MerchantStaffMemberPermission.objects.create(member=m, perm_code=code, granted=granted) audit_owner(mid, mid, 'MEMBER_SET_PERMS', request, resource_id=member_id) return Response({'code': 0, 'msg': '成员权限已更新', 'data': {'permissions': list(get_member_permissions(m))}}) except Exception as e: return _err(e) class StaffWalletAllocateView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: mid, operator = _resolve_wallet_operator(request, 'wallet_allocate') member_id = request.data.get('member_id') amount = request.data.get('amount') m = MerchantStaffMember.query.get( id=member_id, merchant_id=mid, status=MEMBER_STATUS_ACTIVE, ) _assert_not_self_target(operator, m) get_or_create_wallet(m) wallet = allocate_quota(m, amount, request.user.UserUID, remark='划额度') audit_owner(mid, request.user.UserUID, 'WALLET_ALLOCATE', request, resource_type='wallet', resource_id=member_id, amount=amount) shangjia = UserShangjia.query.filter(user__UserUID=mid).first() return Response({'code': 0, 'msg': '划额度成功', 'data': { 'quota_available': str(wallet.quota_available), 'merchant_yue': str(shangjia.yue) if shangjia else '0', }}) except ValueError as e: return Response({'code': 400, 'msg': str(e), 'data': None}) except Exception as e: return _err(e) class StaffWalletRevokeView(APIView): """收回客服未使用额度,退回商家余额。""" permission_classes = [IsAuthenticated] def post(self, request): try: mid, operator = _resolve_wallet_operator(request, 'wallet_revoke') member_id = request.data.get('member_id') amount = request.data.get('amount') remark = (request.data.get('remark') or '').strip() or '收回未使用额度' m = MerchantStaffMember.query.get( id=member_id, merchant_id=mid, status__in=[MEMBER_STATUS_ACTIVE, MEMBER_STATUS_DISABLED], ) _assert_not_self_target(operator, m) wallet = revoke_quota(m, amount, request.user.UserUID, remark=remark) audit_owner(mid, request.user.UserUID, 'WALLET_REVOKE', request, resource_type='wallet', resource_id=member_id, amount=amount) shangjia = UserShangjia.query.filter(user__UserUID=mid).first() return Response({'code': 0, 'msg': '收回成功', 'data': { 'quota_available': str(wallet.quota_available), 'merchant_yue': str(shangjia.yue) if shangjia else '0', }}) except ValueError as e: return Response({'code': 400, 'msg': str(e), 'data': None}) except Exception as e: return _err(e) class StaffWalletLedgerView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: mid = request.user.UserUID if is_merchant_owner(request.user): member_id = request.data.get('member_id') qs = MerchantStaffWalletLedger.query.filter(merchant_id=mid) if member_id: m = MerchantStaffMember.query.get(id=member_id, merchant_id=mid) qs = qs.filter(staff_user_id=m.staff_user_id) else: ctx = resolve_staff_context(request.user) if not ctx: return Response({'code': 403, 'msg': '无权限', 'data': None}) qs = MerchantStaffWalletLedger.query.filter(staff_user_id=request.user.UserUID) items = [{ 'change_type': x.change_type, 'amount': str(x.amount), 'balance_after': str(x.balance_after), 'related_order_id': x.related_order_id or '', 'remark': x.remark, 'time': x.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if x.CreateTime else '', } for x in qs.order_by('-CreateTime')[:100]] return Response({'code': 0, 'msg': '成功', 'data': {'list': items}}) except Exception as e: return _err(e) class StaffAuditListView(APIView): """POST /yonghu/merchant-staff/audit/list — 商家老板或有 audit_view 权限的客服可查看重要操作日志。""" permission_classes = [IsAuthenticated] def post(self, request): try: from merchant_ops.constants import IMPORTANT_AUDIT_ACTIONS from merchant_ops.services.audit import serialize_audit_row mid = request.user.UserUID member = None if is_merchant_owner(request.user): ensure_owner_merchant(mid, request.user) else: member = get_active_staff_member(request.user) if not member or not _member_has_perm(member, 'audit_view'): return Response({'code': 403, 'msg': '无权限', 'data': None}) mid = member.merchant_id page = max(1, int(request.data.get('page', 1))) page_size = min(50, int(request.data.get('page_size', 20))) qs = MerchantStaffAuditLog.query.filter( merchant_id=mid, action__in=IMPORTANT_AUDIT_ACTIONS, ) staff_filter = request.data.get('staff_user_id') if staff_filter: qs = qs.filter(operator_user_id=staff_filter) total = qs.count() rows = list(qs.order_by('-CreateTime')[(page - 1) * page_size: page * page_size]) member_ids = {r.staff_member_id for r in rows if r.staff_member_id} member_map = {} if member_ids: for m in MerchantStaffMember.query.filter(id__in=member_ids).select_related('role'): member_map[m.id] = m items = [serialize_audit_row(r, member_map) for r in rows] return Response({'code': 0, 'msg': '成功', 'data': { 'list': items, 'total': total, 'has_more': page * page_size < total, }}) except Exception as e: return _err(e) class StaffRankView(APIView): permission_classes = [IsAuthenticated] def post(self, request): try: from datetime import date, timedelta from django.db.models import Sum if is_merchant_owner(request.user): mid = request.user.UserUID else: member = get_active_staff_member(request.user) if not member: return Response({'code': 403, 'msg': '无权限', 'data': None}) mid = member.merchant_id period = request.data.get('period', 'today') sort_by = request.data.get('sort_by', 'dispatch_count') today = date.today() if period == 'today': start = today elif period == 'yesterday': start = today - timedelta(days=1) elif period == 'week': start = today - timedelta(days=7) elif period == 'month': start = today - timedelta(days=30) else: start = None qs = MerchantStaffDailyStats.query.filter(merchant_id=mid) if start: if period == 'yesterday': qs = qs.filter(stat_date=start) else: qs = qs.filter(stat_date__gte=start) agg = qs.values('staff_user_id', 'member_id').annotate( dispatch_count=Sum('dispatch_count'), dispatch_amount=Sum('dispatch_amount'), settle_count=Sum('settle_count'), settle_amount=Sum('settle_amount'), ) sort_field = sort_by if sort_by in ('dispatch_count', 'dispatch_amount', 'settle_count', 'settle_amount') else 'dispatch_count' agg = sorted(agg, key=lambda x: x[sort_field] or 0, reverse=True)[:50] result = [] for idx, row in enumerate(agg, 1): m = MerchantStaffMember.query.filter(id=row['member_id']).first() result.append({ 'rank': idx, 'member_id': row['member_id'], 'staff_user_id': row['staff_user_id'], 'display_name': (m.display_name if m else '') or row['staff_user_id'], 'dispatch_count': row['dispatch_count'] or 0, 'dispatch_amount': str(row['dispatch_amount'] or 0), 'settle_count': row['settle_count'] or 0, 'settle_amount': str(row['settle_amount'] or 0), }) return Response({'code': 0, 'msg': '成功', 'data': {'list': result, 'period': period}}) except Exception as e: return _err(e)