feat: 组队招募(/zudui)+ 结单/自动结算/罚款挂钩
新增 Team 表与接口;进8强制锁、组队分账优先且失败不回退全额队长。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,6 +14,8 @@ urlpatterns = [
|
|||||||
|
|
||||||
# 订单相关路由
|
# 订单相关路由
|
||||||
path('dingdan/', include('orders.urls')),
|
path('dingdan/', include('orders.urls')),
|
||||||
|
# 组队
|
||||||
|
path('zudui/', include('orders.urls_zudui')),
|
||||||
|
|
||||||
# 商品/陪玩服务相关路由
|
# 商品/陪玩服务相关路由
|
||||||
path('shangpin/', include('products.urls')),
|
path('shangpin/', include('products.urls')),
|
||||||
|
|||||||
@@ -583,8 +583,13 @@ class FineApplyView(APIView):
|
|||||||
if not dashou_id:
|
if not dashou_id:
|
||||||
return Response({'code': 400, 'msg': '该订单无接单打手,无法罚款'})
|
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': '该打手已被罚款过'})
|
return Response({'code': 400, 'msg': '该打手已被罚款过'})
|
||||||
|
|
||||||
# 检查权限(打手身份 shenfen=1)
|
# 检查权限(打手身份 shenfen=1)
|
||||||
@@ -610,34 +615,57 @@ class FineApplyView(APIView):
|
|||||||
return Response({'code': 500, 'msg': '图片上传失败'})
|
return Response({'code': 500, 'msg': '图片上传失败'})
|
||||||
uploaded_urls.append(file_path)
|
uploaded_urls.append(file_path)
|
||||||
|
|
||||||
# 创建罚单
|
# 创建罚单(组队均摊)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
penalty = Penalty.query.create(
|
from orders.services.team_recruit import maybe_split_penalties
|
||||||
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 users.fadan_fenhong_utils import lock_penalty_bonus
|
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}")
|
logger.info(f"客服罚款申请成功: 订单 {OrderID}, 打手 {dashou_id}, 金额 {FineAmount}")
|
||||||
return Response({'code': 0, 'msg': '罚款已生成', 'data': {'id': penalty.id}})
|
return Response({'code': 0, 'msg': '罚款已生成', 'data': {'id': penalty.id}})
|
||||||
|
|||||||
@@ -207,12 +207,43 @@ def mark_order_completed_unified(order, *, source='auto_settle', operator=''):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False, '打手不存在'
|
return False, '打手不存在'
|
||||||
|
|
||||||
credit_dashou_order_settle(
|
# 组队分账优先;无有效组队则全额给队长(原逻辑)
|
||||||
order=locked,
|
# 若本应组队分账却失败/异常:禁止回退全额队长(避免队友白做或重复入账)
|
||||||
dashou_profile=dashou_profile,
|
try:
|
||||||
user_id=dashou_id,
|
from orders.services.team_recruit import settle_team_or_legacy, get_active_recruit, team_should_split
|
||||||
amount=dashou_fencheng,
|
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:
|
if locked.Platform == 2:
|
||||||
try:
|
try:
|
||||||
|
|||||||
112
orders/migrations/0017_team_recruit.py
Normal file
112
orders/migrations/0017_team_recruit.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
119
orders/models.py
119
orders/models.py
@@ -1138,6 +1138,125 @@ class FakeGrabOrderShuffleState(QModel):
|
|||||||
db_table = 'fake_grab_order_shuffle'
|
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'
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# 兼容别名(渐进迁移,全部迁移完成后可删除)
|
# 兼容别名(渐进迁移,全部迁移完成后可删除)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
695
orders/services/team_recruit.py
Normal file
695
orders/services/team_recruit.py
Normal file
@@ -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
|
||||||
@@ -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()
|
old = Order.query.filter(pk=instance.pk).values_list('Status', flat=True).first()
|
||||||
if old != 8 and instance.Status == 8:
|
if old != 8 and instance.Status == 8:
|
||||||
apply_enter_status_8(instance)
|
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:
|
elif old == 8 and instance.Status != 8:
|
||||||
# 成交时长:在清空 SettlementTime 前挂到实例上供 post_save 指标使用
|
# 成交时长:在清空 SettlementTime 前挂到实例上供 post_save 指标使用
|
||||||
instance._deal_settle_started_at = instance.SettlementTime
|
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)
|
ds.on_order_completed(instance, settle_started_at=started)
|
||||||
elif instance.Status == 5:
|
elif instance.Status == 5:
|
||||||
ds.on_order_refunded(instance)
|
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:
|
except Exception as e:
|
||||||
logger.error(f'成交指标信号处理失败: {e}')
|
logger.error(f'成交指标信号处理失败: {e}')
|
||||||
|
|||||||
20
orders/urls_zudui.py
Normal file
20
orders/urls_zudui.py
Normal file
@@ -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()),
|
||||||
|
]
|
||||||
@@ -489,6 +489,11 @@ class AdGengHuanDaShou(APIView):
|
|||||||
dingdan_obj.PlayerID = new_dashou_id
|
dingdan_obj.PlayerID = new_dashou_id
|
||||||
dingdan_obj.Status = 2 # 进行中状态
|
dingdan_obj.Status = 2 # 进行中状态
|
||||||
dingdan_obj.save()
|
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. 返回成功响应
|
# 13. 返回成功响应
|
||||||
return Response({
|
return Response({
|
||||||
|
|||||||
@@ -1264,8 +1264,20 @@ class DashouDingdanXiangqingView(APIView):
|
|||||||
'data': None
|
'data': None
|
||||||
}, status=status.HTTP_404_NOT_FOUND)
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
# 4. 验证订单是否属于当前打手
|
# 4. 验证:接单打手,或本单在队成员(含已分账结束仍在队)
|
||||||
if dingdan.PlayerID != yonghuid:
|
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({
|
return Response({
|
||||||
'code': 4,
|
'code': 4,
|
||||||
'msg': '没有权限查看此订单',
|
'msg': '没有权限查看此订单',
|
||||||
@@ -1292,6 +1304,24 @@ class DashouDingdanXiangqingView(APIView):
|
|||||||
dingdan_data.update(order_auto_settle_payload(dingdan))
|
dingdan_data.update(order_auto_settle_payload(dingdan))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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. 查询打手提交的图片
|
# 6. 查询打手提交的图片
|
||||||
dashou_tupian_list = PlayerDeliveryImage.query.filter(
|
dashou_tupian_list = PlayerDeliveryImage.query.filter(
|
||||||
|
|||||||
@@ -888,12 +888,35 @@ class ShangjiaJiesuanView(APIView):
|
|||||||
|
|
||||||
# 原子更新打手数据(可提现走冻结网关;未开启时与原先全额进 yue 一致)
|
# 原子更新打手数据(可提现走冻结网关;未开启时与原先全额进 yue 一致)
|
||||||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||||||
credit_dashou_order_settle(
|
from orders.services.team_recruit import settle_team_or_legacy, get_active_recruit, lock_recruit
|
||||||
order=order,
|
from orders.models import TeamRecruit, TeamSettleLedger
|
||||||
dashou_profile=dashou,
|
team_done = False
|
||||||
user_id=jiedan_dashou_id,
|
try:
|
||||||
amount=dashou_fencheng,
|
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(
|
UserShangjia.query.filter(id=shangjia.id).update(
|
||||||
|
|||||||
@@ -168,7 +168,12 @@ class ShangjiaFakuanApplyView(APIView):
|
|||||||
return Response({'code': 4, 'msg': '该订单暂无接单打手,无法罚款'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'code': 4, 'msg': '该订单暂无接单打手,无法罚款'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# ========== 5. 重复罚款检查 ==========
|
# ========== 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)
|
return Response({'code': 5, 'msg': '该打手在此订单已被罚款,不能重复申请'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# ========== 6. 本地创建罚单 ==========
|
# ========== 6. 本地创建罚单 ==========
|
||||||
@@ -180,25 +185,43 @@ class ShangjiaFakuanApplyView(APIView):
|
|||||||
from utils.penalty_status import PENALTY_PLATFORM_AUDIT
|
from utils.penalty_status import PENALTY_PLATFORM_AUDIT
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
fadan = Penalty.query.create(
|
# 组队均摊:有效组队则每人一张罚单
|
||||||
PenalizedUserID=dashou_id,
|
from orders.services.team_recruit import maybe_split_penalties
|
||||||
ApplicantID=shangjia_id, # 申请人为当前商家
|
club_for_p = resolve_penalty_club_id(
|
||||||
Identity=1, # 被打者身份:1=打手
|
order=order,
|
||||||
Reason=chufa_liyou,
|
penalized_user_id=dashou_id,
|
||||||
FineAmount=fakuanjine,
|
request=request,
|
||||||
RelatedOrderID=dingdan_id,
|
applicant_id=shangjia_id,
|
||||||
Status=PENALTY_PLATFORM_AUDIT, # 平台审核中,通过后变为待缴纳
|
user=current_user,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
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.objects.bulk_create([
|
||||||
PenaltyAppealImage(
|
PenaltyAppealImage(
|
||||||
Penalty=fadan,
|
Penalty=fadan,
|
||||||
@@ -207,8 +230,23 @@ class ShangjiaFakuanApplyView(APIView):
|
|||||||
Purpose=1,
|
Purpose=1,
|
||||||
)
|
)
|
||||||
for url in evidence_urls
|
for url in evidence_urls
|
||||||
])
|
]) if not split else None
|
||||||
lock_penalty_bonus(fadan)
|
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}")
|
logger.info(f"商家{shangjia_id}对订单{dingdan_id}打手{dashou_id}提交罚款申请(平台审核),金额{fakuanjine}")
|
||||||
record_order_action_from_request(
|
record_order_action_from_request(
|
||||||
request,
|
request,
|
||||||
|
|||||||
284
orders/views/zudui.py
Normal file
284
orders/views/zudui.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"""组队 API:/zudui/*"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from rest_framework.parsers import JSONParser
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from jituan.services.club_context import resolve_club_id_from_request
|
||||||
|
from orders.models import Order, TeamRecruit, TeamMember
|
||||||
|
from orders.services import team_recruit as team_svc
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _uid(request):
|
||||||
|
return getattr(request.user, 'UserUID', '') or ''
|
||||||
|
|
||||||
|
|
||||||
|
def _club(request):
|
||||||
|
return (resolve_club_id_from_request(request) or '').strip()
|
||||||
|
|
||||||
|
|
||||||
|
class ZuduiKaqiView(APIView):
|
||||||
|
"""开启组队 POST /zudui/kaqi"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
uid = _uid(request)
|
||||||
|
oid = str(request.data.get('dingdan_id') or request.data.get('order_id') or '').strip()
|
||||||
|
if not oid:
|
||||||
|
return Response({'code': 400, 'msg': '缺少订单ID'})
|
||||||
|
order = Order.query.filter(OrderID=oid).first()
|
||||||
|
if not order:
|
||||||
|
return Response({'code': 404, 'msg': '订单不存在'})
|
||||||
|
club = _club(request)
|
||||||
|
if club and (order.ClubID or '') != club:
|
||||||
|
return Response({'code': 403, 'msg': '俱乐部不匹配'})
|
||||||
|
try:
|
||||||
|
rec = team_svc.open_recruit(
|
||||||
|
order=order,
|
||||||
|
leader_uid=uid,
|
||||||
|
need_count=int(request.data.get('need_count') or 1),
|
||||||
|
share_mode=request.data.get('share_mode') or 'fixed',
|
||||||
|
per_person_amount=request.data.get('per_person_amount'),
|
||||||
|
remark=request.data.get('remark') or '',
|
||||||
|
leader_game_id=request.data.get('leader_game_id') or request.data.get('game_id') or '',
|
||||||
|
min_deal_rate=request.data.get('min_deal_rate'),
|
||||||
|
max_fine_rate=request.data.get('max_fine_rate'),
|
||||||
|
)
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '已开启组队',
|
||||||
|
'data': team_svc.serialize_recruit_public(rec, order, include_game_ids=True),
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return Response({'code': 400, 'msg': str(e)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('开启组队失败')
|
||||||
|
return Response({'code': 500, 'msg': '服务器错误'})
|
||||||
|
|
||||||
|
|
||||||
|
class ZuduiGaiyaoqiuView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
uid = _uid(request)
|
||||||
|
rid = request.data.get('recruit_id')
|
||||||
|
try:
|
||||||
|
rid = int(rid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response({'code': 400, 'msg': '缺少 recruit_id'})
|
||||||
|
try:
|
||||||
|
kw = {'recruit_id': rid, 'leader_uid': uid}
|
||||||
|
if 'need_count' in request.data:
|
||||||
|
kw['need_count'] = request.data.get('need_count')
|
||||||
|
if 'remark' in request.data:
|
||||||
|
kw['remark'] = request.data.get('remark')
|
||||||
|
if 'min_deal_rate' in request.data:
|
||||||
|
kw['min_deal_rate'] = request.data.get('min_deal_rate')
|
||||||
|
if 'max_fine_rate' in request.data:
|
||||||
|
kw['max_fine_rate'] = request.data.get('max_fine_rate')
|
||||||
|
rec = team_svc.update_recruit(**kw)
|
||||||
|
order = Order.query.filter(OrderID=rec.order_id).first()
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '已更新',
|
||||||
|
'data': team_svc.serialize_recruit_public(rec, order, include_game_ids=True),
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return Response({'code': 400, 'msg': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class ZuduiGuantingView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
uid = _uid(request)
|
||||||
|
rid = request.data.get('recruit_id')
|
||||||
|
try:
|
||||||
|
rid = int(rid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response({'code': 400, 'msg': '缺少 recruit_id'})
|
||||||
|
rec = TeamRecruit.query.filter(id=rid).first()
|
||||||
|
if not rec:
|
||||||
|
return Response({'code': 404, 'msg': '组队不存在'})
|
||||||
|
if rec.leader_uid != uid:
|
||||||
|
return Response({'code': 403, 'msg': '仅队长可关停'})
|
||||||
|
try:
|
||||||
|
rec = team_svc.lock_recruit(rid, actor_uid=uid, reason='manual')
|
||||||
|
order = Order.query.filter(OrderID=rec.order_id).first()
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '已关停招募',
|
||||||
|
'data': team_svc.serialize_recruit_public(rec, order, include_game_ids=True),
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return Response({'code': 400, 'msg': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class ZuduiDatingView(APIView):
|
||||||
|
"""组队大厅:仅招募中。"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
club = _club(request)
|
||||||
|
if not club:
|
||||||
|
return Response({'code': 400, 'msg': '缺少俱乐部'})
|
||||||
|
page = max(1, int(request.data.get('page') or 1))
|
||||||
|
page_size = min(50, max(1, int(request.data.get('page_size') or 10)))
|
||||||
|
leixing = request.data.get('leixing_id')
|
||||||
|
pingtai = request.data.get('fadanpingtai')
|
||||||
|
|
||||||
|
qs = TeamRecruit.query.filter(
|
||||||
|
club_id=club,
|
||||||
|
status=TeamRecruit.STATUS_RECRUITING,
|
||||||
|
).order_by('-opened_at')
|
||||||
|
|
||||||
|
# 可选:按订单类型过滤
|
||||||
|
order_ids = None
|
||||||
|
if leixing not in (None, '') or pingtai not in (None, ''):
|
||||||
|
oq = Order.query.filter(ClubID=club, Status=2)
|
||||||
|
if leixing not in (None, ''):
|
||||||
|
try:
|
||||||
|
oq = oq.filter(ProductTypeID=int(leixing))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if pingtai not in (None, ''):
|
||||||
|
try:
|
||||||
|
oq = oq.filter(Platform=int(pingtai))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
order_ids = list(oq.values_list('OrderID', flat=True)[:500])
|
||||||
|
qs = qs.filter(order_id__in=order_ids or ['__none__'])
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
rows = list(qs[start:start + page_size])
|
||||||
|
oids = [r.order_id for r in rows]
|
||||||
|
orders = {o.OrderID: o for o in Order.query.filter(OrderID__in=oids)} if oids else {}
|
||||||
|
data_list = [
|
||||||
|
team_svc.serialize_recruit_public(r, orders.get(r.order_id), include_game_ids=False)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': 'ok',
|
||||||
|
'data': {
|
||||||
|
'list': data_list,
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
'has_more': start + len(rows) < total,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ZuduiXiangqingView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
uid = _uid(request)
|
||||||
|
rid = request.data.get('recruit_id')
|
||||||
|
oid = str(request.data.get('dingdan_id') or request.data.get('order_id') or '').strip()
|
||||||
|
rec = None
|
||||||
|
if rid:
|
||||||
|
try:
|
||||||
|
rec = TeamRecruit.query.filter(id=int(rid)).first()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if not rec and oid:
|
||||||
|
rec = TeamRecruit.query.filter(order_id=oid).order_by('-id').first()
|
||||||
|
if not rec:
|
||||||
|
return Response({'code': 404, 'msg': '组队不存在'})
|
||||||
|
|
||||||
|
is_member = TeamMember.query.filter(
|
||||||
|
recruit_id=rec.id, user_uid=uid, join_status=TeamMember.JOIN_ACTIVE,
|
||||||
|
).exists()
|
||||||
|
include_game = is_member or rec.leader_uid == uid
|
||||||
|
order = Order.query.filter(OrderID=rec.order_id).first()
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': 'ok',
|
||||||
|
'data': team_svc.serialize_recruit_public(rec, order, include_game_ids=include_game),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ZuduiRuiduiView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
uid = _uid(request)
|
||||||
|
try:
|
||||||
|
rid = int(request.data.get('recruit_id'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response({'code': 400, 'msg': '缺少 recruit_id'})
|
||||||
|
try:
|
||||||
|
rec = team_svc.join_recruit(
|
||||||
|
recruit_id=rid,
|
||||||
|
user_uid=uid,
|
||||||
|
game_id=request.data.get('game_id') or '',
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
order = Order.query.filter(OrderID=rec.order_id).first()
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '加入成功',
|
||||||
|
'data': team_svc.serialize_recruit_public(rec, order, include_game_ids=True),
|
||||||
|
})
|
||||||
|
except ValueError as e:
|
||||||
|
return Response({'code': 400, 'msg': str(e)})
|
||||||
|
except Exception:
|
||||||
|
logger.exception('加入组队失败')
|
||||||
|
return Response({'code': 500, 'msg': '服务器错误'})
|
||||||
|
|
||||||
|
|
||||||
|
class ZuduiWodeView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
uid = _uid(request)
|
||||||
|
club = _club(request)
|
||||||
|
as_leader = TeamRecruit.query.filter(
|
||||||
|
club_id=club,
|
||||||
|
leader_uid=uid,
|
||||||
|
status__in=[TeamRecruit.STATUS_RECRUITING, TeamRecruit.STATUS_LOCKED],
|
||||||
|
).order_by('-opened_at')[:50]
|
||||||
|
member_rids = list(
|
||||||
|
TeamMember.query.filter(
|
||||||
|
user_uid=uid, join_status=TeamMember.JOIN_ACTIVE, role=TeamMember.ROLE_MEMBER,
|
||||||
|
).values_list('recruit_id', flat=True)[:50]
|
||||||
|
)
|
||||||
|
as_member_qs = TeamRecruit.query.filter(
|
||||||
|
id__in=member_rids or [0],
|
||||||
|
club_id=club,
|
||||||
|
status__in=[TeamRecruit.STATUS_RECRUITING, TeamRecruit.STATUS_LOCKED],
|
||||||
|
) if member_rids and club else []
|
||||||
|
as_member = list(as_member_qs) if as_member_qs else []
|
||||||
|
as_leader_list = list(as_leader)
|
||||||
|
oids = [r.order_id for r in as_leader_list + as_member]
|
||||||
|
orders = {o.OrderID: o for o in Order.query.filter(OrderID__in=oids)} if oids else {}
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': 'ok',
|
||||||
|
'data': {
|
||||||
|
'as_leader': [
|
||||||
|
team_svc.serialize_recruit_public(r, orders.get(r.order_id), include_game_ids=True)
|
||||||
|
for r in as_leader_list
|
||||||
|
],
|
||||||
|
'as_member': [
|
||||||
|
team_svc.serialize_recruit_public(r, orders.get(r.order_id), include_game_ids=True)
|
||||||
|
for r in as_member
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -602,6 +602,12 @@ class KefuChangeDashouView(APIView):
|
|||||||
order.AssignedCS = current_user.UserUID # 记录处理客服ID
|
order.AssignedCS = current_user.UserUID # 记录处理客服ID
|
||||||
order.save()
|
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': '更换打手成功'})
|
return Response({'code': 0, 'msg': '更换打手成功'})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user