新增 merchant_order_action_log,在结算/撤单/退款/罚单/派单/换打手成功路径写入真实操作人;提供 action-stats / action-operators 接口供经营数据页筛选统计。 Co-authored-by: Cursor <cursoragent@cursor.com>
447 lines
21 KiB
Python
447 lines
21 KiB
Python
"""orders.views.merchant_penalty - auto-generated by split script."""
|
||
# ==================== 标准库 ====================
|
||
import hmac
|
||
import os
|
||
import json
|
||
import time
|
||
import random
|
||
import string
|
||
import traceback
|
||
import threading
|
||
import requests
|
||
import hashlib
|
||
import xml.etree.ElementTree as ET
|
||
import xmltodict
|
||
import logging
|
||
from decimal import Decimal
|
||
|
||
from django.conf import settings
|
||
from django.db import models, transaction
|
||
from gvsdsdk.fluent import db, func, FQ
|
||
from django.db.models import F, Q, OuterRef, Subquery
|
||
from django.core.cache import cache
|
||
from django.core.exceptions import ObjectDoesNotExist
|
||
from django.http import HttpResponse
|
||
from django.utils import timezone
|
||
|
||
from rest_framework.views import APIView
|
||
from rest_framework.response import Response
|
||
from rest_framework import permissions, status
|
||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||
from rest_framework.parsers import JSONParser
|
||
from rest_framework.throttling import AnonRateThrottle
|
||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||
|
||
from tencentcloud.common import credential
|
||
from tencentcloud.common.profile.client_profile import ClientProfile
|
||
from tencentcloud.common.profile.http_profile import HttpProfile
|
||
from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
|
||
from tencentcloud.sts.v20180813 import sts_client, models
|
||
|
||
from utils.weixin_broadcast import WeixinBroadcastSender
|
||
from utils.money import yuan_to_fen
|
||
from utils.chat_utils import (
|
||
_send_group_message, _subscribe_users_to_group, establish_order_chat,
|
||
prepare_order_group_chat, resolve_pair_group_id,
|
||
parse_pair_from_group_id, resolve_pair_from_order, check_pair_chat_permission,
|
||
query_pair_orders, order_to_chat_dict,
|
||
)
|
||
from utils.fadan_utils import check_fadan_qiangdan_eligible
|
||
|
||
from orders.utils import (
|
||
calc_shangjia_order_fencheng,
|
||
update_daily_payout,
|
||
settle_shangjia_order_guanshi_fenhong
|
||
)
|
||
from shop.utils import calculate_pingtai_and_dianpu_shouyi, validate_shangpin_and_dianpu, update_dianpu_daily_stat
|
||
from products.utils import update_shangpin_daily_stat
|
||
from backend.utils import update_shangjia_daily, update_dashou_daily_by_action, pick_leixing_id, datetime_aliases, fmt_datetime
|
||
from merchant_ops.constants import (
|
||
ACTION_PENALTY_APPLY,
|
||
ACTION_PENALTY_CANCEL,
|
||
ACTION_PENALTY_MODIFY,
|
||
ACTION_PENALTY_RESUBMIT,
|
||
ACTION_REFUND_CANCEL,
|
||
ACTION_REFUND_MODIFY,
|
||
)
|
||
from merchant_ops.services.action_log import (
|
||
record_order_action_from_request, leixing_id_from_order,
|
||
)
|
||
from rank.services import record_dashou_biaoxian
|
||
from rank.utils import check_dashou_biaoqian_required
|
||
|
||
from orders.notice_tasks import dingdan_guangbo
|
||
|
||
from ..models import (
|
||
Order, MerchantOrderExt, PlatformOrderExt, PlayerDeliveryImage,
|
||
PenaltyRecord, PenaltyEvidenceImage, Penalty, PenaltyAppealImage, PenaltyBonus, PenaltyBonusRate,
|
||
CommissionRate, PlayerRating, RefundRecord,
|
||
)
|
||
from jituan.services.club_config import get_commission_rate, get_commission_rate_object
|
||
from jituan.services.club_context import resolve_club_id_from_request
|
||
from jituan.services.club_user import get_payment_openid
|
||
from jituan.services.club_penalty import resolve_penalty_club_id, resolve_penalty_record_club_id
|
||
from users.fadan_fenhong_utils import lock_penalty_bonus
|
||
from users.fadan_fenhong_utils import lock_penalty_bonus
|
||
from products.models import Shangpin, ShangpinLeixing, Huiyuangoumai
|
||
from users.models import UserDashou, UserShangjia, UserBoss
|
||
from users.business_models import User
|
||
from rank.models import DashouBiaoxian, Chenghao, DingdanBiaoqian, YonghuChenghao
|
||
from config.models import (
|
||
ShangjiaLianjie
|
||
)
|
||
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ===================== 视图:商家罚款申请 =====================
|
||
# 文件路径:dingdan/views.py 或单独文件,根据项目结构放置
|
||
|
||
|
||
class ShangjiaFakuanApplyView(APIView):
|
||
"""
|
||
商家罚款申请接口
|
||
路径:POST /dingdan/sjfksq
|
||
权限:JWT认证的商家用户
|
||
参数:
|
||
dingdan_id - 订单ID (必填)
|
||
chufa_liyou - 罚款原因 (必填)
|
||
fakuanjine - 罚款金额,元 (必填)
|
||
yingxiang_qiangdan - 是否影响抢单 1是 0否 (必填)
|
||
说明:
|
||
1. 自动从订单中获取被打手ID,防止商家篡改目标。
|
||
2. 同一订单同一打手不允许重复罚款。
|
||
3. 跨平台订单若为我方派单,则通知对方平台处罚;否则本地生成罚单。
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
parser_classes = [JSONParser]
|
||
|
||
def post(self, request):
|
||
# ========== 1. 身份验证 ==========
|
||
current_user = request.user
|
||
try:
|
||
shangjia_id = current_user.UserUID # 商家的用户ID
|
||
except AttributeError:
|
||
return Response({'code': 1, 'msg': '用户认证失败'}, status=status.HTTP_401_UNAUTHORIZED)
|
||
|
||
# ========== 2. 参数提取与校验 ==========
|
||
dingdan_id = str(request.data.get('OrderID') or request.data.get('dingdan_id') or '').strip()
|
||
chufa_liyou = request.data.get('chufa_liyou', '').strip()
|
||
fakuanjine = request.data.get('fakuanjine', 0)
|
||
yingxiang_qiangdan = int(request.data.get('yingxiang_qiangdan', 1))
|
||
evidence_urls = request.data.get('evidence_urls') or []
|
||
|
||
if not dingdan_id or not chufa_liyou or not fakuanjine:
|
||
return Response({'code': 2, 'msg': '参数不完整'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
if not isinstance(evidence_urls, list):
|
||
return Response({'code': 2, 'msg': '证据图片格式错误'}, status=status.HTTP_400_BAD_REQUEST)
|
||
evidence_urls = [str(u).strip() for u in evidence_urls if str(u).strip()]
|
||
if len(evidence_urls) > 9:
|
||
return Response({'code': 2, 'msg': '证据图片最多9张'}, status=status.HTTP_400_BAD_REQUEST)
|
||
if len(evidence_urls) < 1:
|
||
return Response({'code': 2, 'msg': '请至少上传1张证据图片'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
try:
|
||
fakuanjine = float(fakuanjine)
|
||
if fakuanjine <= 0:
|
||
raise ValueError
|
||
except (ValueError, TypeError):
|
||
return Response({'code': 2, 'msg': '罚款金额格式错误'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# ========== 3. 获取订单,校验归属商家 ==========
|
||
try:
|
||
# 关联查询商家扩展表,确保订单属于当前商家
|
||
shangjia_kuozhan = MerchantOrderExt.query.select_related('Order').get(
|
||
Order__OrderID=dingdan_id,
|
||
MerchantID=shangjia_id
|
||
)
|
||
order = shangjia_kuozhan.Order
|
||
except MerchantOrderExt.DoesNotExist:
|
||
return Response({'code': 3, 'msg': '订单不存在或您无权操作'}, status=status.HTTP_403_FORBIDDEN)
|
||
|
||
# ========== 4. 获取被打手ID ==========
|
||
dashou_id = order.PlayerID
|
||
if not dashou_id:
|
||
return Response({'code': 4, 'msg': '该订单暂无接单打手,无法罚款'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# ========== 5. 重复罚款检查 ==========
|
||
if Penalty.query.filter(PenalizedUserID=dashou_id, RelatedOrderID=dingdan_id).exists():
|
||
return Response({'code': 5, 'msg': '该打手在此订单已被罚款,不能重复申请'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# ========== 6. 本地创建罚单 ==========
|
||
staff_kefu_id = None
|
||
staff_member = getattr(request, '_staff_member', None)
|
||
if staff_member is not None:
|
||
staff_kefu_id = getattr(staff_member, 'staff_user_id', None) or None
|
||
|
||
from utils.penalty_status import PENALTY_PLATFORM_AUDIT
|
||
|
||
with transaction.atomic():
|
||
fadan = Penalty.query.create(
|
||
PenalizedUserID=dashou_id,
|
||
ApplicantID=shangjia_id, # 申请人为当前商家
|
||
Identity=1, # 被打者身份:1=打手
|
||
Reason=chufa_liyou,
|
||
FineAmount=fakuanjine,
|
||
RelatedOrderID=dingdan_id,
|
||
Status=PENALTY_PLATFORM_AUDIT, # 平台审核中,通过后变为待缴纳
|
||
AffectsGrabbing=yingxiang_qiangdan,
|
||
ApplicantIdentity=5, # 申请人是商家
|
||
ClubID=resolve_penalty_club_id(
|
||
order=order,
|
||
penalized_user_id=dashou_id,
|
||
request=request,
|
||
applicant_id=shangjia_id,
|
||
user=current_user,
|
||
),
|
||
ShopStaffUserID=staff_kefu_id,
|
||
)
|
||
PenaltyAppealImage.objects.bulk_create([
|
||
PenaltyAppealImage(
|
||
Penalty=fadan,
|
||
PenalizedUserID=dashou_id,
|
||
ImageURL=url,
|
||
Purpose=1,
|
||
)
|
||
for url in evidence_urls
|
||
])
|
||
lock_penalty_bonus(fadan)
|
||
logger.info(f"商家{shangjia_id}对订单{dingdan_id}打手{dashou_id}提交罚款申请(平台审核),金额{fakuanjine}")
|
||
record_order_action_from_request(
|
||
request,
|
||
ACTION_PENALTY_APPLY,
|
||
dingdan_id,
|
||
merchant_id=shangjia_id,
|
||
amount=fakuanjine,
|
||
leixing_id=leixing_id_from_order(order),
|
||
remark=chufa_liyou[:200] if chufa_liyou else '',
|
||
)
|
||
return Response({'code': 0, 'msg': '罚款申请已提交,等待平台审核', 'data': {'fadan_id': fadan.id}})
|
||
|
||
|
||
class ShangjiaFakuaiXiugaiView(APIView):
|
||
"""
|
||
商家端统一修改接口:罚款修改/撤销/再次申请,订单退款撤销/修改理由
|
||
URL: /dingdan/sjfkxgycx
|
||
"""
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
def post(self, request):
|
||
user = request.user
|
||
# 验证商家身份
|
||
try:
|
||
shangjia = user.ShopProfile
|
||
except UserShangjia.DoesNotExist:
|
||
return Response({'code': 403, 'msg': '您不是商家'}, status=status.HTTP_403_FORBIDDEN)
|
||
if shangjia.zhuangtai != 1:
|
||
return Response({'code': 403, 'msg': '商家状态异常'}, status=status.HTTP_403_FORBIDDEN)
|
||
|
||
operation = request.data.get('operation')
|
||
dingdan_id = request.data.get('dingdan_id') or request.data.get('OrderID')
|
||
if not dingdan_id:
|
||
return Response({'code': 400, 'msg': '缺少订单ID'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
# 验证订单属于该商家
|
||
try:
|
||
dingdan = Order.query.select_related('shangjia_kuozhan').get(OrderID=dingdan_id)
|
||
except Order.DoesNotExist:
|
||
return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||
if not hasattr(dingdan, 'shangjia_kuozhan') or dingdan.shangjia_kuozhan.MerchantID != user.UserUID:
|
||
return Response({'code': 403, 'msg': '无权操作此订单'}, status=status.HTTP_403_FORBIDDEN)
|
||
|
||
# 处理订单相关操作
|
||
if operation == 'cancel_refund':
|
||
resp = self._cancel_refund(dingdan, request.data.get('liyou', ''))
|
||
if getattr(resp, 'data', {}).get('code') == 0:
|
||
record_order_action_from_request(
|
||
request, ACTION_REFUND_CANCEL, dingdan_id,
|
||
merchant_id=user.UserUID,
|
||
amount=getattr(dingdan, 'Amount', None),
|
||
leixing_id=leixing_id_from_order(dingdan),
|
||
remark=str(request.data.get('liyou') or '')[:200],
|
||
)
|
||
return resp
|
||
elif operation == 'modify_refund_reason':
|
||
resp = self._modify_refund_reason(dingdan, request.data.get('liyou', ''))
|
||
if getattr(resp, 'data', {}).get('code') == 0:
|
||
record_order_action_from_request(
|
||
request, ACTION_REFUND_MODIFY, dingdan_id,
|
||
merchant_id=user.UserUID,
|
||
amount=getattr(dingdan, 'Amount', None),
|
||
leixing_id=leixing_id_from_order(dingdan),
|
||
remark=str(request.data.get('liyou') or '')[:200],
|
||
)
|
||
return resp
|
||
|
||
# 处理罚款相关操作
|
||
fadan_id = request.data.get('fadan_id')
|
||
if not fadan_id:
|
||
return Response({'code': 400, 'msg': '缺少罚单ID'}, status=status.HTTP_400_BAD_REQUEST)
|
||
try:
|
||
fadan = Penalty.query.get(id=fadan_id)
|
||
except Penalty.DoesNotExist:
|
||
return Response({'code': 404, 'msg': '罚单不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||
if fadan.RelatedOrderID != dingdan_id:
|
||
return Response({'code': 403, 'msg': '罚单与订单不匹配'}, status=status.HTTP_403_FORBIDDEN)
|
||
|
||
if operation == 'modify_penalty':
|
||
resp = self._modify_penalty(fadan, request.data.get('liyou', ''), request.data.get('jine'), user.UserUID)
|
||
if getattr(resp, 'data', {}).get('code') == 0:
|
||
record_order_action_from_request(
|
||
request, ACTION_PENALTY_MODIFY, dingdan_id,
|
||
merchant_id=user.UserUID,
|
||
amount=request.data.get('jine') or getattr(fadan, 'FineAmount', None),
|
||
leixing_id=leixing_id_from_order(dingdan),
|
||
remark=str(request.data.get('liyou') or '')[:200],
|
||
)
|
||
return resp
|
||
elif operation == 'cancel_penalty':
|
||
resp = self._cancel_penalty(fadan, request.data.get('liyou', ''), user.UserUID)
|
||
if getattr(resp, 'data', {}).get('code') == 0:
|
||
record_order_action_from_request(
|
||
request, ACTION_PENALTY_CANCEL, dingdan_id,
|
||
merchant_id=user.UserUID,
|
||
amount=getattr(fadan, 'FineAmount', None),
|
||
leixing_id=leixing_id_from_order(dingdan),
|
||
remark=str(request.data.get('liyou') or '')[:200],
|
||
)
|
||
return resp
|
||
elif operation == 'resubmit_penalty':
|
||
resp = self._resubmit_penalty(fadan, request.data.get('liyou', ''), request.data.get('jine'), user.UserUID)
|
||
if getattr(resp, 'data', {}).get('code') == 0:
|
||
record_order_action_from_request(
|
||
request, ACTION_PENALTY_RESUBMIT, dingdan_id,
|
||
merchant_id=user.UserUID,
|
||
amount=request.data.get('jine') or getattr(fadan, 'FineAmount', None),
|
||
leixing_id=leixing_id_from_order(dingdan),
|
||
remark=str(request.data.get('liyou') or '')[:200],
|
||
)
|
||
return resp
|
||
elif operation == 'manage_penalty_evidence':
|
||
return self._manage_penalty_evidence(fadan, request.data)
|
||
else:
|
||
return Response({'code': 400, 'msg': '无效的操作类型'}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
def _cancel_refund(self, dingdan, liyou):
|
||
"""撤销退款申请(状态4 → 状态8,清空退款理由)"""
|
||
if dingdan.Status != 4:
|
||
return Response({'code': 400, 'msg': '只有退款中的订单可以撤销退款申请'})
|
||
with transaction.atomic():
|
||
dingdan.Status = 8
|
||
dingdan.RefundReason = '' # 清空退款理由
|
||
dingdan.save()
|
||
# 记录撤销理由可存入日志(如需要可另建日志表,此处简单处理)
|
||
logger.info(f"商家撤销退款订单 {dingdan.OrderID},理由: {liyou}")
|
||
return Response({'code': 0, 'msg': '已撤销退款申请'})
|
||
|
||
def _modify_refund_reason(self, dingdan, liyou):
|
||
"""修改退款理由(仅状态4时)"""
|
||
if dingdan.Status != 4:
|
||
return Response({'code': 400, 'msg': '只有退款中的订单可以修改退款理由'})
|
||
if not liyou.strip():
|
||
return Response({'code': 400, 'msg': '退款理由不能为空'})
|
||
dingdan.RefundReason = liyou[:500]
|
||
dingdan.save()
|
||
return Response({'code': 0, 'msg': '退款理由修改成功'})
|
||
|
||
def _modify_penalty(self, fadan, liyou, jine_str, processor_id=None):
|
||
"""修改罚款(待缴纳 / 申诉中 / 平台审核中)"""
|
||
from utils.penalty_status import PENALTY_PENDING_PAY, PENALTY_APPEALING, PENALTY_PLATFORM_AUDIT
|
||
if fadan.Status not in (PENALTY_PENDING_PAY, PENALTY_APPEALING, PENALTY_PLATFORM_AUDIT):
|
||
return Response({'code': 400, 'msg': '当前罚单状态不可修改'})
|
||
if not liyou.strip():
|
||
return Response({'code': 400, 'msg': '罚款理由不能为空'})
|
||
try:
|
||
jine = Decimal(jine_str)
|
||
if jine <= 0:
|
||
raise ValueError
|
||
except:
|
||
return Response({'code': 400, 'msg': '罚款金额必须为正数'})
|
||
with transaction.atomic():
|
||
fadan.Reason = liyou[:500]
|
||
fadan.FineAmount = jine
|
||
if processor_id:
|
||
fadan.ProcessorID = processor_id
|
||
fadan.ProcessorIdentity = 5 # 小程序商家自行处理
|
||
fadan.save()
|
||
lock_penalty_bonus(fadan)
|
||
return Response({'code': 0, 'msg': '罚款修改成功'})
|
||
|
||
def _cancel_penalty(self, fadan, liyou, processor_id=None):
|
||
"""撤销罚款(待缴纳 / 申诉中 / 平台审核中 → 已驳回)"""
|
||
from utils.penalty_status import PENALTY_PENDING_PAY, PENALTY_APPEALING, PENALTY_PLATFORM_AUDIT, PENALTY_REJECTED
|
||
if fadan.Status not in (PENALTY_PENDING_PAY, PENALTY_APPEALING, PENALTY_PLATFORM_AUDIT):
|
||
return Response({'code': 400, 'msg': '当前罚单状态不可撤销'})
|
||
if not liyou.strip():
|
||
return Response({'code': 400, 'msg': '驳回理由不能为空'})
|
||
with transaction.atomic():
|
||
fadan.Status = PENALTY_REJECTED # 已驳回
|
||
fadan.RejectReason = liyou[:500]
|
||
if processor_id:
|
||
fadan.ProcessorID = processor_id
|
||
fadan.ProcessorIdentity = 5 # 小程序商家撤销=商家作为处理人
|
||
fadan.save()
|
||
return Response({'code': 0, 'msg': '罚款已撤销(驳回)'})
|
||
|
||
def _resubmit_penalty(self, fadan, liyou, jine_str, processor_id=None):
|
||
"""再次申请罚款(已驳回 → 平台审核中)"""
|
||
from utils.penalty_status import PENALTY_REJECTED, PENALTY_PLATFORM_AUDIT
|
||
if fadan.Status != PENALTY_REJECTED:
|
||
return Response({'code': 400, 'msg': '只有已驳回的罚单可再次申请'})
|
||
if not liyou.strip():
|
||
return Response({'code': 400, 'msg': '罚款理由不能为空'})
|
||
try:
|
||
jine = Decimal(jine_str)
|
||
if jine <= 0:
|
||
raise ValueError
|
||
except:
|
||
return Response({'code': 400, 'msg': '罚款金额必须为正数'})
|
||
with transaction.atomic():
|
||
fadan.Status = PENALTY_PLATFORM_AUDIT
|
||
fadan.Reason = liyou[:500]
|
||
fadan.FineAmount = jine
|
||
fadan.RejectReason = '' # 清空驳回理由
|
||
if processor_id:
|
||
fadan.ProcessorID = processor_id
|
||
fadan.ProcessorIdentity = 5
|
||
fadan.save()
|
||
lock_penalty_bonus(fadan)
|
||
logger.info(f"商家重新申请罚单{fadan.id},金额={jine}")
|
||
return Response({'code': 0, 'msg': '已重新申请罚款'})
|
||
|
||
def _manage_penalty_evidence(self, fadan, data):
|
||
"""管理罚单证据图片(Purpose=1),与申诉图片严格隔离"""
|
||
from utils.penalty_status import PENALTY_PENDING_PAY, PENALTY_APPEALING, PENALTY_PLATFORM_AUDIT
|
||
if fadan.Status not in (PENALTY_PENDING_PAY, PENALTY_APPEALING, PENALTY_PLATFORM_AUDIT):
|
||
return Response({'code': 400, 'msg': '当前罚单状态不可修改证据图片'})
|
||
add_urls = data.get('add_urls') or []
|
||
remove_ids = data.get('remove_ids') or []
|
||
if not add_urls and not remove_ids:
|
||
return Response({'code': 400, 'msg': '未提供图片变更'})
|
||
with transaction.atomic():
|
||
if remove_ids:
|
||
PenaltyAppealImage.query.filter(
|
||
Penalty=fadan, Purpose=1, id__in=remove_ids
|
||
).delete()
|
||
existing_count = PenaltyAppealImage.query.filter(Penalty=fadan, Purpose=1).count()
|
||
if existing_count + len(add_urls) > 9:
|
||
return Response({'code': 400, 'msg': '证据图片最多9张'})
|
||
_valid_add_urls = [str(u).strip() for u in add_urls if str(u).strip()]
|
||
if _valid_add_urls:
|
||
PenaltyAppealImage.objects.bulk_create([
|
||
PenaltyAppealImage(
|
||
Penalty=fadan,
|
||
PenalizedUserID=fadan.PenalizedUserID,
|
||
ImageURL=url,
|
||
Purpose=1,
|
||
)
|
||
for url in _valid_add_urls
|
||
])
|
||
logger.info(
|
||
f"罚单{fadan.id}证据图变更:新增{len(add_urls)}张,删除{len(remove_ids)}张"
|
||
)
|
||
return Response({'code': 0, 'msg': '证据图片已更新'})
|