statistics_service.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. from sqlalchemy.orm import Session
  2. from sqlalchemy import func, desc, and_, case
  3. from datetime import datetime, timedelta, date
  4. from typing import Dict, Any
  5. from app.models.order import VasOrder
  6. from app.models.ticket import VasTicket
  7. from app.models.vas_task import VasTask
  8. from app.models.user import VasUser
  9. from app.models.product import VasProduct
  10. # 静态汇率配置 (基准: CNY)
  11. # 实际生产环境建议从数据库或缓存获取实时汇率
  12. EXCHANGE_RATES = {
  13. "CNY": 1.0,
  14. "USD": 7.25,
  15. "EUR": 7.65,
  16. "GBP": 9.10,
  17. "HKD": 0.92,
  18. "JPY": 0.048
  19. }
  20. CURRENCY_SYMBOLS = {
  21. "CNY": "¥", "USD": "$", "EUR": "€", "GBP": "£", "HKD": "HK$", "JPY": "¥"
  22. }
  23. class StatisticsService:
  24. @staticmethod
  25. def _convert_to_cny(amount: any, currency: str) -> int:
  26. """
  27. 辅助函数:将金额(分)转换为人民币(分)
  28. """
  29. if not amount:
  30. return 0
  31. rate = EXCHANGE_RATES.get(currency, 1.0) # 未知货币默认按 1:1 处理
  32. # 修复点:将 amount 转换为 float,解决 Decimal * float 报错的问题
  33. return int(float(amount) * rate)
  34. @staticmethod
  35. def overview(db: Session) -> Dict[str, Any]:
  36. """
  37. 获取后台概览数据 (统一换算为 CNY 统计)
  38. """
  39. # --- 1. 核心指标卡片 ---
  40. # 1.1 总营收 (按币种分组求和,再换算)
  41. revenue_groups = db.query(
  42. VasOrder.base_currency,
  43. func.sum(VasOrder.base_amount)
  44. ).filter(
  45. VasOrder.status.in_(['paid', 'completed', 'succeeded'])
  46. ).group_by(VasOrder.base_currency).all()
  47. total_revenue_cny = 0
  48. for currency, amount in revenue_groups:
  49. total_revenue_cny += StatisticsService._convert_to_cny(amount, currency)
  50. # 1.2 活跃订单数
  51. total_orders = db.query(func.count(VasOrder.id))\
  52. .filter(VasOrder.status != 'closed')\
  53. .scalar() or 0
  54. # 1.3 活跃用户数
  55. active_users = db.query(func.count(VasUser.id)).scalar() or 0
  56. # 1.4 待处理工单
  57. pending_tickets = db.query(func.count(VasTicket.id))\
  58. .filter(VasTicket.status.in_(['pending', 'info_required']))\
  59. .scalar() or 0
  60. # 1.5 任务成功率
  61. task_counts = db.query(
  62. func.count(VasTask.id).label('total'),
  63. func.sum(case((VasTask.status == 'completed', 1), else_=0)).label('success')
  64. ).first()
  65. success_rate_str = "0%"
  66. if task_counts and task_counts.total > 0:
  67. rate = (task_counts.success / task_counts.total) * 100
  68. success_rate_str = f"{rate:.1f}%"
  69. # --- 2. 营收趋势图 (Last 7 Days) ---
  70. revenue_trend = []
  71. today = date.today()
  72. for i in range(6, -1, -1):
  73. target_date = today - timedelta(days=i)
  74. start_dt = datetime.combine(target_date, datetime.min.time())
  75. end_dt = datetime.combine(target_date, datetime.max.time())
  76. # 按币种分组查询当天的营收
  77. daily_groups = db.query(
  78. VasOrder.base_currency,
  79. func.sum(VasOrder.base_amount).label('amount'),
  80. func.count(VasOrder.id).label('orders')
  81. ).filter(
  82. VasOrder.created_at >= start_dt,
  83. VasOrder.created_at <= end_dt,
  84. VasOrder.status.in_(['paid', 'completed', 'succeeded'])
  85. ).group_by(VasOrder.base_currency).all()
  86. daily_amount_cny = 0
  87. daily_order_count = 0
  88. for curr, amt, cnt in daily_groups:
  89. daily_amount_cny += StatisticsService._convert_to_cny(amt, curr)
  90. daily_order_count += cnt
  91. revenue_trend.append({
  92. "date": target_date.strftime("%m-%d"),
  93. "amount": float(daily_amount_cny) / 100.0, # 转为元 (浮点数)
  94. "orders": daily_order_count
  95. })
  96. # --- 3. 商品销量分布 ---
  97. product_stats = db.query(
  98. VasProduct.title,
  99. func.count(VasOrder.id).label('count')
  100. ).join(VasOrder, VasOrder.product_id == VasProduct.id)\
  101. .filter(VasOrder.status.in_(['paid', 'completed', 'succeeded']))\
  102. .group_by(VasProduct.title)\
  103. .order_by(desc('count'))\
  104. .limit(5).all()
  105. product_dist = [{"name": p.title, "value": p.count} for p in product_stats]
  106. # --- 4. 最新动态 ---
  107. activities = []
  108. # 订单动态
  109. recent_orders = db.query(VasOrder).order_by(desc(VasOrder.created_at)).limit(5).all()
  110. for o in recent_orders:
  111. symbol = CURRENCY_SYMBOLS.get(o.base_currency, o.base_currency)
  112. amt_display = f"{symbol}{o.base_amount / 100}"
  113. activities.append({
  114. "id": f"order_{o.id}",
  115. "text": f"用户下单: {o.product_name or '未知商品'} ({amt_display})",
  116. "time": o.created_at,
  117. "type": "order" if o.status == 'pending' else "money"
  118. })
  119. # 工单动态
  120. recent_tickets = db.query(VasTicket).order_by(desc(VasTicket.created_at)).limit(5).all()
  121. for t in recent_tickets:
  122. reason_preview = t.reason[:20] + "..." if len(t.reason) > 20 else t.reason
  123. activities.append({
  124. "id": f"ticket_{t.id}",
  125. "text": f"新工单 #{t.id}: {reason_preview}",
  126. "time": t.created_at,
  127. "type": "ticket"
  128. })
  129. # 排序与时间格式化
  130. activities.sort(key=lambda x: x['time'], reverse=True)
  131. activities = activities[:10]
  132. now = datetime.now()
  133. for act in activities:
  134. dt = act['time']
  135. if not isinstance(dt, datetime):
  136. continue
  137. diff = now - dt
  138. if diff.days > 0:
  139. time_str = f"{diff.days}天前"
  140. elif diff.seconds > 3600:
  141. time_str = f"{diff.seconds // 3600}小时前"
  142. elif diff.seconds > 60:
  143. time_str = f"{diff.seconds // 60}分钟前"
  144. else:
  145. time_str = "刚刚"
  146. act['time'] = time_str
  147. return {
  148. "stats": {
  149. "totalOrders": total_orders,
  150. "totalRevenue": total_revenue_cny, # 单位:分 (CNY)
  151. "activeUsers": active_users,
  152. "pendingTickets": pending_tickets,
  153. "successRate": success_rate_str
  154. },
  155. "revenue_trend": revenue_trend,
  156. "product_dist": product_dist,
  157. "recent_activities": activities
  158. }