Files
Django/jituan/services/deal_stats.py
XingQue 1b733d21cc feat: 订单列表/详情返回自动结算倒计时字段,并加小程序结算率接口
仅 eligible 结算中订单下发预计时间;支持 auto_settle_pending 筛选;新增 /dingdan/wo-de-deal-stat。

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

335 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:
"""
抢单池展示用:无订单样本则不返回;
有订单则返回成交率/罚款率;平均成交时长仅有结算样本时返回。
"""
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)}%",
}