仅 eligible 结算中订单下发预计时间;支持 auto_settle_pending 筛选;新增 /dingdan/wo-de-deal-stat。 Co-authored-by: Cursor <cursoragent@cursor.com>
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""成交/罚款指标幂等记账(仅 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)}%",
|
||
}
|