"""按真实操作人聚合商家订单操作流水统计。""" from collections import defaultdict from datetime import datetime from decimal import Decimal from django.conf import settings from django.db.models import Count, Max, Sum 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): """解析时间;USE_TZ=False 时必须返回 naive datetime(MySQL)。""" if value is None or value == '': return None dt = None if isinstance(value, datetime): dt = value else: 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) break except ValueError: continue if dt is None: return None 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()) else: if timezone.is_aware(dt): dt = timezone.make_naive(dt, timezone.get_current_timezone()) return dt def _money(val): if val is None: return '0.00' 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, *, 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 _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): 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 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})'), 'order_count': meta['order_count'], 'count': meta['order_count'], # 兼容旧字段:对外主展示订单量 'action_count': meta['action_count'], 'amount': _money(meta['amount']), }) by_leixing_map = _aggregate_unique_orders( qs.exclude(leixing_id__isnull=True), ['leixing_id'], ) 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 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)), 'order_count': meta['order_count'], 'count': meta['order_count'], 'action_count': meta['action_count'], 'amount': _money(meta['amount']), }) return { '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, } def build_operator_action_stats(merchant_id, filters): qs = build_action_stats_queryset(merchant_id, **filters) summary = summarize_action_logs(qs) 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 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': 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 { **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': '商家本人', 'merchant_remark': '', '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: 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': role_name, 'merchant_remark': remark, 'display_name': ' · '.join(parts), 'staff_member_id': m.id, }) return items