"""成交指标 + 可控自动结算(俱乐部开关,默认全关)。 对齐设计文档:成交率与自动结算_设计方案.md """ from __future__ import annotations import logging from datetime import datetime, timedelta from decimal import Decimal from django.conf import settings from django.db import transaction from django.db.models import F from django.utils import timezone logger = logging.getLogger(__name__) def _db_datetime(dt): """写入 DateTimeField 用:与 USE_TZ 对齐,避免 MySQL 拒收 aware datetime。""" if dt is None: return None if settings.USE_TZ: if timezone.is_naive(dt): return timezone.make_aware(dt, timezone.get_current_timezone()) return dt if timezone.is_aware(dt): return timezone.make_naive(dt, timezone.get_current_timezone()) return dt def _default_stats_since(): return _db_datetime(datetime(2020, 1, 1)) 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, stats_enabled=True, stats_since=_default_stats_since(), ) 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 = _db_datetime(timezone.now()) expire_at = _db_datetime(expire_at) 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 = _db_datetime(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 = _default_stats_since() 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 = _default_stats_since() # 历史脏数据:aware datetime 在 USE_TZ=False 下无法落库,保存前统一清洗 row.auto_settle_enabled_at = _db_datetime(row.auto_settle_enabled_at) row.stats_since = _db_datetime(row.stats_since) 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 = _db_datetime(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 enabled_at = _db_datetime(getattr(cfg, 'auto_settle_enabled_at', None)) if cfg else None if ( cfg and cfg.auto_settle_enabled and enabled_at and now >= 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, '非自动结算资格单' now = _db_datetime(timezone.now()) expire_at = _db_datetime(locked.AutoExpireAt) if expire_at and expire_at > 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 enabled_at = _db_datetime(cfg.auto_settle_enabled_at) settle_at = _db_datetime(getattr(locked, 'SettlementTime', None)) if enabled_at and settle_at and settle_at < 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 = _db_datetime(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)}