feat: 组队招募(/zudui)+ 结单/自动结算/罚款挂钩
新增 Team 表与接口;进8强制锁、组队分账优先且失败不回退全额队长。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -489,6 +489,11 @@ class AdGengHuanDaShou(APIView):
|
||||
dingdan_obj.PlayerID = new_dashou_id
|
||||
dingdan_obj.Status = 2 # 进行中状态
|
||||
dingdan_obj.save()
|
||||
try:
|
||||
from orders.services.team_recruit import on_order_change_player
|
||||
on_order_change_player(dingdan_obj.OrderID, actor_uid='admin')
|
||||
except Exception as e:
|
||||
logger.error(f'组队换打手作废失败: {e}')
|
||||
|
||||
# 13. 返回成功响应
|
||||
return Response({
|
||||
|
||||
@@ -1264,8 +1264,20 @@ class DashouDingdanXiangqingView(APIView):
|
||||
'data': None
|
||||
}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# 4. 验证订单是否属于当前打手
|
||||
if dingdan.PlayerID != yonghuid:
|
||||
# 4. 验证:接单打手,或本单在队成员(含已分账结束仍在队)
|
||||
is_player = dingdan.PlayerID == yonghuid
|
||||
is_team_member = False
|
||||
if not is_player:
|
||||
try:
|
||||
from orders.models import TeamMember
|
||||
is_team_member = TeamMember.query.filter(
|
||||
order_id=dingdan_id,
|
||||
user_uid=yonghuid,
|
||||
join_status=TeamMember.JOIN_ACTIVE,
|
||||
).exists()
|
||||
except Exception:
|
||||
is_team_member = False
|
||||
if not is_player and not is_team_member:
|
||||
return Response({
|
||||
'code': 4,
|
||||
'msg': '没有权限查看此订单',
|
||||
@@ -1292,6 +1304,24 @@ class DashouDingdanXiangqingView(APIView):
|
||||
dingdan_data.update(order_auto_settle_payload(dingdan))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from orders.services.team_recruit import get_active_recruit, serialize_recruit_public
|
||||
from orders.models import TeamMember
|
||||
rec = get_active_recruit(dingdan.OrderID)
|
||||
if not rec:
|
||||
from orders.models import TeamRecruit
|
||||
rec = TeamRecruit.query.filter(order_id=dingdan.OrderID).order_by('-id').first()
|
||||
if rec:
|
||||
is_mem = TeamMember.query.filter(
|
||||
recruit_id=rec.id, user_uid=yonghuid, join_status=1,
|
||||
).exists() or rec.leader_uid == yonghuid
|
||||
dingdan_data['team'] = serialize_recruit_public(
|
||||
rec, dingdan, include_game_ids=is_mem,
|
||||
)
|
||||
else:
|
||||
dingdan_data['team'] = None
|
||||
except Exception:
|
||||
dingdan_data['team'] = None
|
||||
|
||||
# 6. 查询打手提交的图片
|
||||
dashou_tupian_list = PlayerDeliveryImage.query.filter(
|
||||
|
||||
@@ -888,12 +888,35 @@ class ShangjiaJiesuanView(APIView):
|
||||
|
||||
# 原子更新打手数据(可提现走冻结网关;未开启时与原先全额进 yue 一致)
|
||||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||||
credit_dashou_order_settle(
|
||||
order=order,
|
||||
dashou_profile=dashou,
|
||||
user_id=jiedan_dashou_id,
|
||||
amount=dashou_fencheng,
|
||||
)
|
||||
from orders.services.team_recruit import settle_team_or_legacy, get_active_recruit, lock_recruit
|
||||
from orders.models import TeamRecruit, TeamSettleLedger
|
||||
team_done = False
|
||||
try:
|
||||
rec = get_active_recruit(order.OrderID)
|
||||
if rec and rec.status == TeamRecruit.STATUS_RECRUITING:
|
||||
lock_recruit(rec.id, actor_uid='system', reason='merchant_settle_force_lock')
|
||||
team_done = settle_team_or_legacy(order, set_idle_leader=True)
|
||||
except Exception as e:
|
||||
logger.error('商家结单组队分账失败 %s: %s', dingdan_id, e)
|
||||
if TeamSettleLedger.query.filter(order_id=dingdan_id).exists() or get_active_recruit(dingdan_id):
|
||||
return Response({
|
||||
'code': 8,
|
||||
'msg': f'组队分账失败,已阻止全额打给队长: {e}',
|
||||
'data': None
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
if not team_done:
|
||||
if get_active_recruit(order.OrderID):
|
||||
return Response({
|
||||
'code': 8,
|
||||
'msg': '存在有效组队但分账未完成,已阻止全额打给队长',
|
||||
'data': None
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
credit_dashou_order_settle(
|
||||
order=order,
|
||||
dashou_profile=dashou,
|
||||
user_id=jiedan_dashou_id,
|
||||
amount=dashou_fencheng,
|
||||
)
|
||||
|
||||
# 更新商家成交单量
|
||||
UserShangjia.query.filter(id=shangjia.id).update(
|
||||
|
||||
@@ -168,7 +168,12 @@ class ShangjiaFakuanApplyView(APIView):
|
||||
return Response({'code': 4, 'msg': '该订单暂无接单打手,无法罚款'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# ========== 5. 重复罚款检查 ==========
|
||||
if Penalty.query.filter(PenalizedUserID=dashou_id, RelatedOrderID=dingdan_id).exists():
|
||||
from orders.services.team_recruit import get_active_recruit
|
||||
_active_team = get_active_recruit(dingdan_id)
|
||||
if _active_team:
|
||||
if Penalty.query.filter(RelatedOrderID=dingdan_id).exclude(Status=4).exists():
|
||||
return Response({'code': 5, 'msg': '该订单已有罚单,不能重复申请'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
elif Penalty.query.filter(PenalizedUserID=dashou_id, RelatedOrderID=dingdan_id).exists():
|
||||
return Response({'code': 5, 'msg': '该打手在此订单已被罚款,不能重复申请'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# ========== 6. 本地创建罚单 ==========
|
||||
@@ -180,25 +185,43 @@ class ShangjiaFakuanApplyView(APIView):
|
||||
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,
|
||||
# 组队均摊:有效组队则每人一张罚单
|
||||
from orders.services.team_recruit import maybe_split_penalties
|
||||
club_for_p = resolve_penalty_club_id(
|
||||
order=order,
|
||||
penalized_user_id=dashou_id,
|
||||
request=request,
|
||||
applicant_id=shangjia_id,
|
||||
user=current_user,
|
||||
)
|
||||
split = maybe_split_penalties(
|
||||
order_id=dingdan_id,
|
||||
total_amount=fakuanjine,
|
||||
reason=chufa_liyou,
|
||||
applicant_id=shangjia_id,
|
||||
applicant_identity=5,
|
||||
affects_grabbing=yingxiang_qiangdan,
|
||||
club_id=club_for_p,
|
||||
)
|
||||
if split:
|
||||
# 均摊单默认待缴纳;若业务需审核,统一改状态
|
||||
from orders.models import Penalty as PModel
|
||||
PModel.objects.filter(id__in=[p.id for p in split]).update(Status=PENALTY_PLATFORM_AUDIT)
|
||||
fadan = split[0]
|
||||
else:
|
||||
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=club_for_p,
|
||||
ShopStaffUserID=staff_kefu_id,
|
||||
)
|
||||
PenaltyAppealImage.objects.bulk_create([
|
||||
PenaltyAppealImage(
|
||||
Penalty=fadan,
|
||||
@@ -207,8 +230,23 @@ class ShangjiaFakuanApplyView(APIView):
|
||||
Purpose=1,
|
||||
)
|
||||
for url in evidence_urls
|
||||
])
|
||||
lock_penalty_bonus(fadan)
|
||||
]) if not split else None
|
||||
if split and evidence_urls:
|
||||
for p in split:
|
||||
PenaltyAppealImage.objects.bulk_create([
|
||||
PenaltyAppealImage(
|
||||
Penalty=p,
|
||||
PenalizedUserID=p.PenalizedUserID,
|
||||
ImageURL=url,
|
||||
Purpose=1,
|
||||
)
|
||||
for url in evidence_urls
|
||||
])
|
||||
if split:
|
||||
for p in split:
|
||||
lock_penalty_bonus(p)
|
||||
else:
|
||||
lock_penalty_bonus(fadan)
|
||||
logger.info(f"商家{shangjia_id}对订单{dingdan_id}打手{dashou_id}提交罚款申请(平台审核),金额{fakuanjine}")
|
||||
record_order_action_from_request(
|
||||
request,
|
||||
|
||||
284
orders/views/zudui.py
Normal file
284
orders/views/zudui.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""组队 API:/zudui/*"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
from orders.models import Order, TeamRecruit, TeamMember
|
||||
from orders.services import team_recruit as team_svc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _uid(request):
|
||||
return getattr(request.user, 'UserUID', '') or ''
|
||||
|
||||
|
||||
def _club(request):
|
||||
return (resolve_club_id_from_request(request) or '').strip()
|
||||
|
||||
|
||||
class ZuduiKaqiView(APIView):
|
||||
"""开启组队 POST /zudui/kaqi"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
uid = _uid(request)
|
||||
oid = str(request.data.get('dingdan_id') or request.data.get('order_id') or '').strip()
|
||||
if not oid:
|
||||
return Response({'code': 400, 'msg': '缺少订单ID'})
|
||||
order = Order.query.filter(OrderID=oid).first()
|
||||
if not order:
|
||||
return Response({'code': 404, 'msg': '订单不存在'})
|
||||
club = _club(request)
|
||||
if club and (order.ClubID or '') != club:
|
||||
return Response({'code': 403, 'msg': '俱乐部不匹配'})
|
||||
try:
|
||||
rec = team_svc.open_recruit(
|
||||
order=order,
|
||||
leader_uid=uid,
|
||||
need_count=int(request.data.get('need_count') or 1),
|
||||
share_mode=request.data.get('share_mode') or 'fixed',
|
||||
per_person_amount=request.data.get('per_person_amount'),
|
||||
remark=request.data.get('remark') or '',
|
||||
leader_game_id=request.data.get('leader_game_id') or request.data.get('game_id') or '',
|
||||
min_deal_rate=request.data.get('min_deal_rate'),
|
||||
max_fine_rate=request.data.get('max_fine_rate'),
|
||||
)
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '已开启组队',
|
||||
'data': team_svc.serialize_recruit_public(rec, order, include_game_ids=True),
|
||||
})
|
||||
except ValueError as e:
|
||||
return Response({'code': 400, 'msg': str(e)})
|
||||
except Exception as e:
|
||||
logger.exception('开启组队失败')
|
||||
return Response({'code': 500, 'msg': '服务器错误'})
|
||||
|
||||
|
||||
class ZuduiGaiyaoqiuView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
uid = _uid(request)
|
||||
rid = request.data.get('recruit_id')
|
||||
try:
|
||||
rid = int(rid)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '缺少 recruit_id'})
|
||||
try:
|
||||
kw = {'recruit_id': rid, 'leader_uid': uid}
|
||||
if 'need_count' in request.data:
|
||||
kw['need_count'] = request.data.get('need_count')
|
||||
if 'remark' in request.data:
|
||||
kw['remark'] = request.data.get('remark')
|
||||
if 'min_deal_rate' in request.data:
|
||||
kw['min_deal_rate'] = request.data.get('min_deal_rate')
|
||||
if 'max_fine_rate' in request.data:
|
||||
kw['max_fine_rate'] = request.data.get('max_fine_rate')
|
||||
rec = team_svc.update_recruit(**kw)
|
||||
order = Order.query.filter(OrderID=rec.order_id).first()
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '已更新',
|
||||
'data': team_svc.serialize_recruit_public(rec, order, include_game_ids=True),
|
||||
})
|
||||
except ValueError as e:
|
||||
return Response({'code': 400, 'msg': str(e)})
|
||||
|
||||
|
||||
class ZuduiGuantingView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
uid = _uid(request)
|
||||
rid = request.data.get('recruit_id')
|
||||
try:
|
||||
rid = int(rid)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '缺少 recruit_id'})
|
||||
rec = TeamRecruit.query.filter(id=rid).first()
|
||||
if not rec:
|
||||
return Response({'code': 404, 'msg': '组队不存在'})
|
||||
if rec.leader_uid != uid:
|
||||
return Response({'code': 403, 'msg': '仅队长可关停'})
|
||||
try:
|
||||
rec = team_svc.lock_recruit(rid, actor_uid=uid, reason='manual')
|
||||
order = Order.query.filter(OrderID=rec.order_id).first()
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '已关停招募',
|
||||
'data': team_svc.serialize_recruit_public(rec, order, include_game_ids=True),
|
||||
})
|
||||
except ValueError as e:
|
||||
return Response({'code': 400, 'msg': str(e)})
|
||||
|
||||
|
||||
class ZuduiDatingView(APIView):
|
||||
"""组队大厅:仅招募中。"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
club = _club(request)
|
||||
if not club:
|
||||
return Response({'code': 400, 'msg': '缺少俱乐部'})
|
||||
page = max(1, int(request.data.get('page') or 1))
|
||||
page_size = min(50, max(1, int(request.data.get('page_size') or 10)))
|
||||
leixing = request.data.get('leixing_id')
|
||||
pingtai = request.data.get('fadanpingtai')
|
||||
|
||||
qs = TeamRecruit.query.filter(
|
||||
club_id=club,
|
||||
status=TeamRecruit.STATUS_RECRUITING,
|
||||
).order_by('-opened_at')
|
||||
|
||||
# 可选:按订单类型过滤
|
||||
order_ids = None
|
||||
if leixing not in (None, '') or pingtai not in (None, ''):
|
||||
oq = Order.query.filter(ClubID=club, Status=2)
|
||||
if leixing not in (None, ''):
|
||||
try:
|
||||
oq = oq.filter(ProductTypeID=int(leixing))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if pingtai not in (None, ''):
|
||||
try:
|
||||
oq = oq.filter(Platform=int(pingtai))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
order_ids = list(oq.values_list('OrderID', flat=True)[:500])
|
||||
qs = qs.filter(order_id__in=order_ids or ['__none__'])
|
||||
|
||||
total = qs.count()
|
||||
start = (page - 1) * page_size
|
||||
rows = list(qs[start:start + page_size])
|
||||
oids = [r.order_id for r in rows]
|
||||
orders = {o.OrderID: o for o in Order.query.filter(OrderID__in=oids)} if oids else {}
|
||||
data_list = [
|
||||
team_svc.serialize_recruit_public(r, orders.get(r.order_id), include_game_ids=False)
|
||||
for r in rows
|
||||
]
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': 'ok',
|
||||
'data': {
|
||||
'list': data_list,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'has_more': start + len(rows) < total,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class ZuduiXiangqingView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
uid = _uid(request)
|
||||
rid = request.data.get('recruit_id')
|
||||
oid = str(request.data.get('dingdan_id') or request.data.get('order_id') or '').strip()
|
||||
rec = None
|
||||
if rid:
|
||||
try:
|
||||
rec = TeamRecruit.query.filter(id=int(rid)).first()
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not rec and oid:
|
||||
rec = TeamRecruit.query.filter(order_id=oid).order_by('-id').first()
|
||||
if not rec:
|
||||
return Response({'code': 404, 'msg': '组队不存在'})
|
||||
|
||||
is_member = TeamMember.query.filter(
|
||||
recruit_id=rec.id, user_uid=uid, join_status=TeamMember.JOIN_ACTIVE,
|
||||
).exists()
|
||||
include_game = is_member or rec.leader_uid == uid
|
||||
order = Order.query.filter(OrderID=rec.order_id).first()
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': 'ok',
|
||||
'data': team_svc.serialize_recruit_public(rec, order, include_game_ids=include_game),
|
||||
})
|
||||
|
||||
|
||||
class ZuduiRuiduiView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
uid = _uid(request)
|
||||
try:
|
||||
rid = int(request.data.get('recruit_id'))
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': '缺少 recruit_id'})
|
||||
try:
|
||||
rec = team_svc.join_recruit(
|
||||
recruit_id=rid,
|
||||
user_uid=uid,
|
||||
game_id=request.data.get('game_id') or '',
|
||||
request=request,
|
||||
)
|
||||
order = Order.query.filter(OrderID=rec.order_id).first()
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': '加入成功',
|
||||
'data': team_svc.serialize_recruit_public(rec, order, include_game_ids=True),
|
||||
})
|
||||
except ValueError as e:
|
||||
return Response({'code': 400, 'msg': str(e)})
|
||||
except Exception:
|
||||
logger.exception('加入组队失败')
|
||||
return Response({'code': 500, 'msg': '服务器错误'})
|
||||
|
||||
|
||||
class ZuduiWodeView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
uid = _uid(request)
|
||||
club = _club(request)
|
||||
as_leader = TeamRecruit.query.filter(
|
||||
club_id=club,
|
||||
leader_uid=uid,
|
||||
status__in=[TeamRecruit.STATUS_RECRUITING, TeamRecruit.STATUS_LOCKED],
|
||||
).order_by('-opened_at')[:50]
|
||||
member_rids = list(
|
||||
TeamMember.query.filter(
|
||||
user_uid=uid, join_status=TeamMember.JOIN_ACTIVE, role=TeamMember.ROLE_MEMBER,
|
||||
).values_list('recruit_id', flat=True)[:50]
|
||||
)
|
||||
as_member_qs = TeamRecruit.query.filter(
|
||||
id__in=member_rids or [0],
|
||||
club_id=club,
|
||||
status__in=[TeamRecruit.STATUS_RECRUITING, TeamRecruit.STATUS_LOCKED],
|
||||
) if member_rids and club else []
|
||||
as_member = list(as_member_qs) if as_member_qs else []
|
||||
as_leader_list = list(as_leader)
|
||||
oids = [r.order_id for r in as_leader_list + as_member]
|
||||
orders = {o.OrderID: o for o in Order.query.filter(OrderID__in=oids)} if oids else {}
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': 'ok',
|
||||
'data': {
|
||||
'as_leader': [
|
||||
team_svc.serialize_recruit_public(r, orders.get(r.order_id), include_game_ids=True)
|
||||
for r in as_leader_list
|
||||
],
|
||||
'as_member': [
|
||||
team_svc.serialize_recruit_public(r, orders.get(r.order_id), include_game_ids=True)
|
||||
for r in as_member
|
||||
],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user