同一行为对同一订单只计1单金额取Max;补充 total_order_count/action_count;人员候选展示职位与商家备注。 Co-authored-by: Cursor <cursoragent@cursor.com>
287 lines
9.6 KiB
Python
287 lines
9.6 KiB
Python
"""按真实操作人聚合商家订单操作流水统计。"""
|
||
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
|