Files
Django/jituan/services/deal_stats.py
XingQue 3fe24cbcce fix: 抢单/组队卡片成交指标有字段即返回,均时含0样本
商家指标俱乐部兜底;组队大厅附带商家成交率/罚款率/均时。

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

349 lines
12 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.
"""成交/罚款指标幂等记账(仅 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:
"""
抢单池展示用:有订单样本才返回。
成交率/罚款率有样本即返回均时仅有结算样本时返回含均时≈0
前端按字段有哪个展示哪个。
"""
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:
h = float(full.get('avg_deal_hours') or 0)
out['avg_deal_hours'] = h
if h < 1:
out['avg_deal_hours_text'] = f'{max(1, int(round(h * 60)))}分钟' if h > 0 else '不足1分钟'
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 = {}
missing = []
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
if not row:
missing.append(uid)
continue
payload = pool_merchant_stat_payload(row)
if payload:
out[uid] = payload
# 订单俱乐部对不上时,按商家 UID 兜底(取样本最多的一条)
if missing:
for r in MerchantDealStat.query.filter(merchant_uid__in=missing).order_by('-order_count'):
uid = r.merchant_uid
if uid in out:
continue
payload = pool_merchant_stat_payload(r)
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:
h = float(full.get('avg_deal_hours') or 0)
out['avg_deal_hours'] = h
if h < 1:
out['avg_deal_hours_text'] = f'{max(1, int(round(h * 60)))}分钟' if h > 0 else '不足1分钟'
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)}%",
}