From dac6193d5b670b055f7a7e3ca9cfd4d0d75db939 Mon Sep 17 00:00:00 2001 From: XingQue Date: Thu, 16 Jul 2026 00:00:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(merchant):=20=E6=93=8D=E4=BD=9C=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E6=8C=89=E8=AE=A2=E5=8D=95=E5=8E=BB=E9=87=8D=E5=B9=B6?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E8=81=8C=E4=BD=8D=E5=A4=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一行为对同一订单只计1单金额取Max;补充 total_order_count/action_count;人员候选展示职位与商家备注。 Co-authored-by: Cursor --- merchant_ops/services/action_stats.py | 169 +++++++++++++++++++------- 1 file changed, 128 insertions(+), 41 deletions(-) diff --git a/merchant_ops/services/action_stats.py b/merchant_ops/services/action_stats.py index bdcdd16..9f2f989 100644 --- a/merchant_ops/services/action_stats.py +++ b/merchant_ops/services/action_stats.py @@ -1,8 +1,10 @@ """按真实操作人聚合商家订单操作流水统计。""" +from collections import defaultdict from datetime import datetime from decimal import Decimal -from django.db.models import Count, Sum, Q +from django.conf import settings +from django.db.models import Count, Max, Sum from django.utils import timezone from merchant_ops.constants import ( @@ -34,9 +36,7 @@ def _parse_dt(value, end_of_day=False): if dt is None: return None - from django.conf import settings use_tz = bool(getattr(settings, 'USE_TZ', False)) - if use_tz: if timezone.is_naive(dt): dt = timezone.make_aware(dt, timezone.get_current_timezone()) @@ -52,6 +52,15 @@ def _money(val): return f'{Decimal(str(val)):.2f}' +def _dec(val): + if val is None: + return Decimal('0') + try: + return Decimal(str(val)) + except Exception: + return Decimal('0') + + def build_action_stats_queryset( merchant_id, *, @@ -93,47 +102,92 @@ def build_action_stats_queryset( return qs +def _aggregate_unique_orders(qs, group_fields): + """ + 同一分组下:同一订单同一行为只计 1 单、金额取该订单该行为记录的 Max(amount)。 + 返回: {group_tuple: {'order_count', 'action_count', 'amount'}} + """ + # 操作次数(含重复) + action_cnt_map = {} + for row in qs.values(*group_fields).annotate(cnt=Count('id')): + key = tuple(row[f] for f in group_fields) + action_cnt_map[key] = int(row['cnt'] or 0) + + # 订单去重:每个 (group..., order_id) 只留一笔金额 + unique_rows = qs.values(*group_fields, 'order_id').annotate(one_amt=Max('amount')) + result = defaultdict(lambda: { + 'order_count': 0, + 'action_count': 0, + 'amount': Decimal('0'), + }) + for row in unique_rows: + key = tuple(row[f] for f in group_fields) + result[key]['order_count'] += 1 + result[key]['amount'] += _dec(row['one_amt']) + + for key, meta in result.items(): + meta['action_count'] = action_cnt_map.get(key, 0) + # 只有重复操作、但 unique 漏掉的分组也补上(理论上 unique 覆盖全部) + for key, cnt in action_cnt_map.items(): + if key not in result: + result[key] = { + 'order_count': 0, + 'action_count': cnt, + 'amount': Decimal('0'), + } + return result + + 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') - ) + action_total = qs.aggregate(cnt=Count('id')) + order_total = qs.values('order_id').distinct().count() + + # 总额:先按 (action_code, order_id) 去重取 Max(amount),再按订单取各行为中金额的? + # 用户关心订单量去重;总额对「全部」按 (action, order) 去重后的金额求和(不同行为各自算一笔金额) + unique_amt_rows = qs.values('action_code', 'order_id').annotate(one_amt=Max('amount')) + total_amount = sum((_dec(r['one_amt']) for r in unique_amt_rows), Decimal('0')) + + by_action_map = _aggregate_unique_orders(qs, ['action_code']) by_action = [] - for row in by_action_rows: - code = int(row['action_code']) + for key in sorted(by_action_map.keys(), key=lambda k: k[0] if k[0] is not None else 0): + code = int(key[0]) + meta = by_action_map[key] 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']), + 'order_count': meta['order_count'], + 'count': meta['order_count'], # 兼容旧字段:对外主展示订单量 + 'action_count': meta['action_count'], + 'amount': _money(meta['amount']), }) - by_leixing_rows = ( - qs.exclude(leixing_id__isnull=True) - .values('leixing_id') - .annotate(cnt=Count('id'), amt=Sum('amount')) - .order_by('-cnt') + by_leixing_map = _aggregate_unique_orders( + qs.exclude(leixing_id__isnull=True), + ['leixing_id'], ) - leixing_ids = [r['leixing_id'] for r in by_leixing_rows if r['leixing_id'] is not None] + leixing_ids = [k[0] for k in by_leixing_map.keys() if k[0] 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'] + for key, meta in sorted(by_leixing_map.items(), key=lambda x: -x[1]['order_count']): + lid = key[0] by_leixing.append({ 'leixing_id': lid, 'leixing_name': name_map.get(lid, str(lid)), - 'count': int(row['cnt'] or 0), - 'amount': _money(row['amt']), + 'order_count': meta['order_count'], + 'count': meta['order_count'], + 'action_count': meta['action_count'], + 'amount': _money(meta['amount']), }) return { - 'total_count': int(total['cnt'] or 0), - 'total_amount': _money(total['amt']), + 'total_order_count': int(order_total or 0), + 'total_count': int(order_total or 0), # 主数字=去重订单量 + 'total_action_count': int(action_total['cnt'] or 0), + 'total_amount': _money(total_amount), 'by_action': by_action, 'by_leixing': by_leixing, } @@ -143,22 +197,44 @@ 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_map = _aggregate_unique_orders( + qs, + ['operator_user_id', 'operator_type', 'role_code', 'role_name'], ) + + # 补职位备注 + staff_uids = [ + k[0] for k in people_map.keys() + if k[1] == ACTOR_STAFF + ] + remark_map = {} + name_map = {} + if staff_uids: + for m in MerchantStaffMember.objects.filter( + merchant_id=str(merchant_id), + staff_user_id__in=staff_uids, + status=1, + ).select_related('role'): + remark_map[m.staff_user_id] = m.merchant_remark or '' + name_map[m.staff_user_id] = m.display_name or '' + people = [] - for row in people_rows: + for key, meta in sorted(people_map.items(), key=lambda x: -x[1]['order_count']): + uid, op_type, role_code, role_name = key + role_name = role_name or ('商家本人' if op_type == ACTOR_MERCHANT else '') + remark = remark_map.get(uid, '') if op_type == ACTOR_STAFF else '' + display = name_map.get(uid, '') if op_type == ACTOR_STAFF else '商家本人' 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']), + 'operator_user_id': uid, + 'operator_type': op_type, + 'role_code': role_code or '', + 'role_name': role_name, + 'merchant_remark': remark, + 'display_name': display, + 'order_count': meta['order_count'], + 'count': meta['order_count'], + 'action_count': meta['action_count'], + 'amount': _money(meta['amount']), }) return { @@ -172,12 +248,13 @@ def build_operator_action_stats(merchant_id, filters): def list_operator_candidates(merchant_id): - """经营数据页可选身份:商家本人 + 子客服列表。""" + """经营数据页可选身份:商家本人 + 子客服(展示职位+备注)。""" items = [{ 'operator_user_id': str(merchant_id), 'operator_type': ACTOR_MERCHANT, 'role_code': 'OWNER', 'role_name': '商家本人', + 'merchant_remark': '', 'display_name': '商家本人', 'staff_member_id': None, }] @@ -188,12 +265,22 @@ def list_operator_candidates(merchant_id): .order_by('id') ) for m in members: + role_name = m.role.role_name if m.role else '子客服' + remark = (m.merchant_remark or '').strip() + nick = (m.display_name or '').strip() + parts = [role_name] + if remark: + parts.append(f'备注:{remark}') + elif nick: + parts.append(nick) + parts.append(m.staff_user_id) 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, + 'role_name': role_name, + 'merchant_remark': remark, + 'display_name': ' · '.join(parts), 'staff_member_id': m.id, }) return items