"""成交指标 + 可控自动结算(俱乐部开关,默认全关)。 对齐设计文档:成交率与自动结算_设计方案.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, '未到自动结算时间' 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)}