Files
Django/jituan/services/deal_stats.py
XingQue 3c02b9477d fix: 成交指标 ensure 遇唯一键冲突时复用已有行
回填并发插入 merchant_deal_stat/dashou_deal_stat 时不再因 Duplicate entry 中断。

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

417 lines
15 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:
"""
成交指标始终记账(抢单池要展示成交率/罚款率/均时)。
若配置了 stats_since只统计该时间点之后的订单。
"""
if not order:
return False
since = getattr(cfg, 'stats_since', None) if cfg else None
if since:
ct = getattr(order, 'CreateTime', None)
if ct and ct < since:
return False
return True
def ensure_club_stats_bootstrapped(club_id: str, *, lookback_days: int = 180):
"""
俱乐部首次拉取抢单池商家指标时,若从未记账则回填近 N 天历史订单。
幂等:用 DealStatEvent 打标记。
"""
cid = (club_id or '').strip()
if not cid:
return
marker = f'{cid}:stats_bootstrap_v1'
from jituan.models import DealStatEvent, MerchantDealStat
if DealStatEvent.query.filter(event_key=marker).exists():
return
# 已有商家样本则只打标,避免重复重算
if MerchantDealStat.query.filter(club_id=cid, order_count__gt=0).exists():
_claim_event(cid, marker, 'stats_bootstrap', subject_uid='system')
return
try:
from orders.models import Order
from datetime import timedelta
since = timezone.now() - timedelta(days=max(7, int(lookback_days or 180)))
qs = (
Order.query.filter(ClubID=cid, CreateTime__gte=since)
.exclude(PlayerID__isnull=True)
.exclude(PlayerID='')
.filter(Status__in=[2, 3, 4, 5, 6, 8])
.order_by('CreateTime')[:800]
)
for order in qs:
try:
on_order_enter_pool(order, force=True)
if int(order.Status or 0) == 3:
on_order_completed(order, settle_started_at=getattr(order, 'SettlementTime', None), force=True)
elif int(order.Status or 0) == 5:
on_order_refunded(order, force=True)
except Exception as e:
logger.warning('回填单条成交指标失败 order=%s: %s', getattr(order, 'OrderID', ''), e)
_claim_event(cid, marker, 'stats_bootstrap', subject_uid='system', meta={'n': len(qs)})
logger.info('俱乐部 %s 成交指标回填完成 n=%s', cid, len(qs))
except Exception as e:
logger.error('俱乐部成交指标回填失败 club=%s: %s', cid, e, exc_info=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
from django.db import IntegrityError
row = MerchantDealStat.query.filter(club_id=club_id, merchant_uid=uid).first()
if row:
return row
try:
row = MerchantDealStat(club_id=club_id, merchant_uid=uid)
row.save()
return row
except IntegrityError:
# 并发回填/记账时可能已有同 club+merchant 行,读回即可
return MerchantDealStat.query.filter(club_id=club_id, merchant_uid=uid).first()
def _ensure_dashou(club_id, uid):
from jituan.models import DashouDealStat
from django.db import IntegrityError
row = DashouDealStat.query.filter(club_id=club_id, dashou_uid=uid).first()
if row:
return row
try:
row = DashouDealStat(club_id=club_id, dashou_uid=uid)
row.save()
return row
except IntegrityError:
return DashouDealStat.query.filter(club_id=club_id, dashou_uid=uid).first()
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, *, force=False):
"""接单入池:商家/打手 order_count+1。force=True 用于历史回填(忽略 since"""
club_id = (getattr(order, 'ClubID', None) or '').strip()
cfg = _cfg(club_id)
if not force and 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, *, force=False):
club_id = (getattr(order, 'ClubID', None) or '').strip()
cfg = _cfg(club_id)
if not force and 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, force=force)
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, *, force=False):
club_id = (getattr(order, 'ClubID', None) or '').strip()
cfg = _cfg(club_id)
if (not force and 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, force=force)
_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()
if not club_id:
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()
# 首次无样本时回填历史,保证旧单刷新也能看到成交率/罚款率/均时
try:
if default_club:
ensure_club_stats_bootstrapped(default_club)
if order_club_map:
for cid in set((c or '').strip() for c in order_club_map.values() if c):
if cid and cid != default_club:
ensure_club_stats_bootstrapped(cid)
except Exception as e:
logger.warning('抢单池成交指标回填触发失败: %s', e)
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)}%",
}