Files
Django/merchant_ops/services/action_stats.py
XingQue 477129de46 fix(merchant): 操作人统计时间筛选兼容 USE_TZ=False
MySQL 在关闭 USE_TZ 时不接受 timezone-aware datetime,解析为 naive 再查询。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 23:53:52 +08:00

200 lines
6.1 KiB
Python
Raw 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 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 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
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