Files
Django/orders/views/merchant_penalty.py
XingQue 9c029288ba feat(merchant): 订单操作流水表与按真实操作人统计
新增 merchant_order_action_log,在结算/撤单/退款/罚单/派单/换打手成功路径写入真实操作人;提供 action-stats / action-operators 接口供经营数据页筛选统计。

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

447 lines
21 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.
"""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': '证据图片已更新'})