From 7d366e8075b5c160b7e0d86b38c0e37e2fa0d51b Mon Sep 17 00:00:00 2001 From: XingQue Date: Mon, 27 Jul 2026 08:05:34 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=84=E9=98=9F=E4=BB=85=E9=98=9F?= =?UTF-8?q?=E9=95=BF=E6=8F=90=E4=BA=A4=E4=BF=AE=E6=94=B9=EF=BC=8C=E7=BB=84?= =?UTF-8?q?=E5=91=98=E5=8F=AF=E8=A7=81=E4=BA=A4=E4=BB=98=EF=BC=9B=E7=BD=9A?= =?UTF-8?q?=E5=8D=95=E8=81=9A=E5=90=88=E6=94=B9=E6=92=A4=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 详情返回 can_submit/can_modify;交付图按订单聚合;组队均摊罚单展示总额并联动修改/撤销;罚单 ClubID 归属订单店。 Co-authored-by: Cursor --- orders/services/team_recruit.py | 82 +++++++++++++++++++++- orders/views/dashou.py | 96 ++++++++++++++++++------- orders/views/merchant.py | 53 +++++++++----- orders/views/merchant_penalty.py | 117 +++++++++++++++++++++++-------- 4 files changed, 275 insertions(+), 73 deletions(-) diff --git a/orders/services/team_recruit.py b/orders/services/team_recruit.py index c2e0a10..a9eab1d 100644 --- a/orders/services/team_recruit.py +++ b/orders/services/team_recruit.py @@ -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, diff --git a/orders/views/dashou.py b/orders/views/dashou.py index 6e391d5..6b7a5c1 100644 --- a/orders/views/dashou.py +++ b/orders/views/dashou.py @@ -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() )) diff --git a/orders/views/merchant.py b/orders/views/merchant.py index 6d166e0..52d970d 100644 --- a/orders/views/merchant.py +++ b/orders/views/merchant.py @@ -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, diff --git a/orders/views/merchant_penalty.py b/orders/views/merchant_penalty.py index ab4764b..8c84dbb 100644 --- a/orders/views/merchant_penalty.py +++ b/orders/views/merchant_penalty.py @@ -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):