MySQL 在关闭 USE_TZ 时不接受 timezone-aware datetime,解析为 naive 再查询。 Co-authored-by: Cursor <cursoragent@cursor.com>
200 lines
6.1 KiB
Python
200 lines
6.1 KiB
Python
"""按真实操作人聚合商家订单操作流水统计。"""
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
|
||
from django.db.models import Count, Sum, Q
|
||
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
|
||
|
||
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())
|
||
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 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 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')
|
||
)
|
||
by_action = []
|
||
for row in by_action_rows:
|
||
code = int(row['action_code'])
|
||
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']),
|
||
})
|
||
|
||
by_leixing_rows = (
|
||
qs.exclude(leixing_id__isnull=True)
|
||
.values('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]
|
||
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']
|
||
by_leixing.append({
|
||
'leixing_id': lid,
|
||
'leixing_name': name_map.get(lid, str(lid)),
|
||
'count': int(row['cnt'] or 0),
|
||
'amount': _money(row['amt']),
|
||
})
|
||
|
||
return {
|
||
'total_count': int(total['cnt'] or 0),
|
||
'total_amount': _money(total['amt']),
|
||
'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_rows = (
|
||
qs.values('operator_user_id', 'operator_type', 'role_code', 'role_name')
|
||
.annotate(cnt=Count('id'), amt=Sum('amount'))
|
||
.order_by('-cnt')
|
||
)
|
||
people = []
|
||
for row in people_rows:
|
||
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']),
|
||
})
|
||
|
||
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': '商家本人',
|
||
'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:
|
||
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,
|
||
'staff_member_id': m.id,
|
||
})
|
||
return items
|