"""成交/罚款指标幂等记账(仅 stats_enabled 且 CreateTime>=stats_since)。""" from __future__ import annotations import logging from django.db import transaction from django.db.models import F from django.utils import timezone logger = logging.getLogger(__name__) def _cfg(club_id: str): from jituan.services.order_deal import get_or_create_deal_config return get_or_create_deal_config(club_id) def _stats_on(cfg, order) -> bool: if not cfg or not cfg.stats_enabled or not cfg.stats_since: return False ct = getattr(order, 'CreateTime', None) if not ct or ct < cfg.stats_since: return False return True def _claim_event(club_id: str, event_key: str, event_type: str, order_id='', subject_uid='', meta=None) -> bool: from jituan.models import DealStatEvent if DealStatEvent.query.filter(event_key=event_key).exists(): return False try: DealStatEvent.objects.create( club_id=club_id, event_key=event_key[:96], event_type=event_type[:32], order_id=(order_id or '')[:32], subject_uid=(subject_uid or '')[:16], meta_json=meta or {}, ) return True except Exception: return False def _ensure_merchant(club_id, uid): from jituan.models import MerchantDealStat row = MerchantDealStat.query.filter(club_id=club_id, merchant_uid=uid).first() if not row: row = MerchantDealStat(club_id=club_id, merchant_uid=uid) row.save() return row def _ensure_dashou(club_id, uid): from jituan.models import DashouDealStat row = DashouDealStat.query.filter(club_id=club_id, dashou_uid=uid).first() if not row: row = DashouDealStat(club_id=club_id, dashou_uid=uid) row.save() return row def _merchant_uid(order): try: ext = getattr(order, 'shangjia_kuozhan', None) return (getattr(ext, 'MerchantID', None) or '') if ext else '' except Exception: return '' @transaction.atomic def on_order_enter_pool(order): """接单入池:商家/打手 order_count+1。""" club_id = (getattr(order, 'ClubID', None) or '').strip() cfg = _cfg(club_id) if not _stats_on(cfg, order): return oid = order.OrderID dashou = (order.PlayerID or '').strip() if dashou and _claim_event(club_id, f'{oid}:dashou_in', 'order_in_pool', oid, dashou): _ensure_dashou(club_id, dashou) from jituan.models import DashouDealStat DashouDealStat.objects.filter(club_id=club_id, dashou_uid=dashou).update( order_count=F('order_count') + 1, UpdateTime=timezone.now(), ) if order.Platform == 2: mid = _merchant_uid(order) if mid and _claim_event(club_id, f'{oid}:merchant_in', 'order_in_pool', oid, mid): _ensure_merchant(club_id, mid) from jituan.models import MerchantDealStat MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid).update( order_count=F('order_count') + 1, UpdateTime=timezone.now(), ) @transaction.atomic def on_order_completed(order, settle_started_at=None): club_id = (getattr(order, 'ClubID', None) or '').strip() cfg = _cfg(club_id) if not _stats_on(cfg, order): return oid = order.OrderID if not _claim_event(club_id, f'{oid}:completed', 'completed', oid): return # 确保入池 on_order_enter_pool(order) st = settle_started_at or getattr(order, '_deal_settle_started_at', None) duration = None if st: try: duration = max(0, int((timezone.now() - st).total_seconds())) except Exception: duration = None dashou = (order.PlayerID or '').strip() if dashou: _ensure_dashou(club_id, dashou) from jituan.models import DashouDealStat DashouDealStat.objects.filter(club_id=club_id, dashou_uid=dashou).update( completed_count=F('completed_count') + 1, UpdateTime=timezone.now(), ) if order.Platform == 2: mid = _merchant_uid(order) if mid: _ensure_merchant(club_id, mid) from jituan.models import MerchantDealStat qs = MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid) qs.update(completed_count=F('completed_count') + 1, UpdateTime=timezone.now()) if duration is not None: qs.update( settle_duration_sum_seconds=F('settle_duration_sum_seconds') + duration, settle_duration_sample_count=F('settle_duration_sample_count') + 1, ) @transaction.atomic def on_order_refunded(order): club_id = (getattr(order, 'ClubID', None) or '').strip() cfg = _cfg(club_id) if not _stats_on(cfg, order) or order.Platform != 2: return oid = order.OrderID mid = _merchant_uid(order) if not mid: return if not _claim_event(club_id, f'{oid}:refunded', 'refunded', oid, mid): return on_order_enter_pool(order) _ensure_merchant(club_id, mid) from jituan.models import MerchantDealStat MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid).update( refund_count=F('refund_count') + 1, UpdateTime=timezone.now(), ) @transaction.atomic def on_penalty_paid(penalty): """罚单 Status→2 已缴纳。""" from utils.penalty_status import PENALTY_PAID if int(getattr(penalty, 'Status', 0) or 0) != PENALTY_PAID: return club_id = (getattr(penalty, 'ClubID', None) or '').strip() cfg = _cfg(club_id) if not cfg or not cfg.stats_enabled or not cfg.stats_since: return pid = getattr(penalty, 'id', None) or getattr(penalty, 'pk', None) key = f'penalty:{pid}:paid' if not _claim_event(club_id, key, 'fine_paid', getattr(penalty, 'RelatedOrderID', '') or ''): return applicant = (getattr(penalty, 'ApplicantID', None) or '').strip() penalized = (getattr(penalty, 'PenalizedUserID', None) or '').strip() # 商家申请人 → 商家罚款率分子 if applicant: _ensure_merchant(club_id, applicant) from jituan.models import MerchantDealStat MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=applicant).update( fine_paid_count=F('fine_paid_count') + 1, UpdateTime=timezone.now(), ) # 被罚打手 → 被罚款率 if penalized: _ensure_dashou(club_id, penalized) from jituan.models import DashouDealStat DashouDealStat.objects.filter(club_id=club_id, dashou_uid=penalized).update( fine_paid_count=F('fine_paid_count') + 1, UpdateTime=timezone.now(), ) def serialize_merchant_stat(row) -> dict: n = int(row.order_count or 0) c = int(row.completed_count or 0) r = int(row.refund_count or 0) f = int(row.fine_paid_count or 0) sc = int(row.settle_duration_sample_count or 0) ss = int(row.settle_duration_sum_seconds or 0) return { 'merchant_uid': row.merchant_uid, 'order_count': n, 'completed_count': c, 'refund_count': r, 'fine_paid_count': f, 'deal_rate': round(c / n, 6) if n else 0, 'refund_rate': round(r / n, 6) if n else 0, 'fine_rate': round(f / n, 6) if n else 0, 'avg_deal_hours': round((ss / sc) / 3600.0, 4) if sc else 0, 'settle_duration_sample_count': sc, } def pool_merchant_stat_payload(row) -> dict | None: """ 抢单池展示用:无订单样本则不返回; 有订单则返回成交率/罚款率;平均成交时长仅有结算样本时返回。 """ if not row: return None n = int(row.order_count or 0) if n <= 0: return None full = serialize_merchant_stat(row) out = { 'deal_rate': full['deal_rate'], 'fine_rate': full['fine_rate'], 'deal_rate_text': f"{round(full['deal_rate'] * 100, 1)}%", 'fine_rate_text': f"{round(full['fine_rate'] * 100, 1)}%", } sc = int(full.get('settle_duration_sample_count') or 0) if sc > 0 and full.get('avg_deal_hours'): h = float(full['avg_deal_hours']) out['avg_deal_hours'] = h if h < 1: out['avg_deal_hours_text'] = f'{max(1, int(round(h * 60)))}分钟' else: out['avg_deal_hours_text'] = f'{round(h, 1)}小时' return out def batch_pool_merchant_stats(club_id: str, merchant_uids, order_club_map=None) -> dict: """ 批量取商家抢单池指标。 order_club_map: {merchant_uid: order_club_id} 优先用订单所属俱乐部查指标。 返回 {merchant_uid: payload} """ from jituan.models import MerchantDealStat uids = [str(u).strip() for u in (merchant_uids or []) if u and str(u).strip()] if not uids: return {} default_club = (club_id or '').strip() club_ids = {default_club} if default_club else set() if order_club_map: for cid in order_club_map.values(): c = (cid or '').strip() if c: club_ids.add(c) if not club_ids: return {} rows = list( MerchantDealStat.query.filter( club_id__in=list(club_ids), merchant_uid__in=uids, ) ) by_key = {(r.club_id, r.merchant_uid): r for r in rows} out = {} for uid in uids: prefer = ((order_club_map or {}).get(uid) or default_club or '').strip() row = by_key.get((prefer, uid)) if not row: for cid in club_ids: row = by_key.get((cid, uid)) if row: break payload = pool_merchant_stat_payload(row) if payload: out[uid] = payload return out def serialize_dashou_stat(row) -> dict: n = int(row.order_count or 0) c = int(row.completed_count or 0) f = int(row.fine_paid_count or 0) return { 'dashou_uid': row.dashou_uid, 'order_count': n, 'completed_count': c, 'fine_paid_count': f, 'deal_rate': round(c / n, 6) if n else 0, 'fined_rate': round(f / n, 6) if n else 0, } def miniapp_merchant_deal_payload(row) -> dict | None: """小程序商家订单列表顶:有样本才返回;文案字段供前端标「结算率」。""" if not row: return None n = int(row.order_count or 0) if n <= 0: return None full = serialize_merchant_stat(row) out = { 'role': 'merchant', 'order_count': full['order_count'], 'deal_rate': full['deal_rate'], 'fine_rate': full['fine_rate'], 'deal_rate_text': f"{round(full['deal_rate'] * 100, 1)}%", 'fine_rate_text': f"{round(full['fine_rate'] * 100, 1)}%", } sc = int(full.get('settle_duration_sample_count') or 0) if sc > 0 and full.get('avg_deal_hours'): h = float(full['avg_deal_hours']) out['avg_deal_hours'] = h if h < 1: out['avg_deal_hours_text'] = f'{max(1, int(round(h * 60)))}分钟' else: out['avg_deal_hours_text'] = f'{round(h, 1)}小时' return out def miniapp_dashou_deal_payload(row) -> dict | None: """小程序打手订单列表顶:有样本才返回。""" if not row: return None n = int(row.order_count or 0) if n <= 0: return None full = serialize_dashou_stat(row) return { 'role': 'dashou', 'order_count': full['order_count'], 'deal_rate': full['deal_rate'], 'fined_rate': full['fined_rate'], 'deal_rate_text': f"{round(full['deal_rate'] * 100, 1)}%", 'fined_rate_text': f"{round(full['fined_rate'] * 100, 1)}%", }