feat: 会员充值明细接口与俱乐部一键创建总会员

新增 hy-recharge-records 查询 czjilu 会员单并关联 gsfenhong 分红;hy-bundle-create 支持俱乐部一步创建总会员;会员数据分析页权限改为 3300a。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-05 18:27:48 +08:00
parent 37bc8f960a
commit 2a350a2ec8
6 changed files with 438 additions and 3 deletions

View File

@@ -44,7 +44,7 @@ from .views.products import (
from .views.members import (
GetMemberListView, UpdateMemberView, AddMemberView,
ClubMemberCatalogView, ClubMemberEnableView, ClubMemberDisableView,
MemberCardImageUploadView,
MemberCardImageUploadView, MemberRechargeRecordsView, MemberBundleCreateView,
)
from .views.guanli import (
GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView,
@@ -103,7 +103,7 @@ __all__ = [
# members
'GetMemberListView', 'UpdateMemberView', 'AddMemberView',
'ClubMemberCatalogView', 'ClubMemberEnableView', 'ClubMemberDisableView',
'MemberCardImageUploadView',
'MemberCardImageUploadView', 'MemberRechargeRecordsView', 'MemberBundleCreateView',
# guanli
'GetGuanliListView', 'GetGuanliDetailView', 'PromoteGuanshiToZuzhangView',
'UpdateGuanliView',

View File

@@ -377,3 +377,81 @@ class MemberCardImageUploadView(APIView):
return Response({'code': 0, 'msg': '上传成功', 'data': data})
class MemberRechargeRecordsView(APIView):
"""POST /jituan/houtai/hy-recharge-records — 会员充值记录明细 + 分红"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = (request.data.get('username') or request.data.get('phone') or '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '3300a' not in permissions:
return Response({'code': 403, 'msg': '无权限查看会员充值记录'})
from jituan.services.member_recharge_records import build_member_recharge_records_payload
filters = request.data.get('filters') or {}
for key in (
'dingdan_id', 'yonghuid', 'buyer_keyword', 'huiyuan_id', 'is_trial',
'zhuangtai', 'date_from', 'date_to', 'jine_min', 'jine_max',
'guanshi_id', 'zuzhang_id', 'keyword', 'is_bundle', 'has_fenhong', 'club_id',
):
if request.data.get(key) not in (None, ''):
filters[key] = request.data.get(key)
payload = build_member_recharge_records_payload(
request,
filters=filters,
page=request.data.get('page', 1),
page_size=request.data.get('page_size', 20),
)
return Response({'code': 0, 'data': payload})
class MemberBundleCreateView(APIView):
"""POST /jituan/houtai/hy-bundle-create — 俱乐部一键创建总会员并上架"""
permission_classes = []
parser_classes = [JSONParser]
def post(self, request):
username = (request.data.get('username') or request.data.get('phone') or '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return permissions
if '3300a' not in permissions:
return Response({'code': 403, 'msg': '无权限'})
jieshao = (request.data.get('jieshao') or '').strip()
if not jieshao:
return Response({'code': 400, 'msg': '会员名称不能为空'})
try:
jiage = Decimal(str(request.data.get('jiage')))
guanshifc = Decimal(str(request.data.get('guanshifc')))
zuzhangfc = Decimal(str(request.data.get('zuzhangfc')))
except Exception:
return Response({'code': 400, 'msg': '金额格式错误'})
from jituan.services.club_member_admin import create_bundle_member_for_club
data, err = create_bundle_member_for_club(
request,
jieshao=jieshao,
jiage=jiage,
guanshifc=guanshifc,
zuzhangfc=zuzhangfc,
jtjieshao=(request.data.get('jtjieshao') or '').strip(),
formal_days=int(request.data.get('formal_days') or 30),
included_huiyuan_ids=request.data.get('included_huiyuan_ids') or [],
)
if err:
return Response({'code': 400, 'msg': err})
return Response({'code': 0, 'msg': '总会员创建并上架成功', 'data': data})

View File

@@ -382,6 +382,51 @@ def add_global_member(jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, bankuai_i
return {'huiyuan_id': member.huiyuan_id}, None
def create_bundle_member_for_club(
request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao,
formal_days=30, included_huiyuan_ids=None,
):
"""俱乐部一键创建总会员:新建全局 is_bundle 类型 + 本俱乐部上架 + 配置包含子会员。"""
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
included_huiyuan_ids = included_huiyuan_ids or []
if not included_huiyuan_ids:
return None, '总会员请至少选择一个包含的子会员'
try:
jiage = Decimal(str(jiage))
guanshifc = Decimal(str(guanshifc))
zuzhangfc = Decimal(str(zuzhangfc))
if jiage <= 0:
raise ValueError
trial_fields = _parse_trial_fields(
{'formal_days': formal_days, 'trial_enabled': False},
jiage, guanshifc, zuzhangfc,
)
except ValueError as e:
return None, str(e) if str(e) else '金额或天数配置错误'
with transaction.atomic():
data, err = add_global_member(
jieshao, jiage, guanshifc, zuzhangfc, jtjieshao or '', is_bundle=True,
)
if err:
return None, err
extra = {
**trial_fields,
'included_huiyuan_ids': included_huiyuan_ids,
}
_, err = enable_member_for_club(
request, data['huiyuan_id'], jiage, guanshifc, zuzhangfc, extra=extra,
)
if err:
return None, err
return data, None
# 兼容旧名:俱乐部不应再调用
def add_member_for_club(request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, bankuai_id=None):
if club_write_blocked(request):

View File

@@ -73,7 +73,7 @@ KEFU_MENU_ROW_DEFS = [
{'page_id': 'member.list', 'name': '会员管理', 'path': '/member/list', 'parent_id': 'member', 'sort_order': 101,
'perm_codes': ['3300a']},
{'page_id': 'member.data', 'name': '会员数据分析', 'path': '/member/data', 'parent_id': 'member', 'sort_order': 102,
'perm_codes': ['huiyuancaiwu']},
'perm_codes': ['3300a']},
{'page_id': 'miniapp', 'name': '小程序配置', 'path': '', 'parent_id': '', 'sort_order': 110},
{'page_id': 'miniapp.carousel', 'name': '轮播图与公告', 'path': '/miniapp/carousel', 'parent_id': 'miniapp', 'sort_order': 111,
'perm_codes': ['8080a']},

View File

@@ -0,0 +1,308 @@
"""后台会员充值记录明细czjilu leixing=1+ 管事/组长分红关联查询。"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from django.db.models import Q, Sum, Count
from django.utils import timezone
from jituan.services.club_context import filter_queryset_by_club, resolve_club_scope, resolve_club_id_from_request
from jituan.services.club_user_access import list_response_meta
from jituan.services.member_recharge import (
MEMBER_LEIXING,
MEMBER_PAID_STATUS,
MEMBER_PENDING_STATUS,
resolve_formal_purchase_cishu,
)
from jituan.constants import DATA_SCOPE_ALL
from products.models import Czjilu, Gsfenhong, Huiyuan
from users.models import UserDashou, UserBoss
MEMBER_STATUS_LABELS = {
MEMBER_PAID_STATUS: '已支付',
MEMBER_PENDING_STATUS: '待支付',
}
def _parse_dt(value):
if not value:
return None
if isinstance(value, datetime):
return value
text = str(value).strip()
if not text:
return None
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d'):
try:
dt = datetime.strptime(text[:19] if 'T' not in text else text.replace('Z', '')[:19], fmt)
if timezone.is_naive(dt):
return timezone.make_aware(dt, timezone.get_current_timezone())
return dt
except ValueError:
continue
return None
def _batch_user_display_names(user_ids):
user_ids = [x for x in {str(i).strip() for i in user_ids if i} if x]
if not user_ids:
return {}
result = {uid: uid for uid in user_ids}
dashou_map = {
r.user.UserUID: (r.nicheng or '').strip()
for r in UserDashou.query.filter(user__UserUID__in=user_ids).select_related('user')
if getattr(r.user, 'UserUID', None)
}
boss_map = {
r.user.UserUID: (r.nickname or '').strip()
for r in UserBoss.query.filter(user__UserUID__in=user_ids).select_related('user')
if getattr(r.user, 'UserUID', None)
}
for uid in user_ids:
name = dashou_map.get(uid) or boss_map.get(uid)
if name:
result[uid] = name
return result
def _build_base_queryset(request):
qs = Czjilu.query.filter(leixing=MEMBER_LEIXING)
return filter_queryset_by_club(qs, request, club_field='club_id')
def _apply_filters(qs, filters):
f = filters or {}
dingdan_id = (f.get('dingdan_id') or '').strip()
if dingdan_id:
qs = qs.filter(dingdan_id__icontains=dingdan_id)
yonghuid = (f.get('yonghuid') or '').strip()
if yonghuid:
qs = qs.filter(yonghuid__icontains=yonghuid)
buyer_keyword = (f.get('buyer_keyword') or f.get('dashou_keyword') or '').strip()
if buyer_keyword:
dashou_ids = list(
UserDashou.query.filter(nicheng__icontains=buyer_keyword).values_list('user__UserUID', flat=True)
)
qs = qs.filter(
Q(yonghuid__icontains=buyer_keyword)
| Q(shuoming__icontains=buyer_keyword)
| Q(yonghuid__in=dashou_ids)
)
huiyuan_id = f.get('huiyuan_id')
if huiyuan_id:
if isinstance(huiyuan_id, (list, tuple)):
ids = [str(x).strip() for x in huiyuan_id if str(x).strip()]
if ids:
qs = qs.filter(huiyuan_id__in=ids)
else:
qs = qs.filter(huiyuan_id=str(huiyuan_id).strip())
if f.get('is_trial') is not None and f.get('is_trial') != '':
qs = qs.filter(is_trial=bool(f.get('is_trial')))
zhuangtai = f.get('zhuangtai')
if zhuangtai is not None and zhuangtai != '':
qs = qs.filter(zhuangtai=int(zhuangtai))
date_from = _parse_dt(f.get('date_from') or f.get('start_time'))
date_to = _parse_dt(f.get('date_to') or f.get('end_time'))
if date_from:
qs = qs.filter(CreateTime__gte=date_from)
if date_to:
if len(str(f.get('date_to') or f.get('end_time') or '')) <= 10:
date_to = date_to.replace(hour=23, minute=59, second=59)
qs = qs.filter(CreateTime__lte=date_to)
jine_min = f.get('jine_min')
jine_max = f.get('jine_max')
if jine_min not in (None, ''):
qs = qs.filter(jine__gte=Decimal(str(jine_min)))
if jine_max not in (None, ''):
qs = qs.filter(jine__lte=Decimal(str(jine_max)))
guanshi_id = (f.get('guanshi_id') or '').strip()
if guanshi_id:
order_ids = Gsfenhong.query.filter(
guanshi__icontains=guanshi_id,
fenhong_leixing=1,
).values_list('dingdan_id', flat=True)
qs = qs.filter(dingdan_id__in=list(order_ids))
zuzhang_id = (f.get('zuzhang_id') or '').strip()
if zuzhang_id:
order_ids = Gsfenhong.query.filter(
zuzhang_id__icontains=zuzhang_id,
fenhong_leixing=1,
).values_list('dingdan_id', flat=True)
qs = qs.filter(dingdan_id__in=list(order_ids))
keyword = (f.get('keyword') or '').strip()
if keyword:
qs = qs.filter(
Q(dingdan_id__icontains=keyword)
| Q(yonghuid__icontains=keyword)
| Q(shuoming__icontains=keyword)
| Q(huiyuan_id__icontains=keyword)
)
is_bundle = f.get('is_bundle')
if is_bundle is not None and is_bundle != '':
bundle_ids = list(
Huiyuan.query.filter(is_bundle=bool(is_bundle)).values_list('huiyuan_id', flat=True)
)
if bool(is_bundle):
qs = qs.filter(huiyuan_id__in=bundle_ids)
else:
qs = qs.exclude(huiyuan_id__in=bundle_ids)
has_fenhong = f.get('has_fenhong')
if has_fenhong is not None and has_fenhong != '':
paid_ids = Gsfenhong.query.filter(fenhong_leixing=1).values_list('dingdan_id', flat=True)
paid_set = set(paid_ids)
if bool(has_fenhong):
qs = qs.filter(dingdan_id__in=paid_set)
else:
qs = qs.exclude(dingdan_id__in=paid_set)
club_id = (f.get('club_id') or '').strip()
if club_id:
qs = qs.filter(club_id=club_id)
return qs
def _build_summary(qs):
paid_qs = qs.filter(zhuangtai=MEMBER_PAID_STATUS)
paid_ids = list(paid_qs.values_list('dingdan_id', flat=True))
fh_qs = Gsfenhong.query.filter(dingdan_id__in=paid_ids, fenhong_leixing=1)
agg = paid_qs.aggregate(
paid_count=Count('dingdan_id'),
paid_jine=Sum('jine'),
trial_count=Count('dingdan_id', filter=Q(is_trial=True)),
)
fh_agg = fh_qs.aggregate(
guanshi_total=Sum('fenhong'),
zuzhang_total=Sum('zuzhang_fenhong'),
fenhong_count=Count('dingdan_id'),
)
return {
'total_count': qs.count(),
'paid_count': int(agg.get('paid_count') or 0),
'pending_count': qs.filter(zhuangtai=MEMBER_PENDING_STATUS).count(),
'paid_jine': str(agg.get('paid_jine') or Decimal('0')),
'trial_count': int(agg.get('trial_count') or 0),
'guanshi_fenhong_total': str(fh_agg.get('guanshi_total') or Decimal('0')),
'zuzhang_fenhong_total': str(fh_agg.get('zuzhang_total') or Decimal('0')),
'fenhong_record_count': int(fh_agg.get('fenhong_count') or 0),
}
def _huiyuan_options(request):
qs = Huiyuan.query.all().order_by('-CreateTime')
return [
{
'huiyuan_id': m.huiyuan_id,
'jieshao': m.jieshao,
'is_bundle': bool(getattr(m, 'is_bundle', False)),
}
for m in qs
]
def build_member_recharge_records_payload(request, filters=None, page=1, page_size=20):
page = max(1, int(page or 1))
page_size = min(100, max(1, int(page_size or 20)))
qs = _apply_filters(_build_base_queryset(request), filters)
qs = qs.order_by('-CreateTime')
total = qs.count()
offset = (page - 1) * page_size
rows = list(qs[offset:offset + page_size])
dingdan_ids = [r.dingdan_id for r in rows]
fenhong_map = {
g.dingdan_id: g
for g in Gsfenhong.query.filter(dingdan_id__in=dingdan_ids, fenhong_leixing=1)
}
huiyuan_ids = {r.huiyuan_id for r in rows if r.huiyuan_id}
huiyuan_map = {m.huiyuan_id: m for m in Huiyuan.query.filter(huiyuan_id__in=huiyuan_ids)}
user_ids = {r.yonghuid for r in rows if r.yonghuid}
for g in fenhong_map.values():
if g.guanshi:
user_ids.add(g.guanshi)
if g.zuzhang_id:
user_ids.add(g.zuzhang_id)
if g.dashouid:
user_ids.add(g.dashouid)
name_map = _batch_user_display_names(user_ids)
items = []
for order in rows:
hy = huiyuan_map.get(order.huiyuan_id)
fh = fenhong_map.get(order.dingdan_id)
formal_cishu = None
if order.zhuangtai == MEMBER_PAID_STATUS and not order.is_trial:
formal_cishu = resolve_formal_purchase_cishu(
order.yonghuid, order.huiyuan_id, order.dingdan_id, order.club_id,
)
fenhong_detail = None
if fh:
fenhong_detail = {
'guanshi_id': fh.guanshi or '',
'guanshi_nicheng': name_map.get(fh.guanshi, fh.guanshi or ''),
'guanshi_fenhong': str(fh.fenhong or 0),
'zuzhang_id': fh.zuzhang_id or '',
'zuzhang_nicheng': name_map.get(fh.zuzhang_id, fh.zuzhang_id or ''),
'zuzhang_fenhong': str(fh.zuzhang_fenhong or 0),
'fenhong_leixing': fh.fenhong_leixing,
'is_trial': bool(fh.is_trial),
'fenhong_time': fh.CreateTime.isoformat() if fh.CreateTime else None,
'formal_cishu': formal_cishu,
'shuoming': fh.shuoming or '',
}
items.append({
'dingdan_id': order.dingdan_id,
'yonghuid': order.yonghuid,
'buyer_nicheng': name_map.get(order.yonghuid, order.yonghuid),
'huiyuan_id': order.huiyuan_id,
'huiyuan_name': hy.jieshao if hy else (order.huiyuan_id or ''),
'is_bundle': bool(hy and getattr(hy, 'is_bundle', False)),
'jine': str(order.jine or 0),
'zhuangtai': order.zhuangtai,
'zhuangtai_label': MEMBER_STATUS_LABELS.get(order.zhuangtai, f'状态{order.zhuangtai}'),
'is_trial': bool(order.is_trial),
'purchase_days': order.purchase_days,
'shuoming': order.shuoming or '',
'club_id': order.club_id,
'CreateTime': order.CreateTime.isoformat() if order.CreateTime else None,
'UpdateTime': order.UpdateTime.isoformat() if order.UpdateTime else None,
'formal_cishu': formal_cishu,
'fenhong': fenhong_detail,
})
return {
'list': items,
'total': total,
'page': page,
'page_size': page_size,
'summary': _build_summary(_apply_filters(_build_base_queryset(request), filters)),
'filter_options': {
'huiyuan_list': _huiyuan_options(request),
'zhuangtai_options': [
{'value': MEMBER_PAID_STATUS, 'label': '已支付'},
{'value': MEMBER_PENDING_STATUS, 'label': '待支付'},
],
},
'scope': resolve_club_scope(request),
'club_id': resolve_club_id_from_request(request),
**list_response_meta(request),
}

View File

@@ -23,6 +23,8 @@ from backend.view import (
ShgxgsjView,
UpdateMemberView,
MemberCardImageUploadView,
MemberRechargeRecordsView,
MemberBundleCreateView,
UpdateWithdrawSettingsView,
)
from users.views import KefuPunishmentListView
@@ -140,6 +142,8 @@ urlpatterns = [
path('houtai/hthysj', ClubMemberEnableView.as_view(), name='jituan_member_enable'),
path('houtai/hthyxj', ClubMemberDisableView.as_view(), name='jituan_member_disable'),
path('houtai/hy-card-upload', MemberCardImageUploadView.as_view(), name='jituan_member_card_upload'),
path('houtai/hy-recharge-records', MemberRechargeRecordsView.as_view(), name='jituan_member_recharge_records'),
path('houtai/hy-bundle-create', MemberBundleCreateView.as_view(), name='jituan_member_bundle_create'),
path('houtai/club-leixing-list', ClubShangpinLeixingListView.as_view(), name='jituan_club_leixing_list'),
path('houtai/club-leixing-catalog', ClubShangpinLeixingCatalogView.as_view(), name='jituan_club_leixing_catalog'),
path('houtai/club-leixing-enable', ClubShangpinLeixingEnableView.as_view(), name='jituan_club_leixing_enable'),