532 lines
22 KiB
Python
532 lines
22 KiB
Python
"""shop.views.shop_order_views - 店铺订单统计与管理视图(含 OSS 图片批量辅助函数)."""
|
||
# ==================== 标准库 ====================
|
||
import io
|
||
import json
|
||
import time
|
||
import random
|
||
import string
|
||
import hmac
|
||
import hashlib
|
||
import requests
|
||
import logging
|
||
import traceback
|
||
import calendar
|
||
import threading
|
||
import xmltodict
|
||
from decimal import Decimal
|
||
from collections import defaultdict
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
|
||
from django.conf import settings
|
||
from django.db import models, transaction, IntegrityError
|
||
from django.db.models import F, Sum, Count, Q, Prefetch
|
||
from gvsdsdk.fluent import db, func, FQ
|
||
from django.core.cache import cache
|
||
from django.core.paginator import Paginator, EmptyPage
|
||
from django.core.exceptions import ObjectDoesNotExist
|
||
from django.utils import timezone
|
||
from django.http import HttpResponse
|
||
|
||
from rest_framework.views import APIView
|
||
from rest_framework.response import Response
|
||
from rest_framework import status
|
||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||
from rest_framework_simplejwt.tokens import RefreshToken
|
||
|
||
from utils.oss_utils import upload_to_oss, delete_from_oss, get_oss_client
|
||
from utils.money import yuan_to_fen
|
||
|
||
from ..utils import verify_shop_permission
|
||
from shop.utils import verify_shop_permission, update_dianpu_daily_stat
|
||
from products.utils import update_shangpin_daily_stat
|
||
from orders.utils import update_daily_payout
|
||
from backend.utils import update_dashou_daily_by_action
|
||
|
||
from ..models import YonghuPingzheng, Dianpu, DianpuShouzhiMeiriTongji
|
||
|
||
from users.models import UserDashou, UserShangjia, UserGuanshi, UserZuzhang
|
||
from users.business_models import User
|
||
|
||
from shop.models import YonghuDianpuBangding, ShangpinLeixingDianpu, DianpuShangpinShenheShezhi
|
||
|
||
from products.models import ShangpinLeixing, ShangpinZhuanqu, Shangpin, Huiyuan
|
||
|
||
from orders.models import (
|
||
Order, PlatformOrderExt, PlayerDeliveryImage, RefundRecord,
|
||
OrderPlayerHistory, Penalty
|
||
)
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class ShopOrderStatisticsView(APIView):
|
||
"""
|
||
店铺订单统计接口
|
||
URL: POST /shangdian/ddltj
|
||
权限: JWT认证 + 店铺身份验证
|
||
|
||
请求参数:
|
||
userId (必填): 店铺所属用户的ID
|
||
|
||
返回结构:
|
||
{
|
||
"code": 200,
|
||
"msg": "统计成功",
|
||
"data": {
|
||
"types": [ # 所有有效商品类型
|
||
{
|
||
"id": 类型ID,
|
||
"jieshao": "类型介绍",
|
||
"tupian_url": "类型图片相对路径",
|
||
"order_count": 该类型订单总数,
|
||
"status_counts": { # 该类型下各状态订单数量
|
||
"1": 数量,
|
||
"7": 数量,
|
||
"2": 数量,
|
||
"8": 数量,
|
||
"3": 数量,
|
||
"4": 数量,
|
||
"5": 数量,
|
||
"6": 数量
|
||
}
|
||
},
|
||
...
|
||
],
|
||
"status_counts": { # 全局各状态订单总数
|
||
"1": 总数,
|
||
"7": 总数,
|
||
"2": 总数,
|
||
"8": 总数,
|
||
"3": 总数,
|
||
"4": 总数,
|
||
"5": 总数,
|
||
"6": 总数
|
||
},
|
||
"total_orders": 订单总量
|
||
}
|
||
}
|
||
"""
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def post(self, request):
|
||
# 1. 身份验证
|
||
user_id = request.data.get('userId', '').strip()
|
||
if not user_id:
|
||
return Response({'code': 400, 'msg': '缺少 userId'})
|
||
|
||
dianpu, error = verify_shop_permission(request, user_id)
|
||
if error:
|
||
return error
|
||
|
||
shop_id = dianpu.id
|
||
|
||
try:
|
||
# 2. 获取所有正常状态(审核通过)的商品类型
|
||
types_qs = ShangpinLeixing.query.filter(
|
||
shenhezhuangtai=1
|
||
).order_by('-paixu', 'id')
|
||
|
||
type_list = []
|
||
type_id_map = {}
|
||
for idx, t in enumerate(types_qs):
|
||
item = {
|
||
'id': t.id,
|
||
'jieshao': t.jieshao or '',
|
||
'tupian_url': t.tupian_url or '',
|
||
'order_count': 0,
|
||
'status_counts': {
|
||
'1': 0, '7': 0, '2': 0, '8': 0,
|
||
'3': 0, '4': 0, '5': 0, '6': 0
|
||
}
|
||
}
|
||
type_list.append(item)
|
||
type_id_map[t.id] = idx
|
||
|
||
# 3. 获取本店铺所有订单的主键ID列表(通过平台扩展表)
|
||
order_pks = list(
|
||
PlatformOrderExt.query.filter(dianpu_id=shop_id)
|
||
.values_list('Order_id', flat=True) # 这里取的是订单主表的主键id
|
||
)
|
||
|
||
total_orders = 0
|
||
global_counts = defaultdict(int)
|
||
|
||
if order_pks:
|
||
# 4. 用主键列表进行聚合统计,按商品类型和订单状态分组
|
||
aggregation = (
|
||
Order.objects
|
||
.filter(id__in=order_pks) # 修正点:使用id__in,而不是dingdan_id__in
|
||
.values('ProductTypeID', 'Status')
|
||
.annotate(cnt=Count('id'))
|
||
)
|
||
|
||
for row in aggregation:
|
||
lid = row['ProductTypeID'] # 商品类型ID
|
||
st = row['Status'] # 订单状态码
|
||
cnt = row['cnt']
|
||
|
||
# 转为字符串键,方便前端展示
|
||
st_key = str(st)
|
||
|
||
# 累加总量
|
||
total_orders += cnt
|
||
global_counts[st_key] += cnt
|
||
|
||
# 填充到对应商品类型
|
||
if lid in type_id_map:
|
||
idx = type_id_map[lid]
|
||
type_list[idx]['order_count'] += cnt
|
||
if st_key in type_list[idx]['status_counts']:
|
||
type_list[idx]['status_counts'][st_key] += cnt
|
||
|
||
# 5. 确保全局状态返回所有8个键,即使值为0
|
||
all_status_keys = ['1', '7', '2', '8', '3', '4', '5', '6']
|
||
final_global_counts = {
|
||
k: global_counts.get(k, 0) for k in all_status_keys
|
||
}
|
||
|
||
# 6. 返回统计结果
|
||
return Response({
|
||
'code': 200,
|
||
'msg': '统计成功',
|
||
'data': {
|
||
'types': type_list,
|
||
'status_counts': final_global_counts,
|
||
'total_orders': total_orders
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.exception("订单统计接口异常")
|
||
return Response({
|
||
'code': 500,
|
||
'msg': '统计失败,服务器内部错误'
|
||
})
|
||
|
||
# ================================================================
|
||
# 辅助函数:获取OSS域名(用于拼接本地图片绝对URL)
|
||
# ================================================================
|
||
def get_oss_domain():
|
||
"""
|
||
获取对象存储的域名,末尾包含 '/'。
|
||
返回:字符串,如 'https://example.com/'
|
||
"""
|
||
return getattr(settings, 'COS_DOMAIN', 'https://xingque999qygwuyq-1404472910.cos.ap-shanghai.myqcloud.com/').rstrip('/') + '/'
|
||
|
||
|
||
# ================================================================
|
||
# 辅助函数:获取单个订单的打手提交图片(绝对URL)
|
||
# ================================================================
|
||
def _fetch_images(order, oss_domain):
|
||
"""
|
||
查询指定订单的打手提交图片记录(Dashoutupian 表),
|
||
将相对路径拼接为完整的绝对 URL 并返回。
|
||
|
||
参数:
|
||
order: Dingdan 订单模型实例
|
||
oss_domain: OSS 域名,末尾含 '/'
|
||
|
||
返回:
|
||
(images_list, is_punished, punishment_info) 三元组
|
||
- images_list: 图片绝对 URL 列表
|
||
- is_punished: 暂未使用,默认 False
|
||
- punishment_info: 暂未使用,默认空字典
|
||
"""
|
||
images = []
|
||
is_punished = False
|
||
punishment_info = {}
|
||
|
||
# 仅当订单有接单打手ID时才查询打手图片
|
||
if order.PlayerID:
|
||
# 从打手图片表中查询该订单、该打手的所有图片记录
|
||
image_urls = PlayerDeliveryImage.query.filter(
|
||
OrderID=order.OrderID, # 订单ID匹配
|
||
PlayerID=order.PlayerID # 接单打手ID匹配
|
||
).values_list('ImageURL', flat=True) # 只取图片URL字段
|
||
|
||
# 遍历所有图片路径
|
||
for img_path in image_urls:
|
||
if img_path:
|
||
if img_path.startswith('http'):
|
||
# 已经是完整URL,直接添加
|
||
images.append(img_path)
|
||
else:
|
||
# 相对路径,拼接 OSS 域名
|
||
images.append(oss_domain + img_path.lstrip('/'))
|
||
|
||
# 跨平台图片拉取逻辑(根据实际业务需求补充,此处保留结构)
|
||
# 如果有跨平台订单,需要请求对方平台接口获取打手图片
|
||
return images, is_punished, punishment_info
|
||
|
||
|
||
# ================================================================
|
||
# 辅助函数:并发获取多个订单的打手图片
|
||
# ================================================================
|
||
def _batch_images(orders, oss_domain):
|
||
"""
|
||
并发调用 _fetch_images,提升批量获取图片的速度。
|
||
|
||
参数:
|
||
orders: Dingdan 订单对象列表
|
||
oss_domain: OSS 域名
|
||
|
||
返回:
|
||
字典,键为订单ID (dingdan_id),值为 (images_list, is_punished, punishment_info)
|
||
"""
|
||
result = {} # 存储结果
|
||
# 使用线程池并发执行,max_workers 可根据服务器性能调整
|
||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||
# 建立 future 到订单ID的映射
|
||
future_to_oid = {
|
||
executor.submit(_fetch_images, order, oss_domain): order.OrderID
|
||
for order in orders
|
||
}
|
||
# 等待所有任务完成
|
||
for future in as_completed(future_to_oid):
|
||
oid = future_to_oid[future] # 对应的订单ID
|
||
try:
|
||
# 获取任务返回值
|
||
imgs, pun, pinfo = future.result()
|
||
result[oid] = (imgs, pun, pinfo)
|
||
except Exception as e:
|
||
# 某个订单获取失败,记录日志并赋予空值,保证整体流程不中断
|
||
logger.error(f"获取订单 {oid} 图片失败: {e}")
|
||
result[oid] = ([], False, {})
|
||
return result
|
||
|
||
|
||
# ================================================================
|
||
# 核心视图类
|
||
# ================================================================
|
||
class ShopOrderManageView(APIView):
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def post(self, request):
|
||
"""
|
||
统一入口,根据 action 参数分发到统计或列表模块。
|
||
"""
|
||
# 1. 从请求中提取店铺用户ID
|
||
user_id = request.data.get('userId', '').strip()
|
||
if not user_id:
|
||
return Response({'code': 400, 'msg': '缺少 userId'})
|
||
|
||
# 2. 验证店铺身份,获取店铺对象
|
||
dianpu, error_response = verify_shop_permission(request, user_id)
|
||
if error_response:
|
||
return error_response
|
||
|
||
# 3. 根据 action 决定调用哪个方法
|
||
action = request.data.get('action', '').strip().lower()
|
||
if action == 'statistics':
|
||
return self._statistics(dianpu) # 统计
|
||
else:
|
||
return self._order_list(request, dianpu) # 列表
|
||
|
||
# -----------------------------------------------------------------
|
||
# 统计实现 (这就是你要求的100个统计字段的详细实现)
|
||
# -----------------------------------------------------------------
|
||
def _statistics(self, dianpu):
|
||
"""
|
||
统计本店铺的订单数据,输出以下信息:
|
||
- types: 商品类型数组,每个元素包含:
|
||
id : 类型ID
|
||
jieshao : 类型介绍
|
||
tupian_url : 类型图片
|
||
order_count : 【该类型的订单总数】
|
||
status_counts: 该类型下各个状态的订单数量
|
||
- status_counts: 全局各状态订单总数(键为状态码字符串)
|
||
- total_orders : 店铺订单总量
|
||
|
||
所有字段名均与前端保持一致。
|
||
"""
|
||
# 获取店铺ID
|
||
shop_id = dianpu.id
|
||
|
||
# ------------------------------------------------------------
|
||
# 步骤1:获取所有正常的商品类型(审核状态为1)
|
||
# ------------------------------------------------------------
|
||
types_query = ShangpinLeixing.query.filter(
|
||
shenhezhuangtai=1 # 审核状态为1(正常)
|
||
).order_by('-paixu', 'id') # 按排序权重和ID排序
|
||
|
||
# 构建返回的类型列表,并预初始化各状态计数
|
||
type_list = [] # 最终要返回的类型数组
|
||
type_ids = [] # 所有类型的ID列表,用于后续快速索引
|
||
|
||
# 定义可能出现的所有状态码(1/7已付款,2进行中,8待结算,3已完成,4退款审核,5已退款,6退款失败)
|
||
status_keys = ['1', '7', '2', '8', '3', '4', '5', '6']
|
||
|
||
for t in types_query:
|
||
# 构造每个类型的基础数据
|
||
type_item = {
|
||
'id': t.id,
|
||
'jieshao': t.jieshao or '',
|
||
'tupian_url': t.tupian_url or '',
|
||
'order_count': 0, # 该类型订单总数,稍后填充
|
||
'status_counts': {k: 0 for k in status_keys} # 各状态计数初始为0
|
||
}
|
||
type_list.append(type_item)
|
||
type_ids.append(t.id) # 记录ID
|
||
|
||
# ------------------------------------------------------------
|
||
# 步骤2:获取本店铺的所有订单ID(通过平台扩展表)
|
||
# ------------------------------------------------------------
|
||
# 从 DingdanPingtai 中过滤出属于当前店铺的订单扩展记录,并提取订单主表ID
|
||
order_ids = list(
|
||
PlatformOrderExt.query.filter(
|
||
dianpu_id=shop_id # 店铺ID匹配
|
||
).values_list('Order_id', flat=True) # 只取订单ID字段
|
||
)
|
||
|
||
# 统计变量
|
||
total_orders = 0 # 订单总量
|
||
global_status_counts = defaultdict(int) # 全局各状态计数,键为状态码字符串
|
||
|
||
# ------------------------------------------------------------
|
||
# 步骤3:如果店铺有订单,进行聚合统计
|
||
# ------------------------------------------------------------
|
||
if order_ids:
|
||
# 使用 Django 聚合查询:按 leixing_id(商品类型)和 zhuangtai(订单状态)分组计数
|
||
aggregation = Order.query.filter(
|
||
OrderID__in=order_ids # 从该店铺的订单中查询
|
||
).values(
|
||
'ProductTypeID', # 按类型ID分组
|
||
'Status' # 按状态分组
|
||
).annotate(
|
||
cnt=Count('id') # 统计每个分组的记录数
|
||
)
|
||
|
||
# 遍历每个聚合结果,累加到对应的类型和全局计数中
|
||
for row in aggregation:
|
||
# 提取分组字段
|
||
lid = row['ProductTypeID'] # 商品类型ID
|
||
st = row['Status'] # 订单状态码
|
||
cnt = row['cnt'] # 该分组下的订单数量
|
||
|
||
# 累加到全局总数
|
||
total_orders += cnt
|
||
global_status_counts[str(st)] += cnt
|
||
|
||
# 累加到对应商品类型的状态计数中
|
||
if lid in type_ids:
|
||
# 在 type_list 中找到对应的类型条目
|
||
for tp in type_list:
|
||
if tp['id'] == lid:
|
||
# 更新该类型的订单总数
|
||
tp['order_count'] += cnt
|
||
# 更新该类型下对应状态的计数(键为字符串)
|
||
tp['status_counts'][str(st)] += cnt
|
||
break # 找到后跳出循环,因为ID唯一
|
||
|
||
# 将全局状态计数转换为按固定顺序的字典,确保前端展示顺序稳定
|
||
final_global_counts = {k: global_status_counts.get(k, 0) for k in status_keys}
|
||
|
||
# ------------------------------------------------------------
|
||
# 步骤4:返回统计结果
|
||
# ------------------------------------------------------------
|
||
return Response({
|
||
'code': 200,
|
||
'msg': '统计成功',
|
||
'data': {
|
||
'types': type_list, # 商品类型列表(含各类型统计)
|
||
'status_counts': final_global_counts, # 全局各状态总数
|
||
'total_orders': total_orders # 订单总量
|
||
}
|
||
})
|
||
|
||
# -----------------------------------------------------------------
|
||
# 订单列表实现
|
||
# -----------------------------------------------------------------
|
||
def _order_list(self, request, dianpu):
|
||
"""处理订单列表请求,支持筛选、分页、并发获取图片。"""
|
||
shop_id = dianpu.id
|
||
|
||
# 解析筛选参数
|
||
leixing_id = request.data.get('leixing_id')
|
||
zhuangtai = request.data.get('zhuangtai')
|
||
dingdan_id_key = request.data.get('dingdan_id', '').strip()
|
||
jiedan_dashou_id_key = request.data.get('jiedan_dashou_id', '').strip()
|
||
nicheng_key = request.data.get('nicheng', '').strip()
|
||
laoban_id_key = request.data.get('laoban_id', '').strip()
|
||
jieshao_key = request.data.get('jieshao', '').strip()
|
||
|
||
page = request.data.get('page', 1)
|
||
page_size = request.data.get('page_size', 20)
|
||
|
||
# 基础查询:从平台扩展表关联订单主表,并按创建时间倒序
|
||
qs = PlatformOrderExt.query.filter(
|
||
dianpu_id=shop_id
|
||
).select_related('Order').order_by('-Order__CreateTime')
|
||
|
||
# 应用筛选条件
|
||
if leixing_id is not None:
|
||
qs = qs.filter(Order__ProductTypeID=leixing_id)
|
||
if zhuangtai is not None:
|
||
qs = qs.filter(Order__Status=zhuangtai)
|
||
if dingdan_id_key:
|
||
qs = qs.filter(Order__OrderID__icontains=dingdan_id_key)
|
||
if jiedan_dashou_id_key:
|
||
qs = qs.filter(Order__PlayerID=jiedan_dashou_id_key)
|
||
if nicheng_key:
|
||
qs = qs.filter(Order__Nickname__icontains=nicheng_key)
|
||
if laoban_id_key:
|
||
qs = qs.filter(BossID=laoban_id_key)
|
||
if jieshao_key:
|
||
qs = qs.filter(Order__Description__icontains=jieshao_key)
|
||
|
||
# 分页
|
||
paginator = Paginator(qs, page_size)
|
||
try:
|
||
page_obj = paginator.page(page)
|
||
except EmptyPage:
|
||
page_obj = paginator.page(1)
|
||
|
||
# 获取当前页的所有订单和扩展对象
|
||
orders_with_ext = [(item.Order, item) for item in page_obj]
|
||
# 提取订单对象列表,用于并发获取图片
|
||
order_objs = [order for order, _ in orders_with_ext]
|
||
oss_domain = get_oss_domain()
|
||
img_map = _batch_images(order_objs, oss_domain)
|
||
|
||
# 构建返回数据
|
||
list_data = []
|
||
for order, ext in orders_with_ext:
|
||
imgs, is_pun, _ = img_map.get(order.OrderID, ([], False, {}))
|
||
list_data.append({
|
||
'dingdan_id': order.OrderID,
|
||
'zhuangtai': order.Status,
|
||
'jine': float(order.Amount) if order.Amount else 0.00,
|
||
'dashou_fencheng': float(order.PlayerCommission) if order.PlayerCommission else 0.00,
|
||
'jiedan_dashou_id': order.PlayerID or '',
|
||
'dashou_liuyan': order.PlayerRemark or '',
|
||
'zhiding_id': order.AssignedID or '',
|
||
'shangpin_id': order.ProductID or '',
|
||
'tupian': order.ImageURL or '',
|
||
'jieshao': order.Description or '',
|
||
'beizhu': order.Remark or '',
|
||
'nicheng': order.Nickname or '',
|
||
'leixing_id': order.ProductTypeID,
|
||
'CreateTime': order.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if order.CreateTime else '',
|
||
'UpdateTime': order.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if order.UpdateTime else '',
|
||
'pingtai_kuozhan': {
|
||
'laoban_id': ext.BossID or '',
|
||
'laoban_pingjia': ext.BossReview or '',
|
||
'dianpu_shouyi': float(ext.ShopIncome) if ext.ShopIncome else 0.00,
|
||
},
|
||
'dashou_images': imgs,
|
||
'is_punished': is_pun,
|
||
'is_fadaned': False,
|
||
})
|
||
|
||
return Response({
|
||
'code': 200,
|
||
'msg': '列表成功',
|
||
'data': {
|
||
'list': list_data,
|
||
'total': paginator.count,
|
||
'page': page_obj.number,
|
||
'page_size': page_size
|
||
}
|
||
})
|
||
|