From 9c029288ba1518b0a5fe6670496c85a9d752252f Mon Sep 17 00:00:00 2001 From: XingQue Date: Wed, 15 Jul 2026 23:42:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(merchant):=20=E8=AE=A2=E5=8D=95=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E6=B0=B4=E8=A1=A8=E4=B8=8E=E6=8C=89=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=93=8D=E4=BD=9C=E4=BA=BA=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 merchant_order_action_log,在结算/撤单/退款/罚单/派单/换打手成功路径写入真实操作人;提供 action-stats / action-operators 接口供经营数据页筛选统计。 Co-authored-by: Cursor --- merchant_ops/constants.py | 27 +++ .../migrations/0003_merchantorderactionlog.py | 46 +++++ merchant_ops/models.py | 26 +++ merchant_ops/services/action_log.py | 157 +++++++++++++++ merchant_ops/services/action_stats.py | 186 ++++++++++++++++++ merchant_ops/services/dispatch.py | 19 +- merchant_ops/urls.py | 5 + merchant_ops/views_action_stats.py | 102 ++++++++++ orders/views/admin.py | 18 +- orders/views/merchant.py | 55 ++++++ orders/views/merchant_penalty.py | 75 ++++++- 11 files changed, 707 insertions(+), 9 deletions(-) create mode 100644 merchant_ops/migrations/0003_merchantorderactionlog.py create mode 100644 merchant_ops/services/action_log.py create mode 100644 merchant_ops/services/action_stats.py create mode 100644 merchant_ops/views_action_stats.py diff --git a/merchant_ops/constants.py b/merchant_ops/constants.py index 481a4f9..3e09813 100644 --- a/merchant_ops/constants.py +++ b/merchant_ops/constants.py @@ -14,6 +14,33 @@ INVITE_STATUS_EXPIRED = 3 ACTOR_MERCHANT = 'MERCHANT' ACTOR_STAFF = 'STAFF' +# 商家订单操作流水行为码(数字,同一订单可重复记录) +ACTION_SETTLE = 1 +ACTION_CANCEL = 2 +ACTION_REFUND_APPLY = 3 +ACTION_REFUND_CANCEL = 4 +ACTION_REFUND_MODIFY = 5 +ACTION_PENALTY_APPLY = 6 +ACTION_PENALTY_CANCEL = 7 +ACTION_PENALTY_MODIFY = 8 +ACTION_PENALTY_RESUBMIT = 9 +ACTION_CHANGE_PLAYER = 10 +ACTION_DISPATCH = 11 + +ORDER_ACTION_LABELS = { + ACTION_SETTLE: '结算', + ACTION_CANCEL: '撤销订单', + ACTION_REFUND_APPLY: '申请退款', + ACTION_REFUND_CANCEL: '取消退款', + ACTION_REFUND_MODIFY: '修改退款理由', + ACTION_PENALTY_APPLY: '申请罚单', + ACTION_PENALTY_CANCEL: '撤销罚单', + ACTION_PENALTY_MODIFY: '修改罚单', + ACTION_PENALTY_RESUBMIT: '重新申请罚单', + ACTION_CHANGE_PLAYER: '更换打手', + ACTION_DISPATCH: '派单', +} + WALLET_ALLOCATE = 'ALLOCATE' WALLET_DISPATCH_FREEZE = 'DISPATCH_FREEZE' WALLET_DISPATCH_CONFIRM = 'DISPATCH_CONFIRM' diff --git a/merchant_ops/migrations/0003_merchantorderactionlog.py b/merchant_ops/migrations/0003_merchantorderactionlog.py new file mode 100644 index 0000000..29d2b98 --- /dev/null +++ b/merchant_ops/migrations/0003_merchantorderactionlog.py @@ -0,0 +1,46 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('merchant_ops', '0002_merchantstaffmember_merchant_remark'), + ] + + operations = [ + migrations.CreateModel( + name='MerchantOrderActionLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, default='xq', max_length=16)), + ('merchant_id', models.CharField(db_index=True, max_length=7)), + ('order_id', models.CharField(db_index=True, max_length=32)), + ('action_code', models.PositiveSmallIntegerField(db_index=True, verbose_name='行为码')), + ('operator_user_id', models.CharField(db_index=True, max_length=7)), + ('operator_type', models.CharField(max_length=16, verbose_name='MERCHANT/STAFF')), + ('staff_member_id', models.BigIntegerField(blank=True, db_index=True, null=True)), + ('role_code', models.CharField(blank=True, default='', max_length=32)), + ('role_name', models.CharField(blank=True, default='', max_length=64)), + ('amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('leixing_id', models.IntegerField(blank=True, db_index=True, null=True)), + ('remark', models.CharField(blank=True, default='', max_length=500)), + ('CreateTime', models.DateTimeField(auto_now_add=True, db_index=True)), + ], + options={ + 'verbose_name': '商家订单操作流水', + 'db_table': 'merchant_order_action_log', + }, + ), + migrations.AddIndex( + model_name='merchantorderactionlog', + index=models.Index(fields=['merchant_id', 'operator_user_id', '-CreateTime'], name='merchant_or_merchan_op_idx'), + ), + migrations.AddIndex( + model_name='merchantorderactionlog', + index=models.Index(fields=['merchant_id', 'action_code', '-CreateTime'], name='merchant_or_merchan_ac_idx'), + ), + migrations.AddIndex( + model_name='merchantorderactionlog', + index=models.Index(fields=['merchant_id', 'leixing_id', '-CreateTime'], name='merchant_or_merchan_lx_idx'), + ), + ] diff --git a/merchant_ops/models.py b/merchant_ops/models.py index dfe5f40..6449c4a 100644 --- a/merchant_ops/models.py +++ b/merchant_ops/models.py @@ -223,3 +223,29 @@ class MerchantDispatchTemplate(QModel): class Meta: db_table = 'merchant_dispatch_template' + + +class MerchantOrderActionLog(QModel): + """商家侧订单操作流水:同一订单可多次记录,按真实操作人统计。""" + club_id = models.CharField(max_length=16, default='xq', db_index=True) + merchant_id = models.CharField(max_length=7, db_index=True) + order_id = models.CharField(max_length=32, db_index=True) + action_code = models.PositiveSmallIntegerField(db_index=True, verbose_name='行为码') + operator_user_id = models.CharField(max_length=7, db_index=True) + operator_type = models.CharField(max_length=16, verbose_name='MERCHANT/STAFF') + staff_member_id = models.BigIntegerField(null=True, blank=True, db_index=True) + role_code = models.CharField(max_length=32, blank=True, default='') + role_name = models.CharField(max_length=64, blank=True, default='') + amount = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + leixing_id = models.IntegerField(null=True, blank=True, db_index=True) + remark = models.CharField(max_length=500, blank=True, default='') + CreateTime = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + db_table = 'merchant_order_action_log' + verbose_name = '商家订单操作流水' + indexes = [ + models.Index(fields=['merchant_id', 'operator_user_id', '-CreateTime']), + models.Index(fields=['merchant_id', 'action_code', '-CreateTime']), + models.Index(fields=['merchant_id', 'leixing_id', '-CreateTime']), + ] diff --git a/merchant_ops/services/action_log.py b/merchant_ops/services/action_log.py new file mode 100644 index 0000000..c2a2125 --- /dev/null +++ b/merchant_ops/services/action_log.py @@ -0,0 +1,157 @@ +"""商家订单操作流水:真实操作人落库(支持同一订单多次操作)。""" +import logging +from decimal import Decimal, InvalidOperation + +from merchant_ops.constants import ( + ACTOR_MERCHANT, + ACTOR_STAFF, + ORDER_ACTION_LABELS, +) +from merchant_ops.models import MerchantOrderActionLog + +logger = logging.getLogger(__name__) + + +def _to_decimal(amount): + if amount is None or amount == '': + return None + try: + return Decimal(str(amount)) + except (InvalidOperation, TypeError, ValueError): + return None + + +def _client_ip(request): + if request is None: + return '' + xff = request.META.get('HTTP_X_FORWARDED_FOR') + if xff: + return xff.split(',')[0].strip() + return request.META.get('REMOTE_ADDR', '') or '' + + +def resolve_operator_from_request(request, merchant_id): + """ + 优先 request._staff_member(子客服代理);否则记为商家本人。 + 返回 dict: operator_user_id/operator_type/staff_member_id/role_code/role_name + """ + mid = str(merchant_id or '').strip() + staff_member = getattr(request, '_staff_member', None) if request is not None else None + if staff_member is not None: + role = getattr(staff_member, 'role', None) + return { + 'operator_user_id': str(getattr(staff_member, 'staff_user_id', '') or ''), + 'operator_type': ACTOR_STAFF, + 'staff_member_id': getattr(staff_member, 'id', None), + 'role_code': getattr(role, 'role_code', '') or '', + 'role_name': getattr(role, 'role_name', '') or '', + } + # 商家本人:代理场景下 request.user 已是商家 + uid = '' + if request is not None and getattr(request, 'user', None) is not None: + uid = str(getattr(request.user, 'UserUID', '') or '') + if not uid: + uid = mid + return { + 'operator_user_id': uid, + 'operator_type': ACTOR_MERCHANT, + 'staff_member_id': None, + 'role_code': 'OWNER', + 'role_name': '商家本人', + } + + +def record_order_action( + *, + merchant_id, + order_id, + action_code, + operator_user_id, + operator_type, + staff_member_id=None, + role_code='', + role_name='', + amount=None, + leixing_id=None, + remark='', + club_id='xq', + request=None, +): + """写入一条操作流水。失败只打日志,不影响主业务。""" + try: + oid = str(order_id or '').strip() + mid = str(merchant_id or '').strip() + if not oid or not mid or not operator_user_id: + logger.warning( + '操作流水跳过:参数不完整 merchant=%s order=%s action=%s operator=%s', + mid, oid, action_code, operator_user_id, + ) + return None + code = int(action_code) + row = MerchantOrderActionLog.objects.create( + club_id=(club_id or 'xq')[:16], + merchant_id=mid[:7], + order_id=oid[:32], + action_code=code, + operator_user_id=str(operator_user_id)[:7], + operator_type=(operator_type or ACTOR_MERCHANT)[:16], + staff_member_id=staff_member_id, + role_code=(role_code or '')[:32], + role_name=(role_name or '')[:64], + amount=_to_decimal(amount), + leixing_id=int(leixing_id) if leixing_id not in (None, '') else None, + remark=(remark or '')[:500], + ) + logger.info( + '【商家操作流水】写入成功 id=%s merchant=%s order=%s action=%s(%s) ' + 'operator=%s type=%s role=%s amount=%s leixing=%s ip=%s', + row.id, mid, oid, code, ORDER_ACTION_LABELS.get(code, '?'), + operator_user_id, operator_type, role_code or role_name, + amount, leixing_id, _client_ip(request), + ) + return row + except Exception: + logger.error( + '【商家操作流水】写入失败 merchant=%s order=%s action=%s', + merchant_id, order_id, action_code, exc_info=True, + ) + return None + + +def record_order_action_from_request( + request, + action_code, + order_id, + *, + merchant_id=None, + amount=None, + leixing_id=None, + remark='', + club_id='xq', +): + """从 request(含 _staff_member)自动识别操作人并落库。""" + mid = str(merchant_id or '').strip() + if not mid and request is not None and getattr(request, 'user', None) is not None: + mid = str(getattr(request.user, 'UserUID', '') or '') + op = resolve_operator_from_request(request, mid) + return record_order_action( + merchant_id=mid, + order_id=order_id, + action_code=action_code, + operator_user_id=op['operator_user_id'], + operator_type=op['operator_type'], + staff_member_id=op.get('staff_member_id'), + role_code=op.get('role_code', ''), + role_name=op.get('role_name', ''), + amount=amount, + leixing_id=leixing_id, + remark=remark, + club_id=club_id, + request=request, + ) + + +def leixing_id_from_order(order): + if order is None: + return None + return getattr(order, 'ProductTypeID', None) or getattr(order, 'leixing_id', None) diff --git a/merchant_ops/services/action_stats.py b/merchant_ops/services/action_stats.py new file mode 100644 index 0000000..2db3ced --- /dev/null +++ b/merchant_ops/services/action_stats.py @@ -0,0 +1,186 @@ +"""按真实操作人聚合商家订单操作流水统计。""" +from datetime import datetime +from decimal import Decimal + +from django.db.models import Count, Sum, Q +from django.utils import timezone + +from merchant_ops.constants import ( + ACTOR_MERCHANT, + ACTOR_STAFF, + ORDER_ACTION_LABELS, +) +from merchant_ops.models import MerchantOrderActionLog, MerchantStaffMember +from products.models import ShangpinLeixing + + +def _parse_dt(value, end_of_day=False): + if value is None or value == '': + return None + if isinstance(value, datetime): + return value + text = str(value).strip().replace('/', '-') + for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d'): + try: + dt = datetime.strptime(text, fmt) + if fmt == '%Y-%m-%d' and end_of_day: + dt = dt.replace(hour=23, minute=59, second=59) + if timezone.is_naive(dt): + dt = timezone.make_aware(dt, timezone.get_current_timezone()) + return dt + except ValueError: + continue + return None + + +def _money(val): + if val is None: + return '0.00' + return f'{Decimal(str(val)):.2f}' + + +def build_action_stats_queryset( + merchant_id, + *, + operator_user_id=None, + operator_type=None, + role_code=None, + action_code=None, + action_codes=None, + leixing_id=None, + start_time=None, + end_time=None, +): + qs = MerchantOrderActionLog.objects.filter(merchant_id=str(merchant_id)) + if operator_user_id: + qs = qs.filter(operator_user_id=str(operator_user_id)) + if operator_type: + qs = qs.filter(operator_type=str(operator_type).upper()) + if role_code: + qs = qs.filter(role_code=str(role_code)) + if action_code not in (None, ''): + qs = qs.filter(action_code=int(action_code)) + if action_codes: + codes = [] + for c in action_codes: + try: + codes.append(int(c)) + except (TypeError, ValueError): + pass + if codes: + qs = qs.filter(action_code__in=codes) + if leixing_id not in (None, ''): + qs = qs.filter(leixing_id=int(leixing_id)) + st = _parse_dt(start_time, end_of_day=False) + et = _parse_dt(end_time, end_of_day=True) + if st: + qs = qs.filter(CreateTime__gte=st) + if et: + qs = qs.filter(CreateTime__lte=et) + return qs + + +def summarize_action_logs(qs): + total = qs.aggregate(cnt=Count('id'), amt=Sum('amount')) + by_action_rows = ( + qs.values('action_code') + .annotate(cnt=Count('id'), amt=Sum('amount')) + .order_by('action_code') + ) + by_action = [] + for row in by_action_rows: + code = int(row['action_code']) + by_action.append({ + 'action_code': code, + 'action_label': ORDER_ACTION_LABELS.get(code, f'未知({code})'), + 'count': int(row['cnt'] or 0), + 'amount': _money(row['amt']), + }) + + by_leixing_rows = ( + qs.exclude(leixing_id__isnull=True) + .values('leixing_id') + .annotate(cnt=Count('id'), amt=Sum('amount')) + .order_by('-cnt') + ) + leixing_ids = [r['leixing_id'] for r in by_leixing_rows if r['leixing_id'] is not None] + name_map = {} + if leixing_ids: + for lx in ShangpinLeixing.query.filter(id__in=leixing_ids).only('id', 'jieshao'): + name_map[lx.id] = getattr(lx, 'jieshao', '') or str(lx.id) + by_leixing = [] + for row in by_leixing_rows: + lid = row['leixing_id'] + by_leixing.append({ + 'leixing_id': lid, + 'leixing_name': name_map.get(lid, str(lid)), + 'count': int(row['cnt'] or 0), + 'amount': _money(row['amt']), + }) + + return { + 'total_count': int(total['cnt'] or 0), + 'total_amount': _money(total['amt']), + 'by_action': by_action, + 'by_leixing': by_leixing, + } + + +def build_operator_action_stats(merchant_id, filters): + qs = build_action_stats_queryset(merchant_id, **filters) + summary = summarize_action_logs(qs) + + people_rows = ( + qs.values('operator_user_id', 'operator_type', 'role_code', 'role_name') + .annotate(cnt=Count('id'), amt=Sum('amount')) + .order_by('-cnt') + ) + people = [] + for row in people_rows: + people.append({ + 'operator_user_id': row['operator_user_id'], + 'operator_type': row['operator_type'], + 'role_code': row['role_code'] or '', + 'role_name': row['role_name'] or ( + '商家本人' if row['operator_type'] == ACTOR_MERCHANT else '' + ), + 'count': int(row['cnt'] or 0), + 'amount': _money(row['amt']), + }) + + return { + **summary, + 'people': people, + 'action_defs': [ + {'action_code': k, 'action_label': v} + for k, v in sorted(ORDER_ACTION_LABELS.items()) + ], + } + + +def list_operator_candidates(merchant_id): + """经营数据页可选身份:商家本人 + 子客服列表。""" + items = [{ + 'operator_user_id': str(merchant_id), + 'operator_type': ACTOR_MERCHANT, + 'role_code': 'OWNER', + 'role_name': '商家本人', + 'display_name': '商家本人', + 'staff_member_id': None, + }] + members = ( + MerchantStaffMember.objects + .filter(merchant_id=str(merchant_id), status=1) + .select_related('role') + .order_by('id') + ) + for m in members: + items.append({ + 'operator_user_id': m.staff_user_id, + 'operator_type': ACTOR_STAFF, + 'role_code': m.role.role_code if m.role else '', + 'role_name': m.role.role_name if m.role else '', + 'display_name': m.display_name or m.merchant_remark or m.staff_user_id, + 'staff_member_id': m.id, + }) + return items diff --git a/merchant_ops/services/dispatch.py b/merchant_ops/services/dispatch.py index 7c5dd0d..75a63eb 100644 --- a/merchant_ops/services/dispatch.py +++ b/merchant_ops/services/dispatch.py @@ -9,9 +9,10 @@ from django.db import transaction from django.db.models import F from backend.utils import update_shangjia_daily -from merchant_ops.constants import ACTOR_STAFF, STAT_ACTION_DISPATCH +from merchant_ops.constants import ACTOR_STAFF, STAT_ACTION_DISPATCH, ACTION_DISPATCH from merchant_ops.models import MerchantOrderDispatch from merchant_ops.services.wallet import check_wallet_available, confirm_dispatch_deduct +from merchant_ops.services.action_log import record_order_action from orders.models import MerchantOrderExt, Order from orders.notice_tasks import dingdan_guangbo from orders.utils import calc_shangjia_order_fencheng @@ -200,6 +201,22 @@ def staff_dispatch_order(member, staff_user, data): except Exception as e: logger.error(f'广播任务失败: {e}') + try: + record_order_action( + merchant_id=merchant_id, + order_id=dingdan.OrderID, + action_code=ACTION_DISPATCH, + operator_user_id=member.staff_user_id, + operator_type=ACTOR_STAFF, + staff_member_id=member.id, + role_code=getattr(getattr(member, 'role', None), 'role_code', '') or '', + role_name=getattr(getattr(member, 'role', None), 'role_name', '') or '', + amount=dingdan.Amount, + leixing_id=getattr(dingdan, 'ProductTypeID', None), + ) + except Exception: + logger.error('子客服派单写操作流水失败', exc_info=True) + return True, { 'code': 200, 'msg': '派单成功', diff --git a/merchant_ops/urls.py b/merchant_ops/urls.py index 44a111b..0ec8d58 100644 --- a/merchant_ops/urls.py +++ b/merchant_ops/urls.py @@ -14,6 +14,9 @@ from merchant_ops.views_orders import ( StaffProductTypesView, StaffSettleView, StaffCancelView, StaffRefundView, StaffPenaltyApplyView, StaffPenaltyManageView, StaffChangePlayerView, ) +from merchant_ops.views_action_stats import ( + StaffOrderActionStatsView, StaffOrderActionOperatorsView, +) from merchant_ops.views_templates import ( StaffTemplateListView, StaffTemplateCreateView, StaffTemplateUpdateView, StaffTemplateDeleteView, StaffLinkGenerateView, StaffLinkListView, @@ -44,6 +47,8 @@ urlpatterns = [ # 子客服订单(全新接口) path('order/list', StaffOrderListView.as_view()), path('order/stats', StaffOrderStatsView.as_view()), + path('order/action-stats', StaffOrderActionStatsView.as_view()), + path('order/action-operators', StaffOrderActionOperatorsView.as_view()), path('order/detail', StaffOrderDetailView.as_view()), path('order/dispatch', StaffDispatchView.as_view()), path('order/product-types', StaffProductTypesView.as_view()), diff --git a/merchant_ops/views_action_stats.py b/merchant_ops/views_action_stats.py new file mode 100644 index 0000000..1dff005 --- /dev/null +++ b/merchant_ops/views_action_stats.py @@ -0,0 +1,102 @@ +"""商家订单操作人统计 API(新接口,不影响旧统计)。""" +import logging + +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from merchant_ops.services.authz import ( + StaffAuthError, + ensure_owner_merchant, + get_active_staff_member, + is_merchant_owner, + member_has_perm, +) +from merchant_ops.services.action_stats import ( + build_operator_action_stats, + list_operator_candidates, +) +from merchant_ops.services.bootstrap import ensure_merchant_roles + +logger = logging.getLogger(__name__) + + +def _resolve_stats_merchant(request): + """商家老板,或具备 stats_view/finance_view/audit_view 的子客服。""" + 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 not ( + member_has_perm(member, 'stats_view') + or member_has_perm(member, 'finance_view') + or member_has_perm(member, 'audit_view') + ): + raise StaffAuthError(403, '无统计权限') + ensure_merchant_roles(member.merchant_id) + return member.merchant_id, member + + +def _parse_filters(data): + action_codes = data.get('action_codes') or data.get('action_code_list') + if isinstance(action_codes, str): + action_codes = [x.strip() for x in action_codes.split(',') if x.strip()] + return { + 'operator_user_id': (data.get('operator_user_id') or data.get('operator_id') or '').strip() or None, + 'operator_type': (data.get('operator_type') or '').strip() or None, + 'role_code': (data.get('role_code') or '').strip() or None, + 'action_code': data.get('action_code'), + 'action_codes': action_codes, + 'leixing_id': data.get('leixing_id'), + 'start_time': data.get('start_time') or data.get('start') or data.get('begin_time'), + 'end_time': data.get('end_time') or data.get('end'), + } + + +class StaffOrderActionStatsView(APIView): + """ + POST /yonghu/merchant-staff/order/action-stats + 按操作人/行为/时间/游戏类型统计结算·撤单·退款·罚单等操作量与金额。 + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + merchant_id, _member = _resolve_stats_merchant(request) + filters = _parse_filters(request.data or {}) + data = build_operator_action_stats(merchant_id, filters) + data['merchant_id'] = merchant_id + data['filters'] = {k: v for k, v in filters.items() if v not in (None, '', [])} + return Response({'code': 0, 'msg': 'ok', 'data': data}) + except StaffAuthError as e: + return Response({'code': e.code, 'msg': e.msg, 'data': None}) + except Exception as e: + logger.error('操作人统计失败: %s', e, exc_info=True) + return Response({'code': 99, 'msg': '统计失败', 'data': None}) + + +class StaffOrderActionOperatorsView(APIView): + """ + POST /yonghu/merchant-staff/order/action-operators + 返回可筛选的操作人候选(商家本人 + 子客服)。 + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + merchant_id, _member = _resolve_stats_merchant(request) + return Response({ + 'code': 0, + 'msg': 'ok', + 'data': { + 'merchant_id': merchant_id, + 'operators': list_operator_candidates(merchant_id), + }, + }) + except StaffAuthError as e: + return Response({'code': e.code, 'msg': e.msg, 'data': None}) + except Exception as e: + logger.error('操作人候选列表失败: %s', e, exc_info=True) + return Response({'code': 99, 'msg': '获取失败', 'data': None}) diff --git a/orders/views/admin.py b/orders/views/admin.py index aff094d..0a46c85 100644 --- a/orders/views/admin.py +++ b/orders/views/admin.py @@ -79,6 +79,8 @@ from rank.models import DashouBiaoxian, Chenghao, DingdanBiaoqian, YonghuChengha from config.models import ( ShangjiaLianjie ) +from merchant_ops.constants import ACTION_CHANGE_PLAYER +from merchant_ops.services.action_log import record_order_action_from_request @@ -1035,9 +1037,9 @@ class AdTongYiTuiKuanShangJia(APIView): if shangjia_id: try: # 查询商家用户主表 - shangjia_user = User.query.select_related('ShopProfile').get( - UserUID=shangjia_id, - #user_type='shop' + shangjia_user = User.query.select_related('ShopProfile').get( + UserUID=shangjia_id, + #user_type='shop' ) # 获取商家扩展表 @@ -1935,6 +1937,16 @@ class ZxsjghdsView(APIView): logger.info(f"商家{yonghuid}更换打手成功,旧订单{dingdan_id}已退款,新订单{new_dingdan_id}") + record_order_action_from_request( + request, + ACTION_CHANGE_PLAYER, + dingdan_id, + merchant_id=yonghuid, + amount=jine, + leixing_id=leixing_id, + remark=f'新订单:{new_dingdan_id}', + ) + return Response({ 'code': 0, 'msg': '更换成功', diff --git a/orders/views/merchant.py b/orders/views/merchant.py index 5d96775..59aff9e 100644 --- a/orders/views/merchant.py +++ b/orders/views/merchant.py @@ -56,6 +56,12 @@ from orders.utils import ( from shop.utils import calculate_pingtai_and_dianpu_shouyi, validate_shangpin_and_dianpu, update_dianpu_daily_stat from products.utils import update_shangpin_daily_stat from backend.utils import update_shangjia_daily, update_dashou_daily_by_action, pick_leixing_id, datetime_aliases, fmt_datetime +from merchant_ops.constants import ( + ACTION_SETTLE, ACTION_CANCEL, ACTION_REFUND_APPLY, ACTION_DISPATCH, ACTION_PENALTY_APPLY, +) +from merchant_ops.services.action_log import ( + record_order_action_from_request, leixing_id_from_order, +) from rank.services import record_dashou_biaoxian from rank.utils import check_dashou_biaoqian_required @@ -365,6 +371,14 @@ class ShangjiaPaifaView(APIView): logger.error(f"广播任务提交失败: {e}", exc_info=True) logger.info(f"商家派单成功: {dingdan.OrderID}, 金额={jiage}") + record_order_action_from_request( + request, + ACTION_DISPATCH, + dingdan.OrderID, + merchant_id=request.user.UserUID, + amount=jiage, + leixing_id=getattr(dingdan, 'ProductTypeID', None), + ) return Response({'code': 200, 'msg': '派单成功', 'data': {'jine': str(dingdan.Amount)}}) except Exception as e: @@ -925,6 +939,15 @@ class ShangjiaJiesuanView(APIView): if order.Platform == 2: settle_shangjia_order_guanshi_fenhong(order, jiedan_dashou_id) + record_order_action_from_request( + request, + ACTION_SETTLE, + dingdan_id, + merchant_id=yonghuid, + amount=jine, + leixing_id=leixing_id_from_order(order), + ) + # 返回成功 return Response({ 'code': 0, @@ -1076,6 +1099,16 @@ class ShangjiaChexiaoView(APIView): order_id=dingdan_id, ) + record_order_action_from_request( + request, + ACTION_CANCEL, + dingdan_id, + merchant_id=request.user.UserUID, + amount=dingdan.Amount, + leixing_id=leixing_id_from_order(dingdan), + remark=chexiao_liyou[:200] if chexiao_liyou else '', + ) + return Response({ 'code': 0, 'msg': '撤销成功', @@ -1215,6 +1248,16 @@ class ShangjiaTuikuanShenqingView(APIView): except Exception as e: logger.error(f"更新打手状态失败:{str(e)},订单{dingdan_id},打手ID{dingdan.PlayerID}") + record_order_action_from_request( + request, + ACTION_REFUND_APPLY, + dingdan_id, + merchant_id=current_user.UserUID, + amount=dingdan.Amount, + leixing_id=leixing_id_from_order(dingdan), + remark=tuikuan_liyou[:200] if tuikuan_liyou else '', + ) + return Response({ 'code': 0, 'msg': '退款申请提交成功', @@ -1385,6 +1428,18 @@ class ShangjiaChufaShenqingView(APIView): shangjia_kuozhan.PenaltyApplyStatus = 0 shangjia_kuozhan.save() + record_order_action_from_request( + request, + ACTION_PENALTY_APPLY, + dingdan_id, + merchant_id=current_user.UserUID, + amount=None, + leixing_id=leixing_id_from_order( + Order.query.filter(OrderID=dingdan_id).first() + ), + remark=(chufa_liyou or '')[:200], + ) + return Response({ 'code': 0, 'msg': '处罚申请提交成功', diff --git a/orders/views/merchant_penalty.py b/orders/views/merchant_penalty.py index eb65aea..f37b602 100644 --- a/orders/views/merchant_penalty.py +++ b/orders/views/merchant_penalty.py @@ -56,6 +56,17 @@ from orders.utils import ( from shop.utils import calculate_pingtai_and_dianpu_shouyi, validate_shangpin_and_dianpu, update_dianpu_daily_stat from products.utils import update_shangpin_daily_stat from backend.utils import update_shangjia_daily, update_dashou_daily_by_action, pick_leixing_id, datetime_aliases, fmt_datetime +from merchant_ops.constants import ( + ACTION_PENALTY_APPLY, + ACTION_PENALTY_CANCEL, + ACTION_PENALTY_MODIFY, + ACTION_PENALTY_RESUBMIT, + ACTION_REFUND_CANCEL, + ACTION_REFUND_MODIFY, +) +from merchant_ops.services.action_log import ( + record_order_action_from_request, leixing_id_from_order, +) from rank.services import record_dashou_biaoxian from rank.utils import check_dashou_biaoqian_required @@ -199,6 +210,15 @@ class ShangjiaFakuanApplyView(APIView): ]) lock_penalty_bonus(fadan) logger.info(f"商家{shangjia_id}对订单{dingdan_id}打手{dashou_id}提交罚款申请(平台审核),金额{fakuanjine}") + record_order_action_from_request( + request, + ACTION_PENALTY_APPLY, + dingdan_id, + merchant_id=shangjia_id, + amount=fakuanjine, + leixing_id=leixing_id_from_order(order), + remark=chufa_liyou[:200] if chufa_liyou else '', + ) return Response({'code': 0, 'msg': '罚款申请已提交,等待平台审核', 'data': {'fadan_id': fadan.id}}) @@ -234,9 +254,27 @@ class ShangjiaFakuaiXiugaiView(APIView): # 处理订单相关操作 if operation == 'cancel_refund': - return self._cancel_refund(dingdan, request.data.get('liyou', '')) + resp = self._cancel_refund(dingdan, request.data.get('liyou', '')) + if getattr(resp, 'data', {}).get('code') == 0: + record_order_action_from_request( + request, ACTION_REFUND_CANCEL, dingdan_id, + merchant_id=user.UserUID, + amount=getattr(dingdan, 'Amount', None), + leixing_id=leixing_id_from_order(dingdan), + remark=str(request.data.get('liyou') or '')[:200], + ) + return resp elif operation == 'modify_refund_reason': - return self._modify_refund_reason(dingdan, request.data.get('liyou', '')) + resp = self._modify_refund_reason(dingdan, request.data.get('liyou', '')) + if getattr(resp, 'data', {}).get('code') == 0: + record_order_action_from_request( + request, ACTION_REFUND_MODIFY, dingdan_id, + merchant_id=user.UserUID, + amount=getattr(dingdan, 'Amount', None), + leixing_id=leixing_id_from_order(dingdan), + remark=str(request.data.get('liyou') or '')[:200], + ) + return resp # 处理罚款相关操作 fadan_id = request.data.get('fadan_id') @@ -250,11 +288,38 @@ class ShangjiaFakuaiXiugaiView(APIView): return Response({'code': 403, 'msg': '罚单与订单不匹配'}, status=status.HTTP_403_FORBIDDEN) if operation == 'modify_penalty': - return self._modify_penalty(fadan, request.data.get('liyou', ''), request.data.get('jine'), user.UserUID) + resp = self._modify_penalty(fadan, request.data.get('liyou', ''), request.data.get('jine'), user.UserUID) + if getattr(resp, 'data', {}).get('code') == 0: + record_order_action_from_request( + request, ACTION_PENALTY_MODIFY, dingdan_id, + merchant_id=user.UserUID, + amount=request.data.get('jine') or getattr(fadan, 'FineAmount', None), + leixing_id=leixing_id_from_order(dingdan), + remark=str(request.data.get('liyou') or '')[:200], + ) + return resp elif operation == 'cancel_penalty': - return self._cancel_penalty(fadan, request.data.get('liyou', ''), user.UserUID) + resp = self._cancel_penalty(fadan, request.data.get('liyou', ''), user.UserUID) + if getattr(resp, 'data', {}).get('code') == 0: + record_order_action_from_request( + request, ACTION_PENALTY_CANCEL, dingdan_id, + merchant_id=user.UserUID, + amount=getattr(fadan, 'FineAmount', None), + leixing_id=leixing_id_from_order(dingdan), + remark=str(request.data.get('liyou') or '')[:200], + ) + return resp elif operation == 'resubmit_penalty': - return self._resubmit_penalty(fadan, request.data.get('liyou', ''), request.data.get('jine'), user.UserUID) + resp = self._resubmit_penalty(fadan, request.data.get('liyou', ''), request.data.get('jine'), user.UserUID) + if getattr(resp, 'data', {}).get('code') == 0: + record_order_action_from_request( + request, ACTION_PENALTY_RESUBMIT, dingdan_id, + merchant_id=user.UserUID, + amount=request.data.get('jine') or getattr(fadan, 'FineAmount', None), + leixing_id=leixing_id_from_order(dingdan), + remark=str(request.data.get('liyou') or '')[:200], + ) + return resp elif operation == 'manage_penalty_evidence': return self._manage_penalty_evidence(fadan, request.data) else: