fix(merchant): 操作统计按订单去重并返回职位备注
同一行为对同一订单只计1单金额取Max;补充 total_order_count/action_count;人员候选展示职位与商家备注。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
"""按真实操作人聚合商家订单操作流水统计。"""
|
"""按真实操作人聚合商家订单操作流水统计。"""
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
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 django.utils import timezone
|
||||||
|
|
||||||
from merchant_ops.constants import (
|
from merchant_ops.constants import (
|
||||||
@@ -34,9 +36,7 @@ def _parse_dt(value, end_of_day=False):
|
|||||||
if dt is None:
|
if dt is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
use_tz = bool(getattr(settings, 'USE_TZ', False))
|
use_tz = bool(getattr(settings, 'USE_TZ', False))
|
||||||
|
|
||||||
if use_tz:
|
if use_tz:
|
||||||
if timezone.is_naive(dt):
|
if timezone.is_naive(dt):
|
||||||
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
||||||
@@ -52,6 +52,15 @@ def _money(val):
|
|||||||
return f'{Decimal(str(val)):.2f}'
|
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(
|
def build_action_stats_queryset(
|
||||||
merchant_id,
|
merchant_id,
|
||||||
*,
|
*,
|
||||||
@@ -93,47 +102,92 @@ def build_action_stats_queryset(
|
|||||||
return qs
|
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):
|
def summarize_action_logs(qs):
|
||||||
total = qs.aggregate(cnt=Count('id'), amt=Sum('amount'))
|
action_total = qs.aggregate(cnt=Count('id'))
|
||||||
by_action_rows = (
|
order_total = qs.values('order_id').distinct().count()
|
||||||
qs.values('action_code')
|
|
||||||
.annotate(cnt=Count('id'), amt=Sum('amount'))
|
# 总额:先按 (action_code, order_id) 去重取 Max(amount),再按订单取各行为中金额的?
|
||||||
.order_by('action_code')
|
# 用户关心订单量去重;总额对「全部」按 (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 = []
|
by_action = []
|
||||||
for row in by_action_rows:
|
for key in sorted(by_action_map.keys(), key=lambda k: k[0] if k[0] is not None else 0):
|
||||||
code = int(row['action_code'])
|
code = int(key[0])
|
||||||
|
meta = by_action_map[key]
|
||||||
by_action.append({
|
by_action.append({
|
||||||
'action_code': code,
|
'action_code': code,
|
||||||
'action_label': ORDER_ACTION_LABELS.get(code, f'未知({code})'),
|
'action_label': ORDER_ACTION_LABELS.get(code, f'未知({code})'),
|
||||||
'count': int(row['cnt'] or 0),
|
'order_count': meta['order_count'],
|
||||||
'amount': _money(row['amt']),
|
'count': meta['order_count'], # 兼容旧字段:对外主展示订单量
|
||||||
|
'action_count': meta['action_count'],
|
||||||
|
'amount': _money(meta['amount']),
|
||||||
})
|
})
|
||||||
|
|
||||||
by_leixing_rows = (
|
by_leixing_map = _aggregate_unique_orders(
|
||||||
qs.exclude(leixing_id__isnull=True)
|
qs.exclude(leixing_id__isnull=True),
|
||||||
.values('leixing_id')
|
['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]
|
leixing_ids = [k[0] for k in by_leixing_map.keys() if k[0] is not None]
|
||||||
name_map = {}
|
name_map = {}
|
||||||
if leixing_ids:
|
if leixing_ids:
|
||||||
for lx in ShangpinLeixing.query.filter(id__in=leixing_ids).only('id', 'jieshao'):
|
for lx in ShangpinLeixing.query.filter(id__in=leixing_ids).only('id', 'jieshao'):
|
||||||
name_map[lx.id] = getattr(lx, 'jieshao', '') or str(lx.id)
|
name_map[lx.id] = getattr(lx, 'jieshao', '') or str(lx.id)
|
||||||
|
|
||||||
by_leixing = []
|
by_leixing = []
|
||||||
for row in by_leixing_rows:
|
for key, meta in sorted(by_leixing_map.items(), key=lambda x: -x[1]['order_count']):
|
||||||
lid = row['leixing_id']
|
lid = key[0]
|
||||||
by_leixing.append({
|
by_leixing.append({
|
||||||
'leixing_id': lid,
|
'leixing_id': lid,
|
||||||
'leixing_name': name_map.get(lid, str(lid)),
|
'leixing_name': name_map.get(lid, str(lid)),
|
||||||
'count': int(row['cnt'] or 0),
|
'order_count': meta['order_count'],
|
||||||
'amount': _money(row['amt']),
|
'count': meta['order_count'],
|
||||||
|
'action_count': meta['action_count'],
|
||||||
|
'amount': _money(meta['amount']),
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'total_count': int(total['cnt'] or 0),
|
'total_order_count': int(order_total or 0),
|
||||||
'total_amount': _money(total['amt']),
|
'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_action': by_action,
|
||||||
'by_leixing': by_leixing,
|
'by_leixing': by_leixing,
|
||||||
}
|
}
|
||||||
@@ -143,22 +197,44 @@ def build_operator_action_stats(merchant_id, filters):
|
|||||||
qs = build_action_stats_queryset(merchant_id, **filters)
|
qs = build_action_stats_queryset(merchant_id, **filters)
|
||||||
summary = summarize_action_logs(qs)
|
summary = summarize_action_logs(qs)
|
||||||
|
|
||||||
people_rows = (
|
people_map = _aggregate_unique_orders(
|
||||||
qs.values('operator_user_id', 'operator_type', 'role_code', 'role_name')
|
qs,
|
||||||
.annotate(cnt=Count('id'), amt=Sum('amount'))
|
['operator_user_id', 'operator_type', 'role_code', 'role_name'],
|
||||||
.order_by('-cnt')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 补职位备注
|
||||||
|
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 = []
|
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({
|
people.append({
|
||||||
'operator_user_id': row['operator_user_id'],
|
'operator_user_id': uid,
|
||||||
'operator_type': row['operator_type'],
|
'operator_type': op_type,
|
||||||
'role_code': row['role_code'] or '',
|
'role_code': role_code or '',
|
||||||
'role_name': row['role_name'] or (
|
'role_name': role_name,
|
||||||
'商家本人' if row['operator_type'] == ACTOR_MERCHANT else ''
|
'merchant_remark': remark,
|
||||||
),
|
'display_name': display,
|
||||||
'count': int(row['cnt'] or 0),
|
'order_count': meta['order_count'],
|
||||||
'amount': _money(row['amt']),
|
'count': meta['order_count'],
|
||||||
|
'action_count': meta['action_count'],
|
||||||
|
'amount': _money(meta['amount']),
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -172,12 +248,13 @@ def build_operator_action_stats(merchant_id, filters):
|
|||||||
|
|
||||||
|
|
||||||
def list_operator_candidates(merchant_id):
|
def list_operator_candidates(merchant_id):
|
||||||
"""经营数据页可选身份:商家本人 + 子客服列表。"""
|
"""经营数据页可选身份:商家本人 + 子客服(展示职位+备注)。"""
|
||||||
items = [{
|
items = [{
|
||||||
'operator_user_id': str(merchant_id),
|
'operator_user_id': str(merchant_id),
|
||||||
'operator_type': ACTOR_MERCHANT,
|
'operator_type': ACTOR_MERCHANT,
|
||||||
'role_code': 'OWNER',
|
'role_code': 'OWNER',
|
||||||
'role_name': '商家本人',
|
'role_name': '商家本人',
|
||||||
|
'merchant_remark': '',
|
||||||
'display_name': '商家本人',
|
'display_name': '商家本人',
|
||||||
'staff_member_id': None,
|
'staff_member_id': None,
|
||||||
}]
|
}]
|
||||||
@@ -188,12 +265,22 @@ def list_operator_candidates(merchant_id):
|
|||||||
.order_by('id')
|
.order_by('id')
|
||||||
)
|
)
|
||||||
for m in members:
|
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({
|
items.append({
|
||||||
'operator_user_id': m.staff_user_id,
|
'operator_user_id': m.staff_user_id,
|
||||||
'operator_type': ACTOR_STAFF,
|
'operator_type': ACTOR_STAFF,
|
||||||
'role_code': m.role.role_code if m.role else '',
|
'role_code': m.role.role_code if m.role else '',
|
||||||
'role_name': m.role.role_name if m.role else '',
|
'role_name': role_name,
|
||||||
'display_name': m.display_name or m.merchant_remark or m.staff_user_id,
|
'merchant_remark': remark,
|
||||||
|
'display_name': ' · '.join(parts),
|
||||||
'staff_member_id': m.id,
|
'staff_member_id': m.id,
|
||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
|
|||||||
Reference in New Issue
Block a user