diff --git a/backend/view.py b/backend/view.py index e70bb55..e37fd21 100644 --- a/backend/view.py +++ b/backend/view.py @@ -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', diff --git a/backend/views/members.py b/backend/views/members.py index 3abeef0..0e14124 100644 --- a/backend/views/members.py +++ b/backend/views/members.py @@ -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}) + + diff --git a/jituan/services/club_member_admin.py b/jituan/services/club_member_admin.py index c6256bb..0bb1b51 100644 --- a/jituan/services/club_member_admin.py +++ b/jituan/services/club_member_admin.py @@ -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): diff --git a/jituan/services/kefu_menu_definitions.py b/jituan/services/kefu_menu_definitions.py index e571f32..6602f80 100644 --- a/jituan/services/kefu_menu_definitions.py +++ b/jituan/services/kefu_menu_definitions.py @@ -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']}, diff --git a/jituan/services/member_recharge_records.py b/jituan/services/member_recharge_records.py new file mode 100644 index 0000000..8d2eb34 --- /dev/null +++ b/jituan/services/member_recharge_records.py @@ -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), + } diff --git a/jituan/urls.py b/jituan/urls.py index e405e34..e189ea3 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -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'),