Files
Django/merchant_ops/views_manage.py
XingQue b2520726dc 派单统计时机:生成链接不计派单;客户填单成功才计;退款/结单在真正完成时统计。
客服日统计联动:update_shangjia_daily(..., order_id=...) 会同步更新对应派单客服的日统计。
客服 /me 接口:返回今日/累计派单、结单、退款等 stats。
角色权限修复:预置角色(总管/财务/派单员)每次访问时强制同步权限,换角色后权限才生效。
展示增强:订单详情带派单员头像;客服列表带金额字段;管理员同意退款补商家+客服统计。
2026-06-20 04:34:41 +08:00

527 lines
24 KiB
Python

"""商家老板:邀请码、成员、角色、额度、日志、排行。"""
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.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
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}'
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
from merchant_ops.services.order_enrich import user_avatar_relative
mid = request.user.UserUID
ensure_owner_merchant(mid, request.user)
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': m.display_name or m.staff_user_id,
'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),
})
return Response({'code': 0, 'msg': '成功', 'data': {'list': result}})
except Exception as e:
return _err(e)
class StaffMemberUpdateRoleView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
ensure_owner_merchant(request.user.UserUID, request.user)
member_id = request.data.get('member_id')
role_id = request.data.get('role_id')
m = MerchantStaffMember.query.get(
id=member_id, merchant_id=request.user.UserUID, status=MEMBER_STATUS_ACTIVE,
)
role = MerchantStaffRole.query.get(id=role_id, merchant_id=request.user.UserUID, status=1)
old = m.role_id
m.role = role
m.save(update_fields=['role'])
audit_owner(request.user.UserUID, 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:
ensure_owner_merchant(request.user.UserUID, request.user)
member_id = request.data.get('member_id')
disable = request.data.get('disable', True)
m = MerchantStaffMember.query.get(id=member_id, merchant_id=request.user.UserUID)
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(request.user.UserUID, 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
ensure_owner_merchant(request.user.UserUID, request.user)
roles = MerchantStaffRole.query.filter(merchant_id=request.user.UserUID, 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'))
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:
ensure_owner_merchant(request.user.UserUID, request.user)
member_id = request.data.get('member_id')
amount = request.data.get('amount')
m = MerchantStaffMember.query.get(id=member_id, merchant_id=request.user.UserUID, status=MEMBER_STATUS_ACTIVE)
get_or_create_wallet(m)
allocate_quota(m, amount, request.user.UserUID, remark='老板划额度')
audit_owner(request.user.UserUID, request.user.UserUID, 'WALLET_ALLOCATE', request,
resource_type='wallet', resource_id=member_id, amount=amount)
return Response({'code': 0, 'msg': '划额度成功', 'data': None})
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)