Files
Django/jituan/services/order_deal.py
XingQue 7002da6093 feat: 组队招募(/zudui)+ 结单/自动结算/罚款挂钩
新增 Team 表与接口;进8强制锁、组队分账优先且失败不回退全额队长。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 03:12:04 +08:00

316 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""成交指标 + 可控自动结算(俱乐部开关,默认全关)。
对齐设计文档成交率与自动结算_设计方案.md
"""
from __future__ import annotations
import logging
from datetime import timedelta
from decimal import Decimal
from django.db import transaction
from django.db.models import F
from django.utils import timezone
logger = logging.getLogger(__name__)
def get_or_create_deal_config(club_id: str):
from jituan.models import ClubOrderDealConfig
cid = (club_id or '').strip()
if not cid:
return None
row = ClubOrderDealConfig.query.filter(club_id=cid).first()
if not row:
row = ClubOrderDealConfig(club_id=cid)
row.save()
return row
def order_auto_settle_payload(order) -> dict:
"""
小程序列表/详情展示用。
仅 Status=8 且 AutoSettleEligible 且有 AutoExpireAt 时返回字段;否则空 dict。
前端有字段才展示,避免关开关/老单误报。
"""
from backend.utils import fmt_datetime
if not order:
return {}
try:
status = int(getattr(order, 'Status', 0) or 0)
except (TypeError, ValueError):
status = 0
if status != 8:
return {}
if not bool(getattr(order, 'AutoSettleEligible', False)):
return {}
expire_at = getattr(order, 'AutoExpireAt', None)
if not expire_at:
return {}
now = timezone.now()
# 与项目 USE_TZ 对齐,避免剩余秒数偏差
if timezone.is_aware(expire_at) and timezone.is_naive(now):
now = timezone.make_aware(now, timezone.get_current_timezone())
elif timezone.is_naive(expire_at) and timezone.is_aware(now):
expire_at = timezone.make_aware(expire_at, timezone.get_current_timezone())
remain = int((expire_at - now).total_seconds())
return {
'auto_settle_eligible': True,
'auto_expire_at': fmt_datetime(expire_at),
'auto_settle_remain_seconds': max(0, remain),
}
def config_to_dict(row) -> dict:
if not row:
return {
'club_id': '',
'auto_settle_enabled': False,
'auto_settle_enabled_at': '',
'hours_platform': 48,
'hours_merchant': 48,
'stats_enabled': False,
'stats_since': '',
}
return {
'club_id': row.club_id,
'auto_settle_enabled': bool(row.auto_settle_enabled),
'auto_settle_enabled_at': row.auto_settle_enabled_at.isoformat() if row.auto_settle_enabled_at else '',
'hours_platform': int(row.hours_platform or 48),
'hours_merchant': int(row.hours_merchant or 48),
'stats_enabled': bool(row.stats_enabled),
'stats_since': row.stats_since.isoformat() if row.stats_since else '',
}
def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict:
"""保存配置。关自动结算时清空本俱乐部所有 eligible。"""
row = get_or_create_deal_config(club_id)
if not row:
raise ValueError('俱乐部不能为空')
was_auto = bool(row.auto_settle_enabled)
was_stats = bool(row.stats_enabled)
now = timezone.now()
if 'hours_platform' in data and data['hours_platform'] is not None:
row.hours_platform = max(1, min(int(data['hours_platform']), 720))
if 'hours_merchant' in data and data['hours_merchant'] is not None:
row.hours_merchant = max(1, min(int(data['hours_merchant']), 720))
if 'auto_settle_enabled' in data and data['auto_settle_enabled'] is not None:
enabled = bool(data['auto_settle_enabled'])
row.auto_settle_enabled = enabled
if enabled and not was_auto:
row.auto_settle_enabled_at = now
if not enabled:
row.auto_settle_enabled_at = None
_clear_club_auto_settle_eligible(row.club_id)
if 'stats_enabled' in data and data['stats_enabled'] is not None:
enabled = bool(data['stats_enabled'])
row.stats_enabled = enabled
if enabled and not was_stats:
row.stats_since = now
if not enabled:
# 停止写入;保留 since 供只读理解历史起点;再次开启会刷新 since
pass
if enabled and was_stats and data.get('reset_stats_since'):
row.stats_since = now
# 若刚打开统计且 since 空
if row.stats_enabled and not row.stats_since:
row.stats_since = now
row.updated_by = (updated_by or '')[:32]
row.save()
return config_to_dict(row)
def _clear_club_auto_settle_eligible(club_id: str):
from orders.models import Order
Order.objects.filter(ClubID=club_id, AutoSettleEligible=True).update(
AutoSettleEligible=False,
AutoExpireAt=None,
PendingDispatch=False,
AutoTaskID='',
)
def apply_enter_status_8(instance):
"""
订单进入结算中(8):写 SettlementTime若俱乐部自动结算已开且本次开启后进入则打 eligible。
由 signal 调用。instance 为即将 save 的 Order。
"""
now = timezone.now()
instance.SettlementTime = now
instance.PendingDispatch = False
instance.AutoTaskID = ''
club_id = (getattr(instance, 'ClubID', None) or '').strip()
cfg = get_or_create_deal_config(club_id) if club_id else None
if (
cfg
and cfg.auto_settle_enabled
and cfg.auto_settle_enabled_at
and now >= cfg.auto_settle_enabled_at
):
hours = int(cfg.hours_merchant if instance.Platform == 2 else cfg.hours_platform) or 48
instance.AutoSettleEligible = True
instance.AutoExpireAt = now + timedelta(hours=hours)
else:
instance.AutoSettleEligible = False
instance.AutoExpireAt = None
def apply_leave_status_8(instance):
instance.SettlementTime = None
instance.AutoSettleEligible = False
instance.AutoExpireAt = None
instance.PendingDispatch = False
instance.AutoTaskID = ''
@transaction.atomic
def mark_order_completed_unified(order, *, source='auto_settle', operator=''):
"""
与客服强制结单等价的核心结算(打手入账 + 商家成交 + 管事分红)。
自动结算路径额外要求 AutoSettleEligible。
"""
from orders.models import Order
from users.business_models import User
from orders.utils import settle_shangjia_order_guanshi_fenhong
from jituan.services.fund_freeze import credit_dashou_order_settle
locked = Order.objects.select_for_update().filter(OrderID=order.OrderID).first()
if not locked:
return False, '订单不存在'
if locked.Status != 8:
return False, f'状态不是结算中({locked.Status})'
if source == 'auto_settle':
if not locked.AutoSettleEligible:
return False, '非自动结算资格单'
if locked.AutoExpireAt and locked.AutoExpireAt > timezone.now():
return False, '未到自动结算时间'
dashou_id = locked.PlayerID
dashou_fencheng = locked.PlayerCommission
if not dashou_id:
return False, '无接单打手'
if not dashou_fencheng or dashou_fencheng <= 0:
return False, '打手分成无效'
try:
dashou_user = User.query.get(UserUID=dashou_id)
dashou_profile = dashou_user.DashouProfile
except Exception:
return False, '打手不存在'
# 组队分账优先;无有效组队则全额给队长(原逻辑)
# 若本应组队分账却失败/异常:禁止回退全额队长(避免队友白做或重复入账)
try:
from orders.services.team_recruit import settle_team_or_legacy, get_active_recruit, team_should_split
from orders.models import TeamSettleLedger, TeamRecruit
rec = get_active_recruit(locked.OrderID)
if rec and rec.status == TeamRecruit.STATUS_RECRUITING:
# 进8锁失败的兜底结单前再强制锁一次
from orders.services.team_recruit import lock_recruit
lock_recruit(rec.id, actor_uid='system', reason='settle_force_lock')
team_done = settle_team_or_legacy(locked, set_idle_leader=True)
except Exception as e:
logger.error('组队分账异常 %s: %s', locked.OrderID, e)
from orders.models import TeamSettleLedger
if TeamSettleLedger.query.filter(order_id=locked.OrderID).exists():
return False, f'组队分账异常且已有入账记录: {e}'
try:
from orders.services.team_recruit import team_should_split, get_active_recruit
if get_active_recruit(locked.OrderID) or team_should_split(locked):
return False, f'组队分账失败: {e}'
except Exception:
pass
team_done = False
if not team_done:
try:
from orders.services.team_recruit import get_active_recruit
if get_active_recruit(locked.OrderID):
return False, '存在有效组队但分账未完成,已阻止全额打给队长'
except Exception:
pass
credit_dashou_order_settle(
order=locked,
dashou_profile=dashou_profile,
user_id=dashou_id,
amount=dashou_fencheng,
)
if locked.Platform == 2:
try:
ext = getattr(locked, 'shangjia_kuozhan', None)
mid = getattr(ext, 'MerchantID', None) if ext else None
if mid:
shangjia_user = User.query.get(UserUID=mid)
profile = shangjia_user.ShopProfile
type(profile).objects.filter(pk=profile.pk).update(chengjiao=F('chengjiao') + 1)
try:
from backend.utils import update_shangjia_daily
update_shangjia_daily(
yonghuid=mid,
amount=Decimal(str(locked.Amount or 0)),
action=2,
order_id=locked.OrderID,
)
except Exception as e:
logger.warning('商家日统计跳过 %s: %s', locked.OrderID, e)
except Exception as e:
logger.warning('商家成交累加跳过 %s: %s', locked.OrderID, e)
settle_shangjia_order_guanshi_fenhong(locked, dashou_id)
locked.Status = 3
locked.AutoSettleEligible = False
locked.AutoExpireAt = None
# pre_save 会清 SettlementTime必须写入 update_fields 才能落库
update_fields = ['Status', 'AutoSettleEligible', 'AutoExpireAt', 'SettlementTime', 'UpdateTime']
if operator:
locked.AssignedCS = (operator or '')[:20]
update_fields.append('AssignedCS')
locked.save(update_fields=update_fields)
# 成交指标由 post_save signal 幂等记账,此处不重复调用
return True, 'ok'
def scan_due_auto_settle(limit: int = 100) -> dict:
"""只处理 eligible 到期单;无关自动结算的俱乐部空跑。"""
from jituan.models import ClubOrderDealConfig
from orders.models import Order
clubs = list(
ClubOrderDealConfig.query.filter(auto_settle_enabled=True).values_list('club_id', flat=True)
)
if not clubs:
return {'scanned': 0, 'settled': 0, 'failed': 0, 'clubs': 0}
now = timezone.now()
qs = (
Order.objects.filter(
ClubID__in=list(clubs),
Status=8,
AutoSettleEligible=True,
AutoExpireAt__isnull=False,
AutoExpireAt__lte=now,
)
.order_by('AutoExpireAt')[:limit]
)
settled = 0
failed = 0
for order in qs:
ok, msg = mark_order_completed_unified(order, source='auto_settle', operator='auto')
if ok:
settled += 1
logger.info('自动结算成功 %s', order.OrderID)
else:
failed += 1
logger.warning('自动结算跳过 %s: %s', order.OrderID, msg)
return {'scanned': settled + failed, 'settled': settled, 'failed': failed, 'clubs': len(clubs)}