Files
Django/jituan/services/order_deal.py
XingQue e3f9654002 fix: 成交指标始终记账并回填;抢单建聊移出事务并重试
抢单池刷新触发历史回填,旧单也能出成交率/罚款率/均时;建聊失败重试并订阅组队成员,避免消息列表空白。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 08:46:22 +08:00

355 lines
14 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
from datetime import datetime
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,
stats_enabled=True,
stats_since=timezone.make_aware(datetime(2020, 1, 1)),
)
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())
try:
expire_ts = int(expire_at.timestamp())
except Exception:
expire_ts = 0
return {
'auto_settle_eligible': True,
'auto_expire_at': fmt_datetime(expire_at),
# 前端用时间戳做秒级时钟倒计时,避免解析本地时区偏差
'auto_expire_at_ts': expire_ts,
'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:
# 开启时默认从较早起点计,便于旧单刷新也能出成交率
from datetime import datetime
row.stats_since = timezone.make_aware(datetime(2020, 1, 1))
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:
from datetime import datetime
row.stats_since = timezone.make_aware(datetime(2020, 1, 1))
row.updated_by = (updated_by or '')[:32]
row.save()
# 保存后触发一次历史回填,保证抢单池马上能刷到成交率/罚款率
try:
from jituan.models import DealStatEvent
from jituan.services.deal_stats import ensure_club_stats_bootstrapped
DealStatEvent.objects.filter(event_key=f'{row.club_id}:stats_bootstrap_v1').delete()
ensure_club_stats_bootstrapped(row.club_id)
except Exception as e:
logger.warning('保存成交配置后回填失败 club=%s: %s', row.club_id, e)
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, '未到自动结算时间'
club_id = (getattr(locked, 'ClubID', None) or '').strip()
cfg = get_or_create_deal_config(club_id) if club_id else None
if not cfg or not cfg.auto_settle_enabled:
return False, '俱乐部已关闭自动结算'
# 只认「本次打开之后」进结算中的单;开启前的旧 8 状态单不会带 eligible
if cfg.auto_settle_enabled_at and getattr(locked, 'SettlementTime', None):
if locked.SettlementTime < cfg.auto_settle_enabled_at:
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:
# 每单再读一次开关:避免扫到一半被关掉仍继续结
cid = (getattr(order, 'ClubID', None) or '').strip()
cfg = ClubOrderDealConfig.query.filter(club_id=cid, auto_settle_enabled=True).first()
if not cfg:
failed += 1
logger.info('自动结算跳过 %s: 俱乐部已关开关', order.OrderID)
continue
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)}