Files
Django/merchant_ops/services/action_stats.py
XingQue dac6193d5b fix(merchant): 操作统计按订单去重并返回职位备注
同一行为对同一订单只计1单金额取Max;补充 total_order_count/action_count;人员候选展示职位与商家备注。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 00:00:07 +08:00

287 lines
9.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""按真实操作人聚合商家订单操作流水统计。"""
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 datetimeMySQL"""
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