diff --git a/a_long_dianjing/urls.py b/a_long_dianjing/urls.py index a2e37ac..d064bf1 100644 --- a/a_long_dianjing/urls.py +++ b/a_long_dianjing/urls.py @@ -14,6 +14,8 @@ urlpatterns = [ # 订单相关路由 path('dingdan/', include('orders.urls')), + # 组队 + path('zudui/', include('orders.urls_zudui')), # 商品/陪玩服务相关路由 path('shangpin/', include('products.urls')), diff --git a/backend/views/penalties.py b/backend/views/penalties.py index 66b594f..0a98a51 100644 --- a/backend/views/penalties.py +++ b/backend/views/penalties.py @@ -583,8 +583,13 @@ class FineApplyView(APIView): if not dashou_id: return Response({'code': 400, 'msg': '该订单无接单打手,无法罚款'}) - # 重复罚款检查 - if Penalty.query.filter(PenalizedUserID=dashou_id, RelatedOrderID=OrderID).exists(): + # 重复罚款检查(组队单按订单维度防重复) + from orders.services.team_recruit import get_active_recruit + _active_team = get_active_recruit(OrderID) + if _active_team: + if Penalty.query.filter(RelatedOrderID=OrderID).exclude(Status=4).exists(): + return Response({'code': 400, 'msg': '该订单已有罚单'}) + elif Penalty.query.filter(PenalizedUserID=dashou_id, RelatedOrderID=OrderID).exists(): return Response({'code': 400, 'msg': '该打手已被罚款过'}) # 检查权限(打手身份 shenfen=1) @@ -610,34 +615,57 @@ class FineApplyView(APIView): return Response({'code': 500, 'msg': '图片上传失败'}) uploaded_urls.append(file_path) - # 创建罚单 + # 创建罚单(组队均摊) with transaction.atomic(): - penalty = Penalty.query.create( - PenalizedUserID=dashou_id, - ApplicantID=request.user.Phone, - Identity=1, # 被处罚人身份:1 打手 - Reason=Reason, - FineAmount=FineAmount, - RelatedOrderID=OrderID, - Status=1, # 待缴纳 - AffectsGrabbing=AffectsGrabbing, - ApplicantIdentity=1, # 申请人身份:1 客服 - ClubID=resolve_penalty_club_id( - order=order, - request=request, - user=request.user, - applicant_id=getattr(request.user, 'UserUID', None), - ), - ) - for relative_url in uploaded_urls: - PenaltyAppealImage.query.create( - Penalty=penalty, - PenalizedUserID=dashou_id, - ImageURL=relative_url, - Purpose=1 - ) + from orders.services.team_recruit import maybe_split_penalties from users.fadan_fenhong_utils import lock_penalty_bonus - lock_penalty_bonus(penalty) + club_for_p = resolve_penalty_club_id( + order=order, + request=request, + user=request.user, + applicant_id=getattr(request.user, 'UserUID', None), + ) + split = maybe_split_penalties( + order_id=OrderID, + total_amount=FineAmount, + reason=Reason, + applicant_id=request.user.Phone, + applicant_identity=1, + affects_grabbing=AffectsGrabbing, + club_id=club_for_p, + ) + if split: + for p in split: + for relative_url in uploaded_urls: + PenaltyAppealImage.query.create( + Penalty=p, + PenalizedUserID=p.PenalizedUserID, + ImageURL=relative_url, + Purpose=1, + ) + lock_penalty_bonus(p) + penalty = split[0] + else: + penalty = Penalty.query.create( + PenalizedUserID=dashou_id, + ApplicantID=request.user.Phone, + Identity=1, + Reason=Reason, + FineAmount=FineAmount, + RelatedOrderID=OrderID, + Status=1, + AffectsGrabbing=AffectsGrabbing, + ApplicantIdentity=1, + ClubID=club_for_p, + ) + for relative_url in uploaded_urls: + PenaltyAppealImage.query.create( + Penalty=penalty, + PenalizedUserID=dashou_id, + ImageURL=relative_url, + Purpose=1 + ) + lock_penalty_bonus(penalty) logger.info(f"客服罚款申请成功: 订单 {OrderID}, 打手 {dashou_id}, 金额 {FineAmount}") return Response({'code': 0, 'msg': '罚款已生成', 'data': {'id': penalty.id}}) diff --git a/jituan/services/order_deal.py b/jituan/services/order_deal.py index 12c1e11..1296df6 100644 --- a/jituan/services/order_deal.py +++ b/jituan/services/order_deal.py @@ -207,12 +207,43 @@ def mark_order_completed_unified(order, *, source='auto_settle', operator=''): except Exception: return False, '打手不存在' - credit_dashou_order_settle( - order=locked, - dashou_profile=dashou_profile, - user_id=dashou_id, - amount=dashou_fencheng, - ) + # 组队分账优先;无有效组队则全额给队长(原逻辑) + # 若本应组队分账却失败/异常:禁止回退全额队长(避免队友白做或重复入账) + try: + from orders.services.team_recruit import settle_team_or_legacy, get_active_recruit, team_should_split + from orders.models import TeamSettleLedger, TeamRecruit + rec = get_active_recruit(locked.OrderID) + if rec and rec.status == TeamRecruit.STATUS_RECRUITING: + # 进8锁失败的兜底:结单前再强制锁一次 + from orders.services.team_recruit import lock_recruit + lock_recruit(rec.id, actor_uid='system', reason='settle_force_lock') + team_done = settle_team_or_legacy(locked, set_idle_leader=True) + except Exception as e: + logger.error('组队分账异常 %s: %s', locked.OrderID, e) + from orders.models import TeamSettleLedger + if TeamSettleLedger.query.filter(order_id=locked.OrderID).exists(): + return False, f'组队分账异常且已有入账记录: {e}' + try: + from orders.services.team_recruit import team_should_split, get_active_recruit + if get_active_recruit(locked.OrderID) or team_should_split(locked): + return False, f'组队分账失败: {e}' + except Exception: + pass + team_done = False + + if not team_done: + try: + from orders.services.team_recruit import get_active_recruit + if get_active_recruit(locked.OrderID): + return False, '存在有效组队但分账未完成,已阻止全额打给队长' + except Exception: + pass + credit_dashou_order_settle( + order=locked, + dashou_profile=dashou_profile, + user_id=dashou_id, + amount=dashou_fencheng, + ) if locked.Platform == 2: try: diff --git a/orders/migrations/0017_team_recruit.py b/orders/migrations/0017_team_recruit.py new file mode 100644 index 0000000..82107f6 --- /dev/null +++ b/orders/migrations/0017_team_recruit.py @@ -0,0 +1,112 @@ +# 组队招募表 TeamRecruit / TeamMember / TeamSettleLedger / TeamActionLog +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0016_order_auto_settle_eligible'), + ] + + operations = [ + migrations.CreateModel( + name='TeamRecruit', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部')), + ('order_id', models.CharField(db_index=True, max_length=32, unique=True, verbose_name='订单ID')), + ('leader_uid', models.CharField(db_index=True, max_length=32, verbose_name='队长UID')), + ('status', models.PositiveSmallIntegerField(db_index=True, default=1, verbose_name='状态')), + ('need_count', models.PositiveSmallIntegerField(default=1, verbose_name='目标队友数1-3')), + ('joined_count', models.PositiveSmallIntegerField(default=0, verbose_name='已入队队友数')), + ('share_mode', models.CharField(default='fixed', max_length=16, verbose_name='fixed|average')), + ('per_person_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='固定值人均')), + ('display_share_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='大厅展示分红')), + ('commission_snap', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='开启时分成快照')), + ('order_amount_snap', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='订单金额快照')), + ('remark', models.CharField(blank=True, default='', max_length=200, verbose_name='组队备注')), + ('leader_game_id', models.CharField(blank=True, default='', max_length=64, verbose_name='队长游戏ID')), + ('min_deal_rate', models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True, verbose_name='最低成交率0-1')), + ('max_fine_rate', models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True, verbose_name='最高罚款率0-1')), + ('opened_at', models.DateTimeField(blank=True, null=True)), + ('locked_at', models.DateTimeField(blank=True, null=True)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'team_recruit', + }, + ), + migrations.CreateModel( + name='TeamMember', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('recruit_id', models.BigIntegerField(db_index=True)), + ('order_id', models.CharField(db_index=True, max_length=32)), + ('user_uid', models.CharField(db_index=True, max_length=32)), + ('role', models.CharField(default='member', max_length=16)), + ('game_id', models.CharField(blank=True, default='', max_length=64)), + ('share_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('join_status', models.PositiveSmallIntegerField(db_index=True, default=1)), + ('joined_at', models.DateTimeField(blank=True, null=True)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'team_member', + }, + ), + migrations.CreateModel( + name='TeamSettleLedger', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('order_id', models.CharField(db_index=True, max_length=32)), + ('user_uid', models.CharField(db_index=True, max_length=32)), + ('amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('biz_id', models.CharField(max_length=64, unique=True)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'team_settle_ledger', + }, + ), + migrations.CreateModel( + name='TeamActionLog', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('club_id', models.CharField(blank=True, db_index=True, default='', max_length=16)), + ('order_id', models.CharField(blank=True, db_index=True, default='', max_length=32)), + ('recruit_id', models.BigIntegerField(blank=True, db_index=True, null=True)), + ('actor_uid', models.CharField(blank=True, default='', max_length=32)), + ('action', models.CharField(db_index=True, max_length=32)), + ('detail', models.TextField(blank=True, default='')), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'db_table': 'team_action_log', + }, + ), + migrations.AddIndex( + model_name='teamrecruit', + index=models.Index(fields=['club_id', 'status', '-opened_at'], name='team_recrui_club_id_0b8c0a_idx'), + ), + migrations.AddIndex( + model_name='teamrecruit', + index=models.Index(fields=['leader_uid', 'status'], name='team_recrui_leader__a1b2c3_idx'), + ), + migrations.AddIndex( + model_name='teammember', + index=models.Index(fields=['order_id', 'join_status'], name='team_member_order_i_d4e5f6_idx'), + ), + migrations.AddIndex( + model_name='teammember', + index=models.Index(fields=['user_uid', 'join_status'], name='team_member_user_ui_g7h8i9_idx'), + ), + migrations.AddConstraint( + model_name='teammember', + constraint=models.UniqueConstraint(fields=('recruit_id', 'user_uid'), name='uniq_team_member_recruit_uid'), + ), + ] diff --git a/orders/models.py b/orders/models.py index 4272d52..4417126 100644 --- a/orders/models.py +++ b/orders/models.py @@ -1138,6 +1138,125 @@ class FakeGrabOrderShuffleState(QModel): db_table = 'fake_grab_order_shuffle' +# =========================================================================== +# 组队(Team)— 新域,与 EscortOrder 无关 +# =========================================================================== + +class TeamRecruit(QModel): + """组队招募帖:一单一帖。status: 1招募中 2已锁定 3结束 4取消 5作废""" + + STATUS_RECRUITING = 1 + STATUS_LOCKED = 2 + STATUS_FINISHED = 3 + STATUS_CANCELLED = 4 + STATUS_VOID = 5 + + MODE_FIXED = 'fixed' + MODE_AVERAGE = 'average' + + id = models.BigAutoField(primary_key=True) + club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部') + order_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='订单ID') + leader_uid = models.CharField(max_length=32, db_index=True, verbose_name='队长UID') + status = models.PositiveSmallIntegerField(default=1, db_index=True, verbose_name='状态') + need_count = models.PositiveSmallIntegerField(default=1, verbose_name='目标队友数1-3') + joined_count = models.PositiveSmallIntegerField(default=0, verbose_name='已入队队友数') + share_mode = models.CharField(max_length=16, default='fixed', verbose_name='fixed|average') + per_person_amount = models.DecimalField( + max_digits=10, decimal_places=2, default=0, verbose_name='固定值人均', + ) + display_share_amount = models.DecimalField( + max_digits=10, decimal_places=2, default=0, verbose_name='大厅展示分红', + ) + commission_snap = models.DecimalField( + max_digits=10, decimal_places=2, default=0, verbose_name='开启时分成快照', + ) + order_amount_snap = models.DecimalField( + max_digits=10, decimal_places=2, default=0, verbose_name='订单金额快照', + ) + remark = models.CharField(max_length=200, blank=True, default='', verbose_name='组队备注') + leader_game_id = models.CharField(max_length=64, blank=True, default='', verbose_name='队长游戏ID') + min_deal_rate = models.DecimalField( + max_digits=8, decimal_places=6, null=True, blank=True, verbose_name='最低成交率0-1', + ) + max_fine_rate = models.DecimalField( + max_digits=8, decimal_places=6, null=True, blank=True, verbose_name='最高罚款率0-1', + ) + opened_at = models.DateTimeField(null=True, blank=True) + locked_at = models.DateTimeField(null=True, blank=True) + closed_at = models.DateTimeField(null=True, blank=True) + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'team_recruit' + indexes = [ + models.Index(fields=['club_id', 'status', '-opened_at']), + models.Index(fields=['leader_uid', 'status']), + ] + + +class TeamMember(QModel): + """组队成员。role: leader|member;join_status: 1在队 2清退""" + + ROLE_LEADER = 'leader' + ROLE_MEMBER = 'member' + JOIN_ACTIVE = 1 + JOIN_CLEARED = 2 + + id = models.BigAutoField(primary_key=True) + club_id = models.CharField(max_length=16, db_index=True) + recruit_id = models.BigIntegerField(db_index=True) + order_id = models.CharField(max_length=32, db_index=True) + user_uid = models.CharField(max_length=32, db_index=True) + role = models.CharField(max_length=16, default='member') + game_id = models.CharField(max_length=64, blank=True, default='') + share_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) + join_status = models.PositiveSmallIntegerField(default=1, db_index=True) + joined_at = models.DateTimeField(null=True, blank=True) + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'team_member' + constraints = [ + models.UniqueConstraint(fields=['recruit_id', 'user_uid'], name='uniq_team_member_recruit_uid'), + ] + indexes = [ + models.Index(fields=['order_id', 'join_status']), + models.Index(fields=['user_uid', 'join_status']), + ] + + +class TeamSettleLedger(QModel): + """组队分账幂等账本。""" + + id = models.BigAutoField(primary_key=True) + club_id = models.CharField(max_length=16, db_index=True) + order_id = models.CharField(max_length=32, db_index=True) + user_uid = models.CharField(max_length=32, db_index=True) + amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) + biz_id = models.CharField(max_length=64, unique=True) + CreateTime = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'team_settle_ledger' + + +class TeamActionLog(QModel): + id = models.BigAutoField(primary_key=True) + club_id = models.CharField(max_length=16, db_index=True, blank=True, default='') + order_id = models.CharField(max_length=32, db_index=True, blank=True, default='') + recruit_id = models.BigIntegerField(null=True, blank=True, db_index=True) + actor_uid = models.CharField(max_length=32, blank=True, default='') + action = models.CharField(max_length=32, db_index=True) + detail = models.TextField(blank=True, default='') + CreateTime = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'team_action_log' + + # =========================================================================== # 兼容别名(渐进迁移,全部迁移完成后可删除) # =========================================================================== diff --git a/orders/services/team_recruit.py b/orders/services/team_recruit.py new file mode 100644 index 0000000..991a5e5 --- /dev/null +++ b/orders/services/team_recruit.py @@ -0,0 +1,695 @@ +"""组队招募核心服务(新域)。无有效组队时所有挂钩函数立即返回,不影响旧逻辑。""" +from __future__ import annotations + +import logging +from decimal import Decimal, ROUND_DOWN +from typing import Optional + +from django.db import transaction +from django.db.models import F +from django.utils import timezone + +logger = logging.getLogger(__name__) + +MONEY = Decimal('0.01') + + +def _d(v) -> Decimal: + try: + return Decimal(str(v or 0)).quantize(MONEY) + except Exception: + return Decimal('0.00') + + +def _log(club_id, order_id, recruit_id, actor, action, detail=''): + try: + from orders.models import TeamActionLog + TeamActionLog( + club_id=(club_id or '')[:16], + order_id=(order_id or '')[:32], + recruit_id=recruit_id, + actor_uid=(actor or '')[:32], + action=(action or '')[:32], + detail=(detail or '')[:2000], + ).save() + except Exception as e: + logger.warning('TeamActionLog 失败: %s', e) + + +def calc_display_share(commission: Decimal, mode: str, need_count: int, per_person: Decimal) -> Decimal: + c = _d(commission) + need = max(1, min(3, int(need_count or 1))) + if mode == 'fixed': + return _d(per_person) + # average: C / (1+need) + n = 1 + need + if n <= 0 or c <= 0: + return Decimal('0.00') + return (c / Decimal(n)).quantize(MONEY, rounding=ROUND_DOWN) + + +def calc_lock_shares(commission: Decimal, mode: str, per_person: Decimal, leader_uid: str, member_uids: list) -> dict: + """返回 {uid: amount},合计等于 C(尾差给队长)。member_uids 不含队长。""" + c = _d(commission) + members = [u for u in (member_uids or []) if u and u != leader_uid] + n = 1 + len(members) + out = {} + if n <= 0 or c <= 0: + return {leader_uid: c} if leader_uid else {} + + if mode == 'fixed': + p = _d(per_person) + for uid in members: + out[uid] = p + out[leader_uid] = _d(c - p * len(members)) + if out[leader_uid] < 0: + out[leader_uid] = Decimal('0.00') + return out + + base = (c / Decimal(n)).quantize(MONEY, rounding=ROUND_DOWN) + for uid in members: + out[uid] = base + out[leader_uid] = _d(c - base * len(members)) + return out + + +def get_active_recruit(order_id: str): + from orders.models import TeamRecruit + return TeamRecruit.query.filter( + order_id=order_id, + status__in=[TeamRecruit.STATUS_RECRUITING, TeamRecruit.STATUS_LOCKED], + ).first() + + +def team_should_split(order) -> bool: + """结单是否走组队分账:已锁定(或招募中但已有人——进8会强制锁)且有在队成员。""" + from orders.models import TeamRecruit, TeamMember + oid = getattr(order, 'OrderID', None) or '' + if not oid: + return False + rec = TeamRecruit.query.filter( + order_id=oid, + status=TeamRecruit.STATUS_LOCKED, + ).first() + if not rec: + return False + return TeamMember.query.filter( + recruit_id=rec.id, join_status=TeamMember.JOIN_ACTIVE, + ).exists() + + +def _set_dashou_busy(uid: str, busy: bool): + from users.models import UserDashou + from users.business_models import User + u = User.query.filter(UserUID=uid).first() + if not u: + return + UserDashou.objects.filter(user=u).update(zhuangtai=0 if busy else 1) + + +def _subscribe_uids_to_order_group(order_id: str, uids: list): + """订阅 Ds{uid} 到 group_{order_id}。失败只打日志。""" + try: + from utils.chat_utils import _subscribe_users_to_group, _get_self_goeasy_appkey, _get_self_goeasy_secret + appkey = _get_self_goeasy_appkey() + secret = _get_self_goeasy_secret() + if not appkey: + return False + user_ids = [f'Ds{u}' for u in uids if u] + if not user_ids: + return False + return _subscribe_users_to_group(user_ids, [f'group_{order_id}'], appkey, secret) + except Exception as e: + logger.warning('组队拉群失败 order=%s: %s', order_id, e) + return False + + +@transaction.atomic +def open_recruit( + *, + order, + leader_uid: str, + need_count: int, + share_mode: str, + per_person_amount=None, + remark: str = '', + leader_game_id: str = '', + min_deal_rate=None, + max_fine_rate=None, +): + from orders.models import TeamRecruit, TeamMember + + oid = order.OrderID + club_id = (getattr(order, 'ClubID', None) or '').strip() or 'xq' + if int(order.Status or 0) != 2: + raise ValueError('仅进行中订单可开启组队') + if (order.PlayerID or '') != leader_uid: + raise ValueError('仅接单打手可开启组队') + if TeamRecruit.query.filter(order_id=oid).exclude( + status__in=[TeamRecruit.STATUS_CANCELLED, TeamRecruit.STATUS_VOID, TeamRecruit.STATUS_FINISHED] + ).exists(): + raise ValueError('本单已有进行中的组队') + + need = int(need_count or 0) + if need < 1 or need > 3: + raise ValueError('招募人数须为1~3') + mode = (share_mode or '').strip().lower() + if mode not in (TeamRecruit.MODE_FIXED, TeamRecruit.MODE_AVERAGE): + raise ValueError('请选择固定值或平均值') + + c = _d(order.PlayerCommission) + if c <= 0: + raise ValueError('打手分成无效') + + p = _d(per_person_amount) + if mode == TeamRecruit.MODE_FIXED: + if p <= 0: + raise ValueError('请设置固定人均金额') + if p * (1 + need) > c: + raise ValueError('人均×(队长+目标人数)不能超过打手分成') + game_id = (leader_game_id or '').strip() + if not game_id: + raise ValueError('请填写队长游戏ID') + + display = calc_display_share(c, mode, need, p) + now = timezone.now() + rec = TeamRecruit( + club_id=club_id, + order_id=oid, + leader_uid=leader_uid, + status=TeamRecruit.STATUS_RECRUITING, + need_count=need, + joined_count=0, + share_mode=mode, + per_person_amount=p if mode == TeamRecruit.MODE_FIXED else Decimal('0.00'), + display_share_amount=display, + commission_snap=c, + order_amount_snap=_d(order.Amount), + remark=(remark or '')[:200], + leader_game_id=game_id[:64], + min_deal_rate=_d(min_deal_rate) if min_deal_rate not in (None, '') else None, + max_fine_rate=_d(max_fine_rate) if max_fine_rate not in (None, '') else None, + opened_at=now, + ) + # Decimal null fields + if min_deal_rate in (None, ''): + rec.min_deal_rate = None + if max_fine_rate in (None, ''): + rec.max_fine_rate = None + rec.save() + + TeamMember( + club_id=club_id, + recruit_id=rec.id, + order_id=oid, + user_uid=leader_uid, + role=TeamMember.ROLE_LEADER, + game_id=game_id[:64], + share_amount=Decimal('0.00'), + join_status=TeamMember.JOIN_ACTIVE, + joined_at=now, + ).save() + + _subscribe_uids_to_order_group(oid, [leader_uid]) + _log(club_id, oid, rec.id, leader_uid, 'open', f'mode={mode} need={need} display={display}') + return rec + + +@transaction.atomic +def update_recruit(*, recruit_id: int, leader_uid: str, need_count=None, remark=None, + min_deal_rate='__omit__', max_fine_rate='__omit__'): + from orders.models import TeamRecruit, Order + + rec = TeamRecruit.objects.select_for_update().filter(id=recruit_id).first() + if not rec: + raise ValueError('组队不存在') + if rec.leader_uid != leader_uid: + raise ValueError('仅队长可修改') + if rec.status != TeamRecruit.STATUS_RECRUITING: + raise ValueError('仅招募中可修改') + + order = Order.query.filter(OrderID=rec.order_id).first() + if not order or int(order.Status or 0) != 2: + raise ValueError('订单非进行中,无法修改招募') + + if need_count is not None: + need = int(need_count) + if need < 1 or need > 3: + raise ValueError('招募人数须为1~3') + if need < int(rec.joined_count or 0): + raise ValueError('目标人数不能小于已入队人数') + if rec.share_mode == TeamRecruit.MODE_FIXED: + p = _d(rec.per_person_amount) + c = _d(order.PlayerCommission) or _d(rec.commission_snap) + if p * (1 + need) > c: + raise ValueError('人均×(队长+目标人数)不能超过打手分成') + rec.need_count = need + + if remark is not None: + rec.remark = (remark or '')[:200] + if min_deal_rate != '__omit__': + rec.min_deal_rate = _d(min_deal_rate) if min_deal_rate not in (None, '') else None + if max_fine_rate != '__omit__': + rec.max_fine_rate = _d(max_fine_rate) if max_fine_rate not in (None, '') else None + + c = _d(order.PlayerCommission) or _d(rec.commission_snap) + rec.display_share_amount = calc_display_share( + c, rec.share_mode, rec.need_count, rec.per_person_amount, + ) + rec.save() + _log(rec.club_id, rec.order_id, rec.id, leader_uid, 'update', f'need={rec.need_count}') + return rec + + +@transaction.atomic +def lock_recruit(rec_or_id, *, actor_uid='', reason='manual'): + from orders.models import TeamRecruit, TeamMember, Order + + rid = rec_or_id if isinstance(rec_or_id, int) else rec_or_id.id + rec = TeamRecruit.objects.select_for_update().filter(id=rid).first() + if not rec: + return None + if rec.status == TeamRecruit.STATUS_LOCKED: + return rec + if rec.status != TeamRecruit.STATUS_RECRUITING: + raise ValueError('当前状态不可锁定') + + order = Order.query.filter(OrderID=rec.order_id).first() + c = _d(getattr(order, 'PlayerCommission', None) if order else None) or _d(rec.commission_snap) + members = list( + TeamMember.query.filter(recruit_id=rec.id, join_status=TeamMember.JOIN_ACTIVE) + .exclude(role=TeamMember.ROLE_LEADER) + .values_list('user_uid', flat=True) + ) + shares = calc_lock_shares(c, rec.share_mode, rec.per_person_amount, rec.leader_uid, list(members)) + now = timezone.now() + for uid, amt in shares.items(): + TeamMember.objects.filter( + recruit_id=rec.id, user_uid=uid, join_status=TeamMember.JOIN_ACTIVE, + ).update(share_amount=amt, UpdateTime=now) + + rec.status = TeamRecruit.STATUS_LOCKED + rec.locked_at = now + rec.display_share_amount = shares.get( + next(iter(members), rec.leader_uid), + calc_display_share(c, rec.share_mode, rec.need_count, rec.per_person_amount), + ) + # 队友展示用:取任一队友份额,无队友则用队长外均分预览 + if members: + rec.display_share_amount = shares.get(members[0], Decimal('0.00')) + rec.save() + _log(rec.club_id, rec.order_id, rec.id, actor_uid, 'lock', reason) + return rec + + +def on_order_enter_status_8(order): + """进结算中:招募中强制锁定。不改 eligible。""" + try: + from orders.models import TeamRecruit + oid = getattr(order, 'OrderID', '') or '' + rec = TeamRecruit.query.filter(order_id=oid, status=TeamRecruit.STATUS_RECRUITING).first() + if rec: + lock_recruit(rec.id, actor_uid='system', reason='enter_status_8') + except Exception as e: + logger.error('组队进8锁定失败: %s', e) + + +@transaction.atomic +def join_recruit(*, recruit_id: int, user_uid: str, game_id: str, request=None): + from orders.models import TeamRecruit, TeamMember, Order + from users.models import UserDashou + from utils.fadan_utils import check_fadan_qiangdan_eligible + + game_id = (game_id or '').strip() + if not game_id: + raise ValueError('请填写游戏ID') + + rec = TeamRecruit.objects.select_for_update().filter(id=recruit_id).first() + if not rec: + raise ValueError('组队不存在') + if rec.status != TeamRecruit.STATUS_RECRUITING: + raise ValueError('招募已结束') + if int(rec.joined_count or 0) >= int(rec.need_count or 0): + raise ValueError('人数已满') + if rec.leader_uid == user_uid: + raise ValueError('队长无需加入') + if TeamMember.query.filter(recruit_id=rec.id, user_uid=user_uid, join_status=TeamMember.JOIN_ACTIVE).exists(): + raise ValueError('您已在队中') + + order = Order.query.filter(OrderID=rec.order_id).first() + if not order or int(order.Status or 0) != 2: + raise ValueError('订单状态不可加入') + if (getattr(order, 'ClubID', '') or '') != rec.club_id: + raise ValueError('俱乐部不匹配') + + # 忙碌 / 罚单 + try: + from users.business_models import User + user_obj = User.query.filter(UserUID=user_uid).first() + if not user_obj: + raise ValueError('用户不存在') + profile = UserDashou.query.filter(user=user_obj).first() + if not profile: + raise ValueError('非打手身份') + if int(getattr(profile, 'zhuangtai', 0) or 0) != 1: + raise ValueError('您有订单正在接待中') + if int(getattr(profile, 'zhanghaozhuangtai', 1) or 1) != 1: + raise ValueError('账号状态异常') + except ValueError: + raise + except Exception: + raise ValueError('打手信息异常') + + ok, msg = check_fadan_qiangdan_eligible(user_uid, 1) + if not ok: + raise ValueError(msg or '存在未处理罚单') + + # 不能同时在其它进行中组队 + busy_other = TeamMember.query.filter( + user_uid=user_uid, + join_status=TeamMember.JOIN_ACTIVE, + role=TeamMember.ROLE_MEMBER, + ).exclude(order_id=rec.order_id) + if busy_other.exists(): + # 仅当对应帖仍 recruiting/locked + from orders.models import TeamRecruit as TR + for m in busy_other[:5]: + r = TR.query.filter(id=m.recruit_id, status__in=[TR.STATUS_RECRUITING, TR.STATUS_LOCKED]).first() + if r: + raise ValueError('您已在其他组队中') + + # 可选指标门槛 + if rec.min_deal_rate is not None or rec.max_fine_rate is not None: + from jituan.models import DashouDealStat + from jituan.services.deal_stats import serialize_dashou_stat + row = DashouDealStat.query.filter(club_id=rec.club_id, dashou_uid=user_uid).first() + deal_rate = 0.0 + fine_rate = 0.0 + if row and int(row.order_count or 0) > 0: + s = serialize_dashou_stat(row) + deal_rate = float(s.get('deal_rate') or 0) + fine_rate = float(s.get('fined_rate') or 0) + if rec.min_deal_rate is not None and deal_rate < float(rec.min_deal_rate): + raise ValueError('成交率未达到队长要求') + if rec.max_fine_rate is not None and fine_rate > float(rec.max_fine_rate): + raise ValueError('罚款率超过队长要求') + + # 原单会员/押金 + if order.GrabRequirement == 1: + from jituan.services.huiyuan_bundle import user_has_huiyuan_access_any, resolve_order_membership_ids + required_ids = resolve_order_membership_ids({ + 'MembershipID': order.MembershipID, + 'GrabRequirement': order.GrabRequirement, + 'ProductTypeID': order.ProductTypeID, + }) + if not user_has_huiyuan_access_any(user_uid, required_ids, club_id=rec.club_id): + raise ValueError('未开通对应会员或会员已过期') + elif order.GrabRequirement == 2: + yajin_required = order.CommissionReq or 0 + if (profile.yajin or 0) < yajin_required: + raise ValueError(f'押金不足,需要{yajin_required}元') + + now = timezone.now() + TeamMember( + club_id=rec.club_id, + recruit_id=rec.id, + order_id=rec.order_id, + user_uid=user_uid, + role=TeamMember.ROLE_MEMBER, + game_id=game_id[:64], + share_amount=_d(rec.display_share_amount) if rec.share_mode == TeamRecruit.MODE_FIXED else Decimal('0.00'), + join_status=TeamMember.JOIN_ACTIVE, + joined_at=now, + ).save() + TeamRecruit.objects.filter(id=rec.id).update(joined_count=F('joined_count') + 1, UpdateTime=now) + rec.refresh_from_db() + _set_dashou_busy(user_uid, True) + _subscribe_uids_to_order_group(rec.order_id, [user_uid, rec.leader_uid]) + _log(rec.club_id, rec.order_id, rec.id, user_uid, 'join', game_id) + + if int(rec.joined_count or 0) >= int(rec.need_count or 0): + lock_recruit(rec.id, actor_uid=user_uid, reason='full') + rec.refresh_from_db() + return rec + + +@transaction.atomic +def clear_recruit(*, order_id: str, status: int, actor_uid='system', reason=''): + """取消/作废:清退成员、置空闲、零分账。status=4取消或5作废。""" + from orders.models import TeamRecruit, TeamMember + + rec = TeamRecruit.objects.select_for_update().filter(order_id=order_id).exclude( + status__in=[TeamRecruit.STATUS_FINISHED, TeamRecruit.STATUS_CANCELLED, TeamRecruit.STATUS_VOID], + ).first() + if not rec: + return None + members = list( + TeamMember.query.filter(recruit_id=rec.id, join_status=TeamMember.JOIN_ACTIVE).values_list('user_uid', 'role') + ) + now = timezone.now() + TeamMember.objects.filter(recruit_id=rec.id, join_status=TeamMember.JOIN_ACTIVE).update( + join_status=TeamMember.JOIN_CLEARED, UpdateTime=now, + ) + for uid, role in members: + if role == TeamMember.ROLE_MEMBER: + _set_dashou_busy(uid, False) + # 队长忙碌由订单主流程管理,此处不强制改队长 + rec.status = status + rec.closed_at = now + rec.save(update_fields=['status', 'closed_at', 'UpdateTime']) + _log(rec.club_id, order_id, rec.id, actor_uid, 'clear', f'{reason} ->{status}') + return rec + + +def on_order_refunded(order): + try: + from orders.models import TeamRecruit + clear_recruit(order_id=order.OrderID, status=TeamRecruit.STATUS_CANCELLED, reason='refund') + except Exception as e: + logger.error('组队退款清退失败: %s', e) + + +def on_order_change_player(order_id: str, actor_uid='system'): + try: + from orders.models import TeamRecruit + clear_recruit(order_id=order_id, status=TeamRecruit.STATUS_VOID, actor_uid=actor_uid, reason='change_player') + except Exception as e: + logger.error('组队换打手作废失败: %s', e) + + +@transaction.atomic +def settle_team_or_legacy(order, *, set_idle_leader=False) -> bool: + """ + 若应组队分账则多人入账并返回 True;否则返回 False 由调用方走原逻辑。 + 调用方须已在 atomic 且 ideally select_for_update 订单。 + """ + from orders.models import TeamRecruit, TeamMember, TeamSettleLedger, Order + from users.business_models import User + from jituan.services.fund_freeze import credit_dashou_order_settle + + oid = order.OrderID + if not team_should_split(order): + return False + + rec = TeamRecruit.objects.select_for_update().filter( + order_id=oid, status=TeamRecruit.STATUS_LOCKED, + ).first() + if not rec: + return False + + members = list( + TeamMember.query.filter(recruit_id=rec.id, join_status=TeamMember.JOIN_ACTIVE) + ) + if not members: + return False + + c = _d(order.PlayerCommission) + # 若份额未写齐,按锁定规则重算 + if any(_d(m.share_amount) <= 0 and m.role == TeamMember.ROLE_MEMBER for m in members) or \ + sum((_d(m.share_amount) for m in members), Decimal('0')) <= 0: + mate_uids = [m.user_uid for m in members if m.role == TeamMember.ROLE_MEMBER] + shares = calc_lock_shares(c, rec.share_mode, rec.per_person_amount, rec.leader_uid, mate_uids) + for m in members: + m.share_amount = shares.get(m.user_uid, Decimal('0.00')) + m.save(update_fields=['share_amount', 'UpdateTime']) + + total = sum((_d(m.share_amount) for m in members), Decimal('0.00')) + if total != c: + # 尾差修正到队长 + diff = _d(c - total) + for m in members: + if m.role == TeamMember.ROLE_LEADER: + m.share_amount = _d(m.share_amount) + diff + m.save(update_fields=['share_amount', 'UpdateTime']) + break + + for m in members: + amt = _d(m.share_amount) + biz_id = f'{oid}:team:{m.user_uid}' + if TeamSettleLedger.query.filter(biz_id=biz_id).exists(): + continue + try: + user = User.query.get(UserUID=m.user_uid) + profile = user.DashouProfile + except Exception: + logger.error('组队分账打手不存在 %s %s', oid, m.user_uid) + continue + credit_dashou_order_settle( + order=order, + dashou_profile=profile, + user_id=m.user_uid, + amount=amt, + set_idle_status=True, + increment_chengjiao=True, + ) + TeamSettleLedger( + club_id=rec.club_id, + order_id=oid, + user_uid=m.user_uid, + amount=amt, + biz_id=biz_id, + ).save() + + now = timezone.now() + rec.status = TeamRecruit.STATUS_FINISHED + rec.closed_at = now + rec.save(update_fields=['status', 'closed_at', 'UpdateTime']) + _log(rec.club_id, oid, rec.id, 'system', 'settle', f'total={c}') + return True + + +def maybe_split_penalties(*, order_id: str, total_amount, reason: str, applicant_id, applicant_identity, + affects_grabbing=1, club_id='', extra_fields=None) -> Optional[list]: + """ + 若有有效组队(锁定或招募中已有队友),为每人创建罚单并返回列表;否则返回 None 走原逻辑。 + """ + from orders.models import TeamRecruit, TeamMember, Penalty + from decimal import Decimal as D + + rec = TeamRecruit.query.filter( + order_id=order_id, + status__in=[TeamRecruit.STATUS_RECRUITING, TeamRecruit.STATUS_LOCKED], + ).first() + if not rec: + return None + members = list( + TeamMember.query.filter(recruit_id=rec.id, join_status=TeamMember.JOIN_ACTIVE) + ) + if len(members) < 2: + return None + + total = _d(total_amount) + k = len(members) + base = (total / D(k)).quantize(MONEY, rounding=ROUND_DOWN) + amounts = [base] * k + amounts[0] = _d(total - base * (k - 1)) # 尾差给列表第一人(队长优先排前面) + + # 队长优先 + members.sort(key=lambda m: 0 if m.role == TeamMember.ROLE_LEADER else 1) + amounts = [base] * k + amounts[0] = _d(total - base * (k - 1)) + + reason_text = f'【组队均摊】{reason or ""}' + created = [] + cid = (club_id or rec.club_id or 'xq')[:16] + for i, m in enumerate(members): + p = Penalty( + PenalizedUserID=m.user_uid, + ApplicantID=applicant_id, + Identity=1, + Reason=reason_text, + FineAmount=amounts[i], + RelatedOrderID=order_id, + Status=1, + AffectsGrabbing=affects_grabbing, + ApplicantIdentity=applicant_identity, + ClubID=cid, + ) + p.save() + created.append(p) + # 调用方负责 lock_penalty_bonus / 证据图;此处只创建罚单记录 + _log(rec.club_id, order_id, rec.id, applicant_id or '', 'penalty_split', f'n={k} total={total}') + return created + + +def serialize_recruit_public(rec, order=None, *, include_game_ids=False) -> dict: + """大厅公开字段;游戏ID默认不返回。""" + from orders.models import Order, MerchantOrderExt + from jituan.services.order_deal import order_auto_settle_payload + from jituan.models import DashouDealStat + from jituan.services.deal_stats import serialize_dashou_stat + from users.business_models import User + from backend.utils import fmt_datetime + + order = order or Order.query.filter(OrderID=rec.order_id).first() + need = int(rec.need_count or 0) + joined = int(rec.joined_count or 0) + data = { + 'recruit_id': rec.id, + 'order_id': rec.order_id, + 'club_id': rec.club_id, + 'status': rec.status, + 'need_count': need, + 'joined_count': joined, + 'remain_count': max(0, need - joined), + 'share_mode': rec.share_mode, + 'display_share_amount': float(_d(rec.display_share_amount)), + 'per_person_amount': float(_d(rec.per_person_amount)), + 'remark': rec.remark or '', + 'leader_uid': rec.leader_uid, + 'leader_nicheng': '', + 'min_deal_rate': float(rec.min_deal_rate) if rec.min_deal_rate is not None else None, + 'max_fine_rate': float(rec.max_fine_rate) if rec.max_fine_rate is not None else None, + 'opened_at': fmt_datetime(rec.opened_at) if rec.opened_at else '', + 'locked_at': fmt_datetime(rec.locked_at) if rec.locked_at else '', + 'fadanpingtai': getattr(order, 'Platform', None) if order else None, + 'fadanpingtai_text': '', + 'leixing_id': getattr(order, 'ProductTypeID', None) if order else None, + 'jieshao': (order.Description or '') if order else '', + 'tupian': (order.ImageURL or '') if order else '', + 'order_zhuangtai': getattr(order, 'Status', None) if order else None, + 'shangjia_nicheng': '', + 'leader_deal_stat': None, + } + if order: + data['fadanpingtai_text'] = '平台单' if order.Platform == 1 else ('商家单' if order.Platform == 2 else '') + if order.Platform == 2: + try: + ext = order.shangjia_kuozhan + data['shangjia_nicheng'] = ext.MerchantNickname or '' + except Exception: + pass + # 自动结算字段:仅锁定且订单结算中 + if rec.status == 2 and int(order.Status or 0) == 8: + data.update(order_auto_settle_payload(order)) + + try: + u = User.query.filter(UserUID=rec.leader_uid).select_related('DashouProfile').first() + if u and getattr(u, 'DashouProfile', None): + data['leader_nicheng'] = u.DashouProfile.nicheng or '' + except Exception: + pass + + row = DashouDealStat.query.filter(club_id=rec.club_id, dashou_uid=rec.leader_uid).first() + if row and int(row.order_count or 0) > 0: + s = serialize_dashou_stat(row) + data['leader_deal_stat'] = { + 'deal_rate': s['deal_rate'], + 'fined_rate': s['fined_rate'], + 'deal_rate_text': f"{round(s['deal_rate'] * 100, 1)}%", + 'fined_rate_text': f"{round(s['fined_rate'] * 100, 1)}%", + } + + if include_game_ids: + from orders.models import TeamMember + mems = TeamMember.query.filter(recruit_id=rec.id, join_status=1) + data['members'] = [ + { + 'user_uid': m.user_uid, + 'role': m.role, + 'game_id': m.game_id, + 'share_amount': float(_d(m.share_amount)), + } + for m in mems + ] + return data diff --git a/orders/signals.py b/orders/signals.py index 6b53f5c..bebb2ca 100644 --- a/orders/signals.py +++ b/orders/signals.py @@ -54,6 +54,11 @@ def handle_order_status_8(sender, instance, **kwargs): old = Order.query.filter(pk=instance.pk).values_list('Status', flat=True).first() if old != 8 and instance.Status == 8: apply_enter_status_8(instance) + try: + from orders.services.team_recruit import on_order_enter_status_8 + on_order_enter_status_8(instance) + except Exception as e: + logger.error(f'组队进结算中锁定失败: {e}') elif old == 8 and instance.Status != 8: # 成交时长:在清空 SettlementTime 前挂到实例上供 post_save 指标使用 instance._deal_settle_started_at = instance.SettlementTime @@ -77,5 +82,10 @@ def handle_order_deal_stats(sender, instance, created, **kwargs): ds.on_order_completed(instance, settle_started_at=started) elif instance.Status == 5: ds.on_order_refunded(instance) + try: + from orders.services.team_recruit import on_order_refunded + on_order_refunded(instance) + except Exception as e: + logger.error(f'组队退款清退失败: {e}') except Exception as e: logger.error(f'成交指标信号处理失败: {e}') diff --git a/orders/urls_zudui.py b/orders/urls_zudui.py new file mode 100644 index 0000000..e60be78 --- /dev/null +++ b/orders/urls_zudui.py @@ -0,0 +1,20 @@ +from django.urls import path +from orders.views.zudui import ( + ZuduiKaqiView, + ZuduiGaiyaoqiuView, + ZuduiGuantingView, + ZuduiDatingView, + ZuduiXiangqingView, + ZuduiRuiduiView, + ZuduiWodeView, +) + +urlpatterns = [ + path('kaqi', ZuduiKaqiView.as_view()), + path('gaiyaoqiu', ZuduiGaiyaoqiuView.as_view()), + path('guanting', ZuduiGuantingView.as_view()), + path('dating', ZuduiDatingView.as_view()), + path('xiangqing', ZuduiXiangqingView.as_view()), + path('ruidui', ZuduiRuiduiView.as_view()), + path('wode', ZuduiWodeView.as_view()), +] diff --git a/orders/views/admin.py b/orders/views/admin.py index fd70dbf..83f9b59 100644 --- a/orders/views/admin.py +++ b/orders/views/admin.py @@ -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({ diff --git a/orders/views/dashou.py b/orders/views/dashou.py index d9476b4..4eea90d 100644 --- a/orders/views/dashou.py +++ b/orders/views/dashou.py @@ -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( diff --git a/orders/views/merchant.py b/orders/views/merchant.py index 46bdcb1..bb32b12 100644 --- a/orders/views/merchant.py +++ b/orders/views/merchant.py @@ -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( diff --git a/orders/views/merchant_penalty.py b/orders/views/merchant_penalty.py index f37b602..ab4764b 100644 --- a/orders/views/merchant_penalty.py +++ b/orders/views/merchant_penalty.py @@ -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, diff --git a/orders/views/zudui.py b/orders/views/zudui.py new file mode 100644 index 0000000..a0e46ff --- /dev/null +++ b/orders/views/zudui.py @@ -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 + ], + }, + }) diff --git a/users/views/kefu_orders.py b/users/views/kefu_orders.py index e875089..6bde545 100644 --- a/users/views/kefu_orders.py +++ b/users/views/kefu_orders.py @@ -602,6 +602,12 @@ class KefuChangeDashouView(APIView): order.AssignedCS = current_user.UserUID # 记录处理客服ID order.save() + try: + from orders.services.team_recruit import on_order_change_player + on_order_change_player(order.OrderID, actor_uid=getattr(current_user, 'UserUID', '') or 'kefu') + except Exception as e: + logger.error(f'组队换打手作废失败: {e}') + return Response({'code': 0, 'msg': '更换打手成功'})