diff --git a/jituan/services/fake_grab_order_admin.py b/jituan/services/fake_grab_order_admin.py new file mode 100644 index 0000000..ae171e3 --- /dev/null +++ b/jituan/services/fake_grab_order_admin.py @@ -0,0 +1,322 @@ +"""后台假单管理(按俱乐部 + 商品类型)。""" +import logging +import os +import random +import time +import uuid +from decimal import Decimal + +from jituan.services.club_context import ( + club_id_for_write, + resolve_club_id_from_request, + resolve_club_scope, +) +from jituan.services.club_user_access import list_response_meta +from orders.models import FakeGrabOrder, FakeGrabOrderShuffleState +from orders.services.fake_order_pool import DEFAULT_DISPATCHER_AVATAR, _scope_key +from products.models import ShangpinLeixing + +logger = logging.getLogger(__name__) + +CLUB_WRITE_SCOPE_MSG = '当前为集团汇总视图,请先在顶栏切换到具体俱乐部后再操作' +CLUB_READ_SCOPE_MSG = '请先在顶栏切换到具体小程序/俱乐部后再操作' +PRODUCT_PERMS = ('2200a',) +SHUFFLE_STATE_PK = 1 + + +def club_read_blocked(request): + return resolve_club_scope(request) == DATA_SCOPE_ALL + + +def club_write_blocked(request): + return resolve_club_scope(request) == DATA_SCOPE_ALL + + +def _gen_order_id(): + while True: + oid = f'JD{int(time.time() * 1000)}{random.randint(1000, 9999)}' + if not FakeGrabOrder.query.filter(OrderID=oid).exists(): + return oid + + +def invalidate_fake_order_shuffle(club_id, leixing_id=None): + """假单增删改后清除对应打乱缓存,下次读取自动重排。""" + try: + state = FakeGrabOrderShuffleState.objects.get(pk=SHUFFLE_STATE_PK) + except FakeGrabOrderShuffleState.DoesNotExist: + return + seq_map = dict(state.OrderSequence or {}) + if leixing_id is not None: + seq_map.pop(_scope_key(club_id, leixing_id), None) + else: + prefix = f'{club_id}|' + for key in list(seq_map.keys()): + if key.startswith(prefix): + seq_map.pop(key, None) + state.OrderSequence = seq_map + state.save(update_fields=['OrderSequence', 'UpdateTime']) + + +def _leixing_name_map(): + return { + lx.id: (lx.jieshao or f'类型{lx.id}') + for lx in ShangpinLeixing.query.exclude(shenhezhuangtai=2) + } + + +def build_leixing_options(): + """全部可用商品类型(非审核专用),假单可绑定任意类型。""" + options = [] + for lx in ShangpinLeixing.query.exclude(shenhezhuangtai=2).order_by('-paixu', 'id'): + options.append({ + 'id': lx.id, + 'jieshao': lx.jieshao or '', + 'tupian_url': lx.tupian_url or '', + 'bankuai_id': lx.bankuai_id, + }) + return options + + +def _row_to_dict(row, leixing_names=None): + leixing_names = leixing_names or _leixing_name_map() + return { + 'order_id': row.OrderID, + 'dingdan_id': row.OrderID, + 'amount': str(row.Amount or 0), + 'jine': str(row.Amount or 0), + 'leixing_id': row.ProductTypeID, + 'leixing_name': leixing_names.get(row.ProductTypeID, f'类型{row.ProductTypeID}'), + 'jieshao': row.Description or '', + 'beizhu': row.Remark or '', + 'paidan_yonghuid': row.DispatcherUserID or '', + 'paidan_touxiang': row.DispatcherAvatar or DEFAULT_DISPATCHER_AVATAR, + 'shangjia_nicheng': row.MerchantNickname or '', + 'is_youzhi_shangjia': bool(row.IsPremiumMerchant), + 'fadan_pingtai': int(row.Platform or 1), + 'platform_label': '平台发单' if int(row.Platform or 1) == 1 else '商家发单', + 'sort_order': int(row.SortOrder or 0), + 'club_id': row.ClubID, + 'CreateTime': row.CreateTime.isoformat() if row.CreateTime else None, + 'UpdateTime': row.UpdateTime.isoformat() if row.UpdateTime else None, + } + + +def build_fake_order_list_payload(request, leixing_id=None, page=1, page_size=20): + if club_read_blocked(request): + return { + 'list': [], + 'total': 0, + 'page': page, + 'page_size': page_size, + 'club_id': resolve_club_id_from_request(request), + 'scope': resolve_club_scope(request), + 'scope_error': CLUB_READ_SCOPE_MSG, + 'leixing_options': build_leixing_options(), + **list_response_meta(request), + } + + club_id = resolve_club_id_from_request(request) + page = max(1, int(page or 1)) + page_size = min(100, max(1, int(page_size or 20))) + + qs = FakeGrabOrder.query.filter(ClubID=club_id) + if leixing_id not in (None, ''): + qs = qs.filter(ProductTypeID=int(leixing_id)) + qs = qs.order_by('-SortOrder', '-CreateTime') + total = qs.count() + offset = (page - 1) * page_size + rows = list(qs[offset:offset + page_size]) + names = _leixing_name_map() + + return { + 'list': [_row_to_dict(r, names) for r in rows], + 'total': total, + 'page': page, + 'page_size': page_size, + 'club_id': club_id, + 'scope': resolve_club_scope(request), + 'leixing_options': build_leixing_options(), + **list_response_meta(request), + } + + +def _parse_decimal(value, field_name='金额'): + try: + val = Decimal(str(value)) + if val < 0: + raise ValueError + return val + except Exception: + raise ValueError(f'{field_name}格式错误') + + +def _validate_leixing_id(leixing_id): + try: + lid = int(leixing_id) + except (TypeError, ValueError): + return None, '请选择商品类型' + if not ShangpinLeixing.query.filter(id=lid).exclude(shenhezhuangtai=2).exists(): + return None, '商品类型不存在或为审核专用' + return lid, None + + +def create_fake_order(request, data): + if club_write_blocked(request): + return None, CLUB_WRITE_SCOPE_MSG + + club_id = club_id_for_write(request) + leixing_id, err = _validate_leixing_id(data.get('leixing_id')) + if err: + return None, err + + try: + amount = _parse_decimal(data.get('amount') or data.get('jine'), '价格') + except ValueError as e: + return None, str(e) + + platform = int(data.get('fadan_pingtai') or data.get('platform') or 1) + if platform not in (1, 2): + return None, '发单平台无效' + + row = FakeGrabOrder.query.create( + OrderID=_gen_order_id(), + Amount=amount, + ProductTypeID=leixing_id, + Description=(data.get('jieshao') or '').strip(), + Remark=(data.get('beizhu') or '').strip(), + DispatcherUserID=(data.get('paidan_yonghuid') or '').strip(), + DispatcherAvatar=(data.get('paidan_touxiang') or '').strip() or DEFAULT_DISPATCHER_AVATAR, + MerchantNickname=(data.get('shangjia_nicheng') or '').strip() if platform == 2 else '', + IsPremiumMerchant=bool(data.get('is_youzhi_shangjia')) if platform == 2 else False, + Platform=platform, + ClubID=club_id, + SortOrder=int(data.get('sort_order') or 0), + ) + invalidate_fake_order_shuffle(club_id, leixing_id) + return _row_to_dict(row), None + + +def update_fake_order(request, order_id, data): + if club_write_blocked(request): + return None, CLUB_WRITE_SCOPE_MSG + + club_id = club_id_for_write(request) + order_id = (order_id or '').strip() + if not order_id: + return None, '缺少订单ID' + + try: + row = FakeGrabOrder.objects.get(OrderID=order_id, ClubID=club_id) + except FakeGrabOrder.DoesNotExist: + return None, '假单不存在或不属于当前俱乐部' + + old_leixing = row.ProductTypeID + update_fields = ['UpdateTime'] + + if 'leixing_id' in data and data.get('leixing_id') not in (None, ''): + leixing_id, err = _validate_leixing_id(data.get('leixing_id')) + if err: + return None, err + row.ProductTypeID = leixing_id + update_fields.append('ProductTypeID') + + if 'amount' in data or 'jine' in data: + try: + row.Amount = _parse_decimal(data.get('amount') or data.get('jine'), '价格') + except ValueError as e: + return None, str(e) + update_fields.append('Amount') + + if 'jieshao' in data: + row.Description = (data.get('jieshao') or '').strip() + update_fields.append('Description') + if 'beizhu' in data: + row.Remark = (data.get('beizhu') or '').strip() + update_fields.append('Remark') + if 'paidan_yonghuid' in data: + row.DispatcherUserID = (data.get('paidan_yonghuid') or '').strip() + update_fields.append('DispatcherUserID') + if 'paidan_touxiang' in data: + row.DispatcherAvatar = (data.get('paidan_touxiang') or '').strip() or DEFAULT_DISPATCHER_AVATAR + update_fields.append('DispatcherAvatar') + if 'sort_order' in data: + row.SortOrder = int(data.get('sort_order') or 0) + update_fields.append('SortOrder') + + if 'fadan_pingtai' in data or 'platform' in data: + platform = int(data.get('fadan_pingtai') or data.get('platform') or row.Platform or 1) + if platform not in (1, 2): + return None, '发单平台无效' + row.Platform = platform + update_fields.append('Platform') + if platform == 1: + row.MerchantNickname = '' + row.IsPremiumMerchant = False + update_fields.extend(['MerchantNickname', 'IsPremiumMerchant']) + + if row.Platform == 2: + if 'shangjia_nicheng' in data: + row.MerchantNickname = (data.get('shangjia_nicheng') or '').strip() + update_fields.append('MerchantNickname') + if 'is_youzhi_shangjia' in data: + row.IsPremiumMerchant = bool(data.get('is_youzhi_shangjia')) + update_fields.append('IsPremiumMerchant') + + row.save(update_fields=list(set(update_fields))) + invalidate_fake_order_shuffle(club_id, row.ProductTypeID) + if old_leixing != row.ProductTypeID: + invalidate_fake_order_shuffle(club_id, old_leixing) + return _row_to_dict(row), None + + +def delete_fake_order(request, order_id): + if club_write_blocked(request): + return None, CLUB_WRITE_SCOPE_MSG + + club_id = club_id_for_write(request) + order_id = (order_id or '').strip() + if not order_id: + return None, '缺少订单ID' + + try: + row = FakeGrabOrder.objects.get(OrderID=order_id, ClubID=club_id) + except FakeGrabOrder.DoesNotExist: + return None, '假单不存在或不属于当前俱乐部' + + leixing_id = row.ProductTypeID + row.delete() + invalidate_fake_order_shuffle(club_id, leixing_id) + return {'order_id': order_id}, None + + +def upload_fake_order_avatar(request, order_id, file_obj): + if club_write_blocked(request): + return None, CLUB_WRITE_SCOPE_MSG + + from utils.oss_utils import upload_to_oss, validate_image + + club_id = club_id_for_write(request) + order_id = (order_id or '').strip() + if not order_id or not file_obj: + return None, '参数不完整' + + ok, msg = validate_image(file_obj) + if not ok: + return None, msg + + try: + row = FakeGrabOrder.objects.get(OrderID=order_id, ClubID=club_id) + except FakeGrabOrder.DoesNotExist: + return None, '假单不存在或不属于当前俱乐部' + + ext = os.path.splitext(getattr(file_obj, 'name', '') or '')[1].lower() + if ext not in ('.png', '.jpg', '.jpeg', '.webp', '.gif'): + ext = '.png' + rel_path = f'club/{club_id}/fake_grab_order/{order_id}_{uuid.uuid4().hex}{ext}' + if not upload_to_oss(file_obj, rel_path): + return None, '上传 OSS 失败' + + row.DispatcherAvatar = rel_path + row.save(update_fields=['DispatcherAvatar', 'UpdateTime']) + invalidate_fake_order_shuffle(club_id, row.ProductTypeID) + return {'order_id': order_id, 'paidan_touxiang': rel_path}, None diff --git a/jituan/services/kefu_menu_definitions.py b/jituan/services/kefu_menu_definitions.py index 6602f80..2858772 100644 --- a/jituan/services/kefu_menu_definitions.py +++ b/jituan/services/kefu_menu_definitions.py @@ -69,6 +69,8 @@ KEFU_MENU_ROW_DEFS = [ 'perm_codes': ['7007a']}, {'page_id': 'product.data', 'name': '商品数据分析', 'path': '/product/data', 'parent_id': 'product', 'sort_order': 93, 'perm_codes': ['2200a']}, + {'page_id': 'product.fake-orders', 'name': '假单管理', 'path': '/product/fake-orders', 'parent_id': 'product', 'sort_order': 94, + 'perm_codes': ['2200a']}, {'page_id': 'member', 'name': '会员管理', 'path': '', 'parent_id': '', 'sort_order': 100}, {'page_id': 'member.list', 'name': '会员管理', 'path': '/member/list', 'parent_id': 'member', 'sort_order': 101, 'perm_codes': ['3300a']}, diff --git a/jituan/urls.py b/jituan/urls.py index e189ea3..c152a77 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -66,6 +66,11 @@ from jituan.views_dashou_exam import ( DashouExamQuestionManageView, DashouExamStatusView, ) +from jituan.views_fake_grab_order import ( + FakeGrabOrderAvatarUploadView, + FakeGrabOrderListView, + FakeGrabOrderManageView, +) from jituan.views_club_catalog import ( ClubKaoheChenghaoCatalogView, ClubKaoheChenghaoDisableView, @@ -144,6 +149,9 @@ urlpatterns = [ 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/fake-grab-order-list', FakeGrabOrderListView.as_view(), name='jituan_fake_grab_order_list'), + path('houtai/fake-grab-order-manage', FakeGrabOrderManageView.as_view(), name='jituan_fake_grab_order_manage'), + path('houtai/fake-grab-order-avatar', FakeGrabOrderAvatarUploadView.as_view(), name='jituan_fake_grab_order_avatar'), 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'), diff --git a/jituan/views_fake_grab_order.py b/jituan/views_fake_grab_order.py new file mode 100644 index 0000000..4430d7a --- /dev/null +++ b/jituan/views_fake_grab_order.py @@ -0,0 +1,108 @@ +"""假单后台管理 API。""" +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated + +from backend.view import verify_kefu_permission + +PRODUCT_PERMS = ('2200a',) + + +def _check_product_perm(request, phone): + kefu, permissions = verify_kefu_permission(request, phone) + if kefu is None: + return None, permissions + if not any(p in permissions for p in PRODUCT_PERMS): + return None, Response({'code': 403, 'msg': '无商品管理权限'}) + return kefu, None + + +class FakeGrabOrderListView(APIView): + """POST /jituan/houtai/fake-grab-order-list""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + _, err = _check_product_perm(request, phone) + if err: + return err + + from jituan.services.fake_grab_order_admin import build_fake_order_list_payload + + payload = build_fake_order_list_payload( + request, + leixing_id=request.data.get('leixing_id'), + page=request.data.get('page', 1), + page_size=request.data.get('page_size', 20), + ) + return Response({'code': 0, 'data': payload}) + + +class FakeGrabOrderManageView(APIView): + """POST /jituan/houtai/fake-grab-order-manage action=create|update|delete""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + action = (request.data.get('action') or '').strip().lower() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + _, err = _check_product_perm(request, phone) + if err: + return err + + from jituan.services.fake_grab_order_admin import ( + create_fake_order, + delete_fake_order, + update_fake_order, + ) + + if action == 'create': + data, err_msg = create_fake_order(request, request.data) + if err_msg: + return Response({'code': 400, 'msg': err_msg}) + return Response({'code': 0, 'msg': '创建成功', 'data': data}) + + if action == 'update': + order_id = request.data.get('order_id') or request.data.get('dingdan_id') + data, err_msg = update_fake_order(request, order_id, request.data) + if err_msg: + return Response({'code': 400, 'msg': err_msg}) + return Response({'code': 0, 'msg': '更新成功', 'data': data}) + + if action == 'delete': + order_id = request.data.get('order_id') or request.data.get('dingdan_id') + data, err_msg = delete_fake_order(request, order_id) + if err_msg: + return Response({'code': 400, 'msg': err_msg}) + return Response({'code': 0, 'msg': '已删除', 'data': data}) + + return Response({'code': 400, 'msg': '无效的 action'}) + + +class FakeGrabOrderAvatarUploadView(APIView): + """POST /jituan/houtai/fake-grab-order-avatar""" + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + order_id = (request.data.get('order_id') or request.data.get('dingdan_id') or '').strip() + file_obj = request.FILES.get('file') + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + _, err = _check_product_perm(request, phone) + if err: + return err + + from jituan.services.fake_grab_order_admin import upload_fake_order_avatar + + data, err_msg = upload_fake_order_avatar(request, order_id, file_obj) + if err_msg: + return Response({'code': 400, 'msg': err_msg}) + return Response({'code': 0, 'msg': '上传成功', 'data': data}) diff --git a/orders/services/fake_order_pool.py b/orders/services/fake_order_pool.py index cfa5384..a3844e9 100644 --- a/orders/services/fake_order_pool.py +++ b/orders/services/fake_order_pool.py @@ -170,7 +170,7 @@ def _load_fake_rows_in_saved_order(club_id, leixing_id): def _format_fake_row(row, anchor_dt=None): """字段与 DashouDingdanHuoquView 真实单列表保持一致。""" - avatar = DEFAULT_DISPATCHER_AVATAR + avatar = (getattr(row, 'DispatcherAvatar', None) or '').strip() or DEFAULT_DISPATCHER_AVATAR platform = int(row.Platform or 1) oid = row.OrderID price = float(row.Amount or 0)