组队接口一律校验 X-Club-Id=订单=帖;不走跨店互通。分账 biz_id 按队员区分。 Co-authored-by: Cursor <cursoragent@cursor.com>
702 lines
26 KiB
Python
702 lines
26 KiB
Python
"""组队招募核心服务(新域)。无有效组队时所有挂钩函数立即返回,不影响旧逻辑。"""
|
||
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, require_club_id=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 require_club_id and (rec.club_id or '') != require_club_id:
|
||
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('俱乐部不匹配')
|
||
if require_club_id and (order.ClubID or '') != require_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,
|
||
settle_biz_id=biz_id,
|
||
)
|
||
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
|