fix: 组队仅队长提交修改,组员可见交付;罚单聚合改撤联动

详情返回 can_submit/can_modify;交付图按订单聚合;组队均摊罚单展示总额并联动修改/撤销;罚单 ClubID 归属订单店。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-27 08:05:34 +08:00
parent c5ceacf3d3
commit 7d366e8075
4 changed files with 275 additions and 73 deletions

View File

@@ -81,6 +81,78 @@ def get_active_recruit(order_id: str):
).first()
def get_team_role_for_user(order_id: str, user_uid: str) -> dict:
"""
当前用户在本单组队中的角色。
返回: has_team, is_leader, is_member, leader_uid, my_share, recruit
"""
from orders.models import TeamMember, TeamRecruit
out = {
'has_team': False,
'is_leader': False,
'is_member': False,
'leader_uid': '',
'my_share': None,
'recruit': None,
}
oid = (order_id or '').strip()
uid = (user_uid or '').strip()
if not oid or not uid:
return out
rec = get_active_recruit(oid)
if not rec:
rec = TeamRecruit.query.filter(order_id=oid).order_by('-id').first()
if not rec:
return out
out['has_team'] = True
out['recruit'] = rec
out['leader_uid'] = rec.leader_uid or ''
out['is_leader'] = (rec.leader_uid or '') == uid
mem = TeamMember.query.filter(
recruit_id=rec.id, user_uid=uid, join_status=TeamMember.JOIN_ACTIVE,
).first()
if mem:
out['is_member'] = True
out['my_share'] = float(_d(mem.share_amount))
elif out['is_leader']:
out['is_member'] = True
return out
def ensure_team_leader_can_submit(order, user_uid: str) -> Optional[str]:
"""
有有效组队时:仅队长可提交/修改交付。
无组队时:须为订单 PlayerID。
返回错误文案;通过返回 None。
"""
oid = getattr(order, 'OrderID', None) or ''
uid = (user_uid or '').strip()
player = (getattr(order, 'PlayerID', None) or '').strip()
from orders.models import TeamRecruit
ctx = get_team_role_for_user(oid, uid)
if ctx['has_team'] and ctx['recruit'] and int(ctx['recruit'].status or 0) in (
TeamRecruit.STATUS_RECRUITING, TeamRecruit.STATUS_LOCKED,
):
if not ctx['is_leader']:
return '仅队长可提交或修改交付内容'
return None
if player and player != uid:
return '订单不存在或没有提交权限'
return None
def list_team_split_penalties(order_id: str, *, include_rejected=False):
"""同订单【组队均摊】罚单列表(按创建顺序)。"""
from orders.models import Penalty
qs = Penalty.query.filter(
RelatedOrderID=order_id,
Reason__startswith='【组队均摊】',
)
if not include_rejected:
qs = qs.exclude(Status=4)
return list(qs.order_by('id'))
def team_should_split(order) -> bool:
"""结单是否走组队分账已锁定或招募中但已有人——进8会强制锁且有在队成员。"""
from orders.models import TeamRecruit, TeamMember
@@ -702,7 +774,15 @@ def maybe_split_penalties(*, order_id: str, total_amount, reason: str, applicant
reason_text = f'【组队均摊】{reason or ""}'
created = []
cid = (club_id or rec.club_id or 'xq')[:16]
# 罚单归属订单俱乐部,避免跨店接单时记到错误店;利率仍按申请人俱乐部在 lock_penalty_bonus 锁定
order_club = ''
try:
from orders.models import Order
o = Order.query.filter(OrderID=order_id).only('ClubID').first()
order_club = (getattr(o, 'ClubID', None) or '').strip() if o else ''
except Exception:
order_club = ''
cid = (order_club or club_id or rec.club_id or 'xq')[:16]
for i, m in enumerate(members):
p = Penalty(
PenalizedUserID=m.user_uid,

View File

@@ -862,18 +862,28 @@ class DashouCOSZhengshuView(APIView):
if not allowed:
return Response({'code': 403, 'msg': '无权上传退款图片'}, status=403)
else:
# 默认为订单相关,需要验证订单且打手必须接单
# 默认为订单交付图:组队仅队长;非组队须接单
try:
dingdan = Order.query.get(
OrderID=dingdan_id,
PlayerID=request.user.UserUID,
Status__in=[2, 8]
)
except Order.DoesNotExist:
return Response(
{'code': 403, 'msg': '此功能暂未开放', 'error': f'Order {dingdan_id} not found or not assigned to user {request.user.UserUID}'},
{'code': 403, 'msg': '此功能暂未开放', 'error': f'Order {dingdan_id} not found'},
status=status.HTTP_403_FORBIDDEN
)
try:
from orders.services.team_recruit import ensure_team_leader_can_submit
deny = ensure_team_leader_can_submit(dingdan, request.user.UserUID)
if deny:
return Response({'code': 403, 'msg': deny}, status=status.HTTP_403_FORBIDDEN)
except Exception:
if dingdan.PlayerID != request.user.UserUID:
return Response(
{'code': 403, 'msg': '此功能暂未开放'},
status=status.HTTP_403_FORBIDDEN
)
# 4. 获取腾讯云STS临时密钥
try:
@@ -1220,26 +1230,34 @@ class DashouTijiaoView(APIView):
'data': None
}, status=status.HTTP_403_FORBIDDEN)
# 4. 验证订单状态和权限
# 4. 验证订单状态和权限(组队单仅队长可提交)
try:
dingdan = Order.query.get(
OrderID=dingdan_id,
PlayerID=yonghuid,
Status=2 # 必须是进行中的订单
)
dingdan = Order.query.get(OrderID=dingdan_id, Status=2)
except Order.DoesNotExist:
return Response({
'code': 7,
'msg': '订单不存在或没有提交权限',
'data': None
}, status=status.HTTP_403_FORBIDDEN)
try:
from orders.services.team_recruit import ensure_team_leader_can_submit
deny = ensure_team_leader_can_submit(dingdan, yonghuid)
if deny:
return Response({'code': 7, 'msg': deny, 'data': None}, status=status.HTTP_403_FORBIDDEN)
except Exception:
if dingdan.PlayerID != yonghuid:
return Response({
'code': 7,
'msg': '订单不存在或没有提交权限',
'data': None
}, status=status.HTTP_403_FORBIDDEN)
# 5. 存储图片到Dashoutupian表
# 5. 存储图片到Dashoutupian表(统一记在接单人/队长名下,组员可读)
owner_uid = dingdan.PlayerID or yonghuid
for tupian_url in jiaofu_tupian_urls:
# 创建打手提交图片记录
PlayerDeliveryImage.query.create(
OrderID=dingdan_id,
PlayerID=yonghuid,
PlayerID=owner_uid,
ImageURL=tupian_url,
CreateTime=timezone.now()
)
@@ -1342,11 +1360,32 @@ class DashouDingdanXiangqingView(APIView):
'data': None
}, status=status.HTTP_403_FORBIDDEN)
# 5. 构建基础订单数据
# 5. 构建基础订单数据(组队:组员看本人份额;交付图全员可见)
from orders.services.team_recruit import get_team_role_for_user, ensure_team_leader_can_submit
team_ctx = get_team_role_for_user(dingdan.OrderID, yonghuid)
full_commission = float(dingdan.PlayerCommission) if dingdan.PlayerCommission else 0.0
my_share = team_ctx.get('my_share')
show_jine = full_commission
if team_ctx.get('has_team') and not team_ctx.get('is_leader') and my_share is not None:
show_jine = float(my_share)
submit_deny = ensure_team_leader_can_submit(dingdan, yonghuid)
can_submit = int(dingdan.Status or 0) == 2 and submit_deny is None
can_modify = int(dingdan.Status or 0) in (4, 8) and submit_deny is None
is_team_leader = (
bool(team_ctx.get('is_leader'))
if team_ctx.get('has_team')
else (dingdan.PlayerID == yonghuid)
)
dingdan_data = {
'dingdan_id': dingdan.OrderID,
'zhuangtai': dingdan.Status,
'jine': float(dingdan.PlayerCommission) if dingdan.PlayerCommission else 0.0,
'jine': show_jine,
'order_commission': full_commission,
'my_share_amount': my_share if my_share is not None else full_commission,
'is_team_leader': is_team_leader,
'can_submit': bool(can_submit),
'can_modify': bool(can_modify),
'jieshao': dingdan.Description or '',
'tupian': dingdan.ImageURL or '',
'nicheng': dingdan.Nickname or '',
@@ -1391,10 +1430,9 @@ class DashouDingdanXiangqingView(APIView):
except Exception:
dingdan_data['team'] = None
# 6. 查询打手提交的图片
# 6. 订单交付图(组队全员可见,不按当前用户过滤)
dashou_tupian_list = PlayerDeliveryImage.query.filter(
OrderID=dingdan_id,
PlayerID=yonghuid
).order_by('CreateTime')
jiaofu_tupian_urls = []
@@ -1606,30 +1644,34 @@ class DashouXiugaiView(APIView):
return Response({'code': 5, 'msg': '用户不是打手身份', 'data': None},
status=status.HTTP_403_FORBIDDEN)
# ---------- 订单查询及权限验证 ----------
# ---------- 订单查询及权限验证(组队仅队长) ----------
try:
# 订单必须属于该打手且状态为4退款中或8结算中
dingdan = Order.query.get(
OrderID=dingdan_id,
PlayerID=yonghuid,
Status__in=[4, 8] # 只允许修改退款中或结算中的订单
)
dingdan = Order.query.get(OrderID=dingdan_id, Status__in=[4, 8])
except Order.DoesNotExist:
return Response({'code': 6, 'msg': '订单不存在或没有修改权限', 'data': None},
status=status.HTTP_403_FORBIDDEN)
try:
from orders.services.team_recruit import ensure_team_leader_can_submit
deny = ensure_team_leader_can_submit(dingdan, yonghuid)
if deny:
return Response({'code': 6, 'msg': deny, 'data': None},
status=status.HTTP_403_FORBIDDEN)
except Exception:
if dingdan.PlayerID != yonghuid:
return Response({'code': 6, 'msg': '订单不存在或没有修改权限', 'data': None},
status=status.HTTP_403_FORBIDDEN)
owner_uid = dingdan.PlayerID or yonghuid
# ---------- 执行修改(使用事务保证原子性) ----------
try:
with transaction.atomic():
# 1. 处理删除的图片:从 Dashoutupian 表中删除记录
if deleted_images:
# 根据订单ID、打手ID和图片URL删除记录
delete_count, _ = PlayerDeliveryImage.query.filter(
OrderID=dingdan_id,
PlayerID=yonghuid,
ImageURL__in=deleted_images
).delete()
# 可选:记录删除数量
logger.info(f"订单 {dingdan_id} 删除图片 {delete_count}")
# 2. 处理新增的图片:插入新记录
@@ -1639,7 +1681,7 @@ class DashouXiugaiView(APIView):
for url in new_images:
new_records.append(PlayerDeliveryImage(
OrderID=dingdan_id,
PlayerID=yonghuid,
PlayerID=owner_uid,
ImageURL=url,
CreateTime=timezone.now()
))

View File

@@ -642,21 +642,37 @@ class ShangjiaDingdanXiangqingView(APIView):
if hasattr(dashou_user, 'DashouProfile') and dashou_user.DashouProfile:
dashou_nicheng = dashou_user.DashouProfile.nicheng or ''
# 6. 罚款数据(如果存在
fadan_obj = Penalty.query.filter(
RelatedOrderID=dingdan_id,
PenalizedUserID=dashou_yonghuid
).first()
# 6. 罚款数据(组队均摊:聚合全部罚单;展示总额+明细
from orders.services.team_recruit import list_team_split_penalties
split_list = list_team_split_penalties(dingdan_id, include_rejected=True)
fadan_qs = Penalty.query.filter(RelatedOrderID=dingdan_id).order_by('id')
if split_list:
fadan_list = split_list
else:
fadan_list = list(
fadan_qs.filter(PenalizedUserID=dashou_yonghuid)[:1]
) or list(fadan_qs[:1])
fadan_obj = fadan_list[0] if fadan_list else None
fadan_dict = None
if fadan_obj:
fadan_items = []
if fadan_list:
total_fine = 0.0
total_bonus = 0.0
rate = 0.0
for p in fadan_list:
total_fine += float(p.FineAmount or 0)
total_bonus += float(p.ApplicantBonusAmount or 0)
if p.ApplicantBonusRate:
rate = float(p.ApplicantBonusRate)
fadan_items.append({
'id': p.id,
'beichufa_id': p.PenalizedUserID or '',
'fakuanjine': float(p.FineAmount or 0),
'zhuangtai': p.Status,
'yingde_fenhong': float(p.ApplicantBonusAmount or 0),
})
ct = fmt_datetime(fadan_obj.CreateTime)
ut = fmt_datetime(fadan_obj.UpdateTime)
zhengju_urls = list(
PenaltyAppealImage.query.filter(Penalty=fadan_obj, Purpose=1).values_list('ImageURL', flat=True)
)
shensu_urls = list(
PenaltyAppealImage.query.filter(Penalty=fadan_obj, Purpose=2).values_list('ImageURL', flat=True)
)
zhengju_imgs = [
{'id': t.id, 'url': t.ImageURL}
for t in PenaltyAppealImage.query.filter(Penalty=fadan_obj, Purpose=1)
@@ -665,20 +681,23 @@ class ShangjiaDingdanXiangqingView(APIView):
{'id': t.id, 'url': t.ImageURL}
for t in PenaltyAppealImage.query.filter(Penalty=fadan_obj, Purpose=2)
]
reason = (fadan_obj.Reason or '').replace('【组队均摊】', '', 1)
fadan_dict = {
'id': fadan_obj.id,
'chufaliyou': fadan_obj.Reason or '',
'fakuanjine': float(fadan_obj.FineAmount) if fadan_obj.FineAmount else 0.0,
'chufaliyou': reason,
'fakuanjine': total_fine,
'yingxiang_qiangdan': fadan_obj.AffectsGrabbing,
'zhuangtai': fadan_obj.Status,
'shensuliyou': fadan_obj.AppealReason or '',
'tongyi_chufaliyou': fadan_obj.ApproveReason or '',
'bohuiliyou': fadan_obj.RejectReason or '',
'yingde_fenhong': float(fadan_obj.ApplicantBonusAmount or 0),
'fenhong_lilv': float(fadan_obj.ApplicantBonusRate or 0),
'fenhong_yiruzhang': bool(fadan_obj.BonusCredited),
'yingde_fenhong': total_bonus,
'fenhong_lilv': rate,
'fenhong_yiruzhang': all(bool(p.BonusCredited) for p in fadan_list),
'zhengju_tupian': zhengju_imgs,
'shensu_tupian': shensu_imgs,
'is_team_split': len(fadan_list) > 1 or (fadan_obj.Reason or '').startswith('【组队均摊】'),
'items': fadan_items,
'CreateTime': ct,
'create_time': ct,
'UpdateTime': ut,

View File

@@ -385,9 +385,23 @@ class ShangjiaFakuaiXiugaiView(APIView):
dingdan.save()
return Response({'code': 0, 'msg': '退款理由修改成功'})
def _team_split_siblings(self, fadan, *, include_rejected=False):
"""同订单组队均摊罚单;非均摊则只返回自身。"""
from orders.services.team_recruit import list_team_split_penalties
reason = fadan.Reason or ''
if reason.startswith('【组队均摊】') and fadan.RelatedOrderID:
sibs = list_team_split_penalties(
fadan.RelatedOrderID, include_rejected=include_rejected,
)
if sibs:
return sibs
return [fadan]
def _modify_penalty(self, fadan, liyou, jine_str, processor_id=None):
"""修改罚款(待缴纳 / 申诉中 / 平台审核中)"""
"""修改罚款(待缴纳 / 申诉中 / 平台审核中);组队均摊联动改总额并重分。"""
from utils.penalty_status import PENALTY_PENDING_PAY, PENALTY_APPEALING, PENALTY_PLATFORM_AUDIT
from orders.services.team_recruit import _d, MONEY
from decimal import ROUND_DOWN
if fadan.Status not in (PENALTY_PENDING_PAY, PENALTY_APPEALING, PENALTY_PLATFORM_AUDIT):
return Response({'code': 400, 'msg': '当前罚单状态不可修改'})
if not liyou.strip():
@@ -396,37 +410,70 @@ class ShangjiaFakuaiXiugaiView(APIView):
jine = Decimal(jine_str)
if jine <= 0:
raise ValueError
except:
except Exception:
return Response({'code': 400, 'msg': '罚款金额必须为正数'})
siblings = self._team_split_siblings(fadan, include_rejected=False)
for s in siblings:
if s.Status not in (PENALTY_PENDING_PAY, PENALTY_APPEALING, PENALTY_PLATFORM_AUDIT):
return Response({'code': 400, 'msg': '组队均摊罚单状态不一致,无法修改'})
reason_text = liyou[:500]
if (fadan.Reason or '').startswith('【组队均摊】') and not reason_text.startswith('【组队均摊】'):
reason_text = f'【组队均摊】{reason_text}'
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)
k = len(siblings)
if k > 1:
total = _d(jine)
base = (total / Decimal(k)).quantize(MONEY, rounding=ROUND_DOWN)
amounts = [base] * k
amounts[0] = _d(total - base * (k - 1))
# 队长优先(与创建时一致)
siblings = sorted(
siblings,
key=lambda p: 0 if p.id == fadan.id else 1,
)
for i, s in enumerate(siblings):
s.Reason = reason_text
s.FineAmount = amounts[i]
if processor_id:
s.ProcessorID = processor_id
s.ProcessorIdentity = 5
s.save()
lock_penalty_bonus(s)
else:
fadan.Reason = reason_text
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': '驳回理由不能为空'})
siblings = self._team_split_siblings(fadan, include_rejected=False)
with transaction.atomic():
fadan.Status = PENALTY_REJECTED # 已驳回
fadan.RejectReason = liyou[:500]
if processor_id:
fadan.ProcessorID = processor_id
fadan.ProcessorIdentity = 5 # 小程序商家撤销=商家作为处理人
fadan.save()
for s in siblings:
if s.Status not in (PENALTY_PENDING_PAY, PENALTY_APPEALING, PENALTY_PLATFORM_AUDIT):
continue
s.Status = PENALTY_REJECTED
s.RejectReason = liyou[:500]
if processor_id:
s.ProcessorID = processor_id
s.ProcessorIdentity = 5
s.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
from orders.services.team_recruit import _d, MONEY
from decimal import ROUND_DOWN
if fadan.Status != PENALTY_REJECTED:
return Response({'code': 400, 'msg': '只有已驳回的罚单可再次申请'})
if not liyou.strip():
@@ -435,19 +482,33 @@ class ShangjiaFakuaiXiugaiView(APIView):
jine = Decimal(jine_str)
if jine <= 0:
raise ValueError
except:
except Exception:
return Response({'code': 400, 'msg': '罚款金额必须为正数'})
siblings = self._team_split_siblings(fadan, include_rejected=True)
siblings = [s for s in siblings if s.Status == PENALTY_REJECTED] or [fadan]
reason_text = liyou[:500]
if (fadan.Reason or '').startswith('【组队均摊】') and not reason_text.startswith('【组队均摊】'):
reason_text = f'【组队均摊】{reason_text}'
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}")
k = len(siblings)
total = _d(jine)
if k > 1:
base = (total / Decimal(k)).quantize(MONEY, rounding=ROUND_DOWN)
amounts = [base] * k
amounts[0] = _d(total - base * (k - 1))
else:
amounts = [total]
for i, s in enumerate(siblings):
s.Status = PENALTY_PLATFORM_AUDIT
s.Reason = reason_text
s.FineAmount = amounts[i] if i < len(amounts) else amounts[-1]
s.RejectReason = ''
if processor_id:
s.ProcessorID = processor_id
s.ProcessorIdentity = 5
s.save()
lock_penalty_bonus(s)
logger.info(f"商家重新申请组队罚单 order={fadan.RelatedOrderID} 总额={jine} n={k}")
return Response({'code': 0, 'msg': '已重新申请罚款'})
def _manage_penalty_evidence(self, fadan, data):