feat: 成交指标新表+可控自动结算(默认关,仅新进结算中eligible)

按设计文档:SettlementTime起表;关开关清eligible;定时任务只结到期eligible单;打款走统一结单;指标从stats_since起幂等记账。不打开旧全局ORDER_AUTO_SETTLEMENT。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-27 01:28:44 +08:00
parent 97040c552e
commit 0e102fbcba
13 changed files with 840 additions and 26 deletions

View File

@@ -0,0 +1,249 @@
"""成交指标 + 可控自动结算(俱乐部开关,默认全关)。
对齐设计文档成交率与自动结算_设计方案.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 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, '打手不存在'
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)}