feat: 成交指标新表+可控自动结算(默认关,仅新进结算中eligible)

按设计文档:SettlementTime起表;关开关清eligible;定时任务只结到期eligible单;打款走统一结单;指标从stats_since起幂等记账。不打开旧全局ORDER_AUTO_SETTLEMENT。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-27 01:28:44 +08:00
parent 97040c552e
commit 0e102fbcba
13 changed files with 840 additions and 26 deletions

View File

@@ -95,6 +95,16 @@ app.conf.beat_schedule = {
'priority': 4,
},
},
# 6. 可控订单自动结算(仅 AutoSettleEligible俱乐部开关关则空跑
'scan_order_auto_settle_due': {
'task': 'jituan.tasks.scan_order_auto_settle_due',
'schedule': crontab(minute='*/5'),
'options': {
'queue': 'periodic_tasks',
'priority': 4,
},
},
}

View File

@@ -0,0 +1,87 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0019_role_agreement_gates'),
]
operations = [
migrations.CreateModel(
name='ClubOrderDealConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='俱乐部')),
('auto_settle_enabled', models.BooleanField(default=False, verbose_name='自动结算开关')),
('auto_settle_enabled_at', models.DateTimeField(blank=True, null=True, verbose_name='本次打开自动结算时间')),
('hours_platform', models.PositiveIntegerField(default=48, verbose_name='平台单超时小时')),
('hours_merchant', models.PositiveIntegerField(default=48, verbose_name='商家单超时小时')),
('stats_enabled', models.BooleanField(default=False, verbose_name='成交指标统计开关')),
('stats_since', models.DateTimeField(blank=True, null=True, verbose_name='指标统计起点')),
('updated_by', models.CharField(blank=True, default='', max_length=32)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '成交与自动结算配置',
'db_table': 'club_order_deal_config',
},
),
migrations.CreateModel(
name='MerchantDealStat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('merchant_uid', models.CharField(db_index=True, max_length=16, verbose_name='商家UserUID')),
('order_count', models.PositiveIntegerField(default=0)),
('completed_count', models.PositiveIntegerField(default=0)),
('refund_count', models.PositiveIntegerField(default=0)),
('fine_paid_count', models.PositiveIntegerField(default=0)),
('settle_duration_sum_seconds', models.BigIntegerField(default=0)),
('settle_duration_sample_count', models.PositiveIntegerField(default=0)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '商家成交指标',
'db_table': 'merchant_deal_stat',
'unique_together': {('club_id', 'merchant_uid')},
},
),
migrations.CreateModel(
name='DashouDealStat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('dashou_uid', models.CharField(db_index=True, max_length=16, verbose_name='打手UserUID')),
('order_count', models.PositiveIntegerField(default=0)),
('completed_count', models.PositiveIntegerField(default=0)),
('fine_paid_count', models.PositiveIntegerField(default=0)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '打手成交指标',
'db_table': 'dashou_deal_stat',
'unique_together': {('club_id', 'dashou_uid')},
},
),
migrations.CreateModel(
name='DealStatEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, max_length=16)),
('event_key', models.CharField(db_index=True, max_length=96, unique=True)),
('event_type', models.CharField(db_index=True, max_length=32)),
('order_id', models.CharField(blank=True, db_index=True, default='', max_length=32)),
('subject_uid', models.CharField(blank=True, db_index=True, default='', max_length=16)),
('meta_json', models.JSONField(blank=True, default=dict)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': '成交指标事件',
'db_table': 'deal_stat_event',
},
),
]

View File

@@ -672,3 +672,71 @@ class ClubRoleAgreementSign(QModel):
models.Index(fields=['user_id', 'role', 'version'], name='idx_cras_user_role_ver'),
models.Index(fields=['club_id', 'role', 'signed_at'], name='idx_cras_club_role_t'),
]
class ClubOrderDealConfig(QModel):
"""俱乐部:成交指标统计开关 + 可控自动结算开关(默认全关)。"""
club_id = models.CharField(max_length=16, unique=True, db_index=True, verbose_name='俱乐部')
auto_settle_enabled = models.BooleanField(default=False, verbose_name='自动结算开关')
auto_settle_enabled_at = models.DateTimeField(null=True, blank=True, verbose_name='本次打开自动结算时间')
hours_platform = models.PositiveIntegerField(default=48, verbose_name='平台单超时小时')
hours_merchant = models.PositiveIntegerField(default=48, verbose_name='商家单超时小时')
stats_enabled = models.BooleanField(default=False, verbose_name='成交指标统计开关')
stats_since = models.DateTimeField(null=True, blank=True, verbose_name='指标统计起点')
updated_by = models.CharField(max_length=32, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_order_deal_config'
verbose_name = '成交与自动结算配置'
class MerchantDealStat(QModel):
"""商家成交/罚款/退款指标快照(仅统计起点之后事件累加)。"""
club_id = models.CharField(max_length=16, db_index=True)
merchant_uid = models.CharField(max_length=16, db_index=True, verbose_name='商家UserUID')
order_count = models.PositiveIntegerField(default=0)
completed_count = models.PositiveIntegerField(default=0)
refund_count = models.PositiveIntegerField(default=0)
fine_paid_count = models.PositiveIntegerField(default=0)
settle_duration_sum_seconds = models.BigIntegerField(default=0)
settle_duration_sample_count = models.PositiveIntegerField(default=0)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'merchant_deal_stat'
verbose_name = '商家成交指标'
unique_together = [['club_id', 'merchant_uid']]
class DashouDealStat(QModel):
"""打手成交/被罚款指标快照。"""
club_id = models.CharField(max_length=16, db_index=True)
dashou_uid = models.CharField(max_length=16, db_index=True, verbose_name='打手UserUID')
order_count = models.PositiveIntegerField(default=0)
completed_count = models.PositiveIntegerField(default=0)
fine_paid_count = models.PositiveIntegerField(default=0)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'dashou_deal_stat'
verbose_name = '打手成交指标'
unique_together = [['club_id', 'dashou_uid']]
class DealStatEvent(QModel):
"""指标幂等事件(防重复记账)。"""
club_id = models.CharField(max_length=16, db_index=True)
event_key = models.CharField(max_length=96, unique=True, db_index=True)
event_type = models.CharField(max_length=32, db_index=True)
order_id = models.CharField(max_length=32, blank=True, default='', db_index=True)
subject_uid = models.CharField(max_length=16, blank=True, default='', db_index=True)
meta_json = models.JSONField(default=dict, blank=True)
CreateTime = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'deal_stat_event'
verbose_name = '成交指标事件'

View File

@@ -294,6 +294,11 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
if fadan:
fadan.Status = 2
fadan.save(update_fields=['Status'])
try:
from jituan.services.deal_stats import on_penalty_paid
on_penalty_paid(fadan)
except Exception as exc:
logger.warning('成交罚款指标记账跳过 fadan=%s: %s', fadan.id, exc)
success, msg = process_fadan_fenhong(fadan.id)
if not success:
logger.error('罚单 %s 分红处理失败: %s', fadan.id, msg)

View File

@@ -0,0 +1,220 @@
"""成交/罚款指标幂等记账(仅 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 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,
}

View File

@@ -24,6 +24,8 @@ KEFU_MENU_ROW_DEFS = [
'perm_codes': ['002ab']},
{'page_id': 'order.merchant', 'name': '商家订单', 'path': '/order/merchant', 'parent_id': 'order', 'sort_order': 32,
'perm_codes': ['002ac', '002ab', '003aa']},
{'page_id': 'order.deal-config', 'name': '成交与自动结算', 'path': '/order/deal-config', 'parent_id': 'order', 'sort_order': 33,
'perm_codes': ['002ab', '002ac', '003aa']},
{'page_id': 'cross-platform', 'name': '跨平台管理', 'path': '', 'parent_id': '', 'sort_order': 40},
{'page_id': 'cross-platform.order', 'name': '跨平台订单', 'path': '/cross-order', 'parent_id': 'cross-platform', 'sort_order': 41,
'perm_codes': ['003aa']},

View File

@@ -0,0 +1,249 @@
"""成交指标 + 可控自动结算(俱乐部开关,默认全关)。
对齐设计文档成交率与自动结算_设计方案.md
"""
from __future__ import annotations
import logging
from datetime import timedelta
from decimal import Decimal
from django.db import transaction
from django.db.models import F
from django.utils import timezone
logger = logging.getLogger(__name__)
def get_or_create_deal_config(club_id: str):
from jituan.models import ClubOrderDealConfig
cid = (club_id or '').strip()
if not cid:
return None
row = ClubOrderDealConfig.query.filter(club_id=cid).first()
if not row:
row = ClubOrderDealConfig(club_id=cid)
row.save()
return row
def config_to_dict(row) -> dict:
if not row:
return {
'club_id': '',
'auto_settle_enabled': False,
'auto_settle_enabled_at': '',
'hours_platform': 48,
'hours_merchant': 48,
'stats_enabled': False,
'stats_since': '',
}
return {
'club_id': row.club_id,
'auto_settle_enabled': bool(row.auto_settle_enabled),
'auto_settle_enabled_at': row.auto_settle_enabled_at.isoformat() if row.auto_settle_enabled_at else '',
'hours_platform': int(row.hours_platform or 48),
'hours_merchant': int(row.hours_merchant or 48),
'stats_enabled': bool(row.stats_enabled),
'stats_since': row.stats_since.isoformat() if row.stats_since else '',
}
def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict:
"""保存配置。关自动结算时清空本俱乐部所有 eligible。"""
row = get_or_create_deal_config(club_id)
if not row:
raise ValueError('俱乐部不能为空')
was_auto = bool(row.auto_settle_enabled)
was_stats = bool(row.stats_enabled)
now = timezone.now()
if 'hours_platform' in data and data['hours_platform'] is not None:
row.hours_platform = max(1, min(int(data['hours_platform']), 720))
if 'hours_merchant' in data and data['hours_merchant'] is not None:
row.hours_merchant = max(1, min(int(data['hours_merchant']), 720))
if 'auto_settle_enabled' in data and data['auto_settle_enabled'] is not None:
enabled = bool(data['auto_settle_enabled'])
row.auto_settle_enabled = enabled
if enabled and not was_auto:
row.auto_settle_enabled_at = now
if not enabled:
row.auto_settle_enabled_at = None
_clear_club_auto_settle_eligible(row.club_id)
if 'stats_enabled' in data and data['stats_enabled'] is not None:
enabled = bool(data['stats_enabled'])
row.stats_enabled = enabled
if enabled and not was_stats:
row.stats_since = now
if not enabled:
# 停止写入;保留 since 供只读理解历史起点;再次开启会刷新 since
pass
if enabled and was_stats and data.get('reset_stats_since'):
row.stats_since = now
# 若刚打开统计且 since 空
if row.stats_enabled and not row.stats_since:
row.stats_since = now
row.updated_by = (updated_by or '')[:32]
row.save()
return config_to_dict(row)
def _clear_club_auto_settle_eligible(club_id: str):
from orders.models import Order
Order.objects.filter(ClubID=club_id, AutoSettleEligible=True).update(
AutoSettleEligible=False,
AutoExpireAt=None,
PendingDispatch=False,
AutoTaskID='',
)
def apply_enter_status_8(instance):
"""
订单进入结算中(8):写 SettlementTime若俱乐部自动结算已开且本次开启后进入则打 eligible。
由 signal 调用。instance 为即将 save 的 Order。
"""
now = timezone.now()
instance.SettlementTime = now
instance.PendingDispatch = False
instance.AutoTaskID = ''
club_id = (getattr(instance, 'ClubID', None) or '').strip()
cfg = get_or_create_deal_config(club_id) if club_id else None
if (
cfg
and cfg.auto_settle_enabled
and cfg.auto_settle_enabled_at
and now >= cfg.auto_settle_enabled_at
):
hours = int(cfg.hours_merchant if instance.Platform == 2 else cfg.hours_platform) or 48
instance.AutoSettleEligible = True
instance.AutoExpireAt = now + timedelta(hours=hours)
else:
instance.AutoSettleEligible = False
instance.AutoExpireAt = None
def apply_leave_status_8(instance):
instance.SettlementTime = None
instance.AutoSettleEligible = False
instance.AutoExpireAt = None
instance.PendingDispatch = False
instance.AutoTaskID = ''
@transaction.atomic
def mark_order_completed_unified(order, *, source='auto_settle', operator=''):
"""
与客服强制结单等价的核心结算(打手入账 + 商家成交 + 管事分红)。
自动结算路径额外要求 AutoSettleEligible。
"""
from orders.models import Order
from users.business_models import User
from orders.utils import settle_shangjia_order_guanshi_fenhong
from jituan.services.fund_freeze import credit_dashou_order_settle
locked = Order.objects.select_for_update().filter(OrderID=order.OrderID).first()
if not locked:
return False, '订单不存在'
if locked.Status != 8:
return False, f'状态不是结算中({locked.Status})'
if source == 'auto_settle':
if not locked.AutoSettleEligible:
return False, '非自动结算资格单'
if locked.AutoExpireAt and locked.AutoExpireAt > timezone.now():
return False, '未到自动结算时间'
dashou_id = locked.PlayerID
dashou_fencheng = locked.PlayerCommission
if not dashou_id:
return False, '无接单打手'
if not dashou_fencheng or dashou_fencheng <= 0:
return False, '打手分成无效'
try:
dashou_user = User.query.get(UserUID=dashou_id)
dashou_profile = dashou_user.DashouProfile
except Exception:
return False, '打手不存在'
credit_dashou_order_settle(
order=locked,
dashou_profile=dashou_profile,
user_id=dashou_id,
amount=dashou_fencheng,
)
if locked.Platform == 2:
try:
ext = getattr(locked, 'shangjia_kuozhan', None)
mid = getattr(ext, 'MerchantID', None) if ext else None
if mid:
shangjia_user = User.query.get(UserUID=mid)
profile = shangjia_user.ShopProfile
type(profile).objects.filter(pk=profile.pk).update(chengjiao=F('chengjiao') + 1)
try:
from backend.utils import update_shangjia_daily
update_shangjia_daily(
yonghuid=mid,
amount=Decimal(str(locked.Amount or 0)),
action=2,
order_id=locked.OrderID,
)
except Exception as e:
logger.warning('商家日统计跳过 %s: %s', locked.OrderID, e)
except Exception as e:
logger.warning('商家成交累加跳过 %s: %s', locked.OrderID, e)
settle_shangjia_order_guanshi_fenhong(locked, dashou_id)
locked.Status = 3
locked.AutoSettleEligible = False
locked.AutoExpireAt = None
# pre_save 会清 SettlementTime必须写入 update_fields 才能落库
update_fields = ['Status', 'AutoSettleEligible', 'AutoExpireAt', 'SettlementTime', 'UpdateTime']
if operator:
locked.AssignedCS = (operator or '')[:20]
update_fields.append('AssignedCS')
locked.save(update_fields=update_fields)
# 成交指标由 post_save signal 幂等记账,此处不重复调用
return True, 'ok'
def scan_due_auto_settle(limit: int = 100) -> dict:
"""只处理 eligible 到期单;无关自动结算的俱乐部空跑。"""
from jituan.models import ClubOrderDealConfig
from orders.models import Order
clubs = list(
ClubOrderDealConfig.query.filter(auto_settle_enabled=True).values_list('club_id', flat=True)
)
if not clubs:
return {'scanned': 0, 'settled': 0, 'failed': 0, 'clubs': 0}
now = timezone.now()
qs = (
Order.objects.filter(
ClubID__in=list(clubs),
Status=8,
AutoSettleEligible=True,
AutoExpireAt__isnull=False,
AutoExpireAt__lte=now,
)
.order_by('AutoExpireAt')[:limit]
)
settled = 0
failed = 0
for order in qs:
ok, msg = mark_order_completed_unified(order, source='auto_settle', operator='auto')
if ok:
settled += 1
logger.info('自动结算成功 %s', order.OrderID)
else:
failed += 1
logger.warning('自动结算跳过 %s: %s', order.OrderID, msg)
return {'scanned': settled + failed, 'settled': settled, 'failed': failed, 'clubs': len(clubs)}

View File

@@ -13,3 +13,15 @@ def scan_fund_freeze_due(limit: int = 200):
result = scan_due_freeze_ledgers(limit=limit)
logger.info('scan_fund_freeze_due %s', result)
return result
@shared_task(name='jituan.tasks.scan_order_auto_settle_due')
def scan_order_auto_settle_due(limit: int = 100):
"""
可控自动结算:仅 Status=8 且 AutoSettleEligible 且 AutoExpireAt 到期。
俱乐部开关关闭时空跑;绝不扫老结算中单。
"""
from jituan.services.order_deal import scan_due_auto_settle
result = scan_due_auto_settle(limit=limit)
logger.info('scan_order_auto_settle_due %s', result)
return result

View File

@@ -107,6 +107,11 @@ from jituan.views_fund_freeze import (
FundFreezeRiskView,
)
from jituan.views_role_agreement import RoleAgreementAdminView
from jituan.views_order_deal import (
OrderDealConfigView,
MerchantDealStatView,
DashouDealStatView,
)
urlpatterns = [
path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'),
@@ -122,6 +127,9 @@ urlpatterns = [
path('houtai/fund-freeze-ledger', FundFreezeLedgerView.as_view(), name='jituan_fund_freeze_ledger'),
path('houtai/fund-freeze-risk', FundFreezeRiskView.as_view(), name='jituan_fund_freeze_risk'),
path('houtai/role-agreement', RoleAgreementAdminView.as_view(), name='jituan_role_agreement'),
path('houtai/order-deal-config', OrderDealConfigView.as_view(), name='jituan_order_deal_config'),
path('houtai/merchant-deal-stat', MerchantDealStatView.as_view(), name='jituan_merchant_deal_stat'),
path('houtai/dashou-deal-stat', DashouDealStatView.as_view(), name='jituan_dashou_deal_stat'),
path('houtai/payment-channel-manage', ClubPaymentChannelManageView.as_view(), name='jituan_payment_channel_manage'),
path('houtai/club-cert-upload', ClubCertUploadView.as_view(), name='jituan_club_cert_upload'),
path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'),

116
jituan/views_order_deal.py Normal file
View File

@@ -0,0 +1,116 @@
"""客服:成交指标配置 / 商家·打手指标查询。"""
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from backend.utils import verify_kefu_permission, has_merchant_order_permission
from jituan.services.club_context import resolve_club_id_from_request
from jituan.services import order_deal as deal_cfg
from jituan.services import deal_stats as stats_svc
def _auth_order(request):
username = (request.data.get('username') or request.data.get('phone') or '').strip()
if not username:
return None, Response({'code': 400, 'msg': '缺少username'})
kefu, permissions = verify_kefu_permission(request, username)
if kefu is None:
return None, permissions
codes = set(permissions or [])
user = getattr(request, 'user', None)
if getattr(user, 'IsSuperuser', False) or '000001' in codes:
return kefu, None
if has_merchant_order_permission(permissions) or '002ab' in codes:
return kefu, None
return None, Response({'code': 403, 'msg': '无订单管理权限'})
class OrderDealConfigView(APIView):
"""
POST /jituan/houtai/order-deal-config
action: get | save
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
kefu, err = _auth_order(request)
if err:
return err
club_id = (resolve_club_id_from_request(request) or '').strip()
if not club_id:
return Response({'code': 400, 'msg': '请先选择俱乐部'})
action = (request.data.get('action') or 'get').strip().lower()
if action == 'get':
row = deal_cfg.get_or_create_deal_config(club_id)
return Response({'code': 0, 'msg': 'ok', 'data': deal_cfg.config_to_dict(row)})
if action == 'save':
try:
operator = getattr(request.user, 'UserUID', '') or ''
data = deal_cfg.save_deal_config(club_id, request.data, updated_by=operator)
return Response({'code': 0, 'msg': '已保存', 'data': data})
except ValueError as e:
return Response({'code': 400, 'msg': str(e)})
return Response({'code': 400, 'msg': '无效 action'})
class MerchantDealStatView(APIView):
"""POST /jituan/houtai/merchant-deal-stat action: list|detail"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
kefu, err = _auth_order(request)
if err:
return err
club_id = (resolve_club_id_from_request(request) or '').strip()
from jituan.models import MerchantDealStat
action = (request.data.get('action') or 'list').strip().lower()
uid = (request.data.get('merchant_uid') or '').strip()
if action == 'detail' and uid:
row = MerchantDealStat.query.filter(club_id=club_id, merchant_uid=uid).first()
return Response({
'code': 0,
'msg': 'ok',
'data': stats_svc.serialize_merchant_stat(row) if row else {
'merchant_uid': uid, 'order_count': 0, 'deal_rate': 0, 'refund_rate': 0,
'fine_rate': 0, 'avg_deal_hours': 0,
},
})
qs = MerchantDealStat.query.filter(club_id=club_id).order_by('-order_count')[:200]
return Response({
'code': 0,
'msg': 'ok',
'data': {'list': [stats_svc.serialize_merchant_stat(r) for r in qs]},
})
class DashouDealStatView(APIView):
"""POST /jituan/houtai/dashou-deal-stat action: list|detail"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
kefu, err = _auth_order(request)
if err:
return err
club_id = (resolve_club_id_from_request(request) or '').strip()
from jituan.models import DashouDealStat
action = (request.data.get('action') or 'list').strip().lower()
uid = (request.data.get('dashou_uid') or '').strip()
if action == 'detail' and uid:
row = DashouDealStat.query.filter(club_id=club_id, dashou_uid=uid).first()
return Response({
'code': 0,
'msg': 'ok',
'data': stats_svc.serialize_dashou_stat(row) if row else {
'dashou_uid': uid, 'order_count': 0, 'deal_rate': 0, 'fined_rate': 0,
},
})
qs = DashouDealStat.query.filter(club_id=club_id).order_by('-order_count')[:200]
return Response({
'code': 0,
'msg': 'ok',
'data': {'list': [stats_svc.serialize_dashou_stat(r) for r in qs]},
})

View File

@@ -0,0 +1,22 @@
# 订单 AutoSettleEligible + jituan 成交/自动结算配置与指标表
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0015_merchantorderext_bossphone'),
]
operations = [
migrations.AddField(
model_name='order',
name='AutoSettleEligible',
field=models.BooleanField(
db_column='auto_settle_eligible',
db_index=True,
default=False,
verbose_name='允许自动结算',
),
),
]

View File

@@ -156,6 +156,11 @@ class Order(QModel):
null=True, blank=True,
db_column='status_8_time', verbose_name='状态变为8的时间',
)
# 可控自动结算:仅「俱乐部开启后新进入结算中」的单为 True老单永不置 True
AutoSettleEligible = models.BooleanField(
default=False, db_index=True,
db_column='auto_settle_eligible', verbose_name='允许自动结算',
)
# ---- 时间戳 ----
CreateTime = models.DateTimeField(
auto_now_add=True,

View File

@@ -6,11 +6,9 @@
import sys
import importlib
import logging
from django.db.models.signals import pre_save
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from django.conf import settings
from django.utils import timezone
from gvsdsdk.fluent import db, func, FQ
import logging
logger = logging.getLogger(__name__)
@@ -42,30 +40,42 @@ ensure_redis_loaded()
@receiver(pre_save, sender='orders.Order')
def handle_order_status_8(sender, instance, **kwargs):
"""
订单状态变为8时的信号处理。
ORDER_AUTO_SETTLEMENT_ENABLED=False 时不提交任何 Celery 超时结算任务。
订单进入/离开结算中(8)
- 写/清 SettlementTime进结算时刻
- 俱乐部自动结算开启后新进 8 → AutoSettleEligible否则永不 eligible老单安全
旧全局 ORDER_AUTO_SETTLEMENT_ENABLED 保持 False不投递旧延时任务。
"""
if not getattr(settings, 'ORDER_AUTO_SETTLEMENT_ENABLED', False):
if instance.pk is None:
return
try:
from orders.models import Order
old = Order.query.filter(pk=instance.pk).values_list('Status', flat=True).first()
if old != 8 and instance.Status == 8:
instance.SettlementTime = timezone.now()
instance.pending_dispatch = False
instance.auto_task_id = ''
instance.auto_expire_at = None
elif old == 8 and instance.Status != 8:
instance.SettlementTime = None
instance.pending_dispatch = False
instance.auto_task_id = ''
except Exception as e:
logger.error(f'信号处理失败: {e}')
if instance.pk is None:
return
try:
from orders.models import Order
from jituan.services.order_deal import apply_enter_status_8, apply_leave_status_8
# --- 以下原自动结算逻辑保留备查(开关打开时才启用,当前生产应为 False---
old = Order.query.filter(pk=instance.pk).values_list('Status', flat=True).first()
if old != 8 and instance.Status == 8:
apply_enter_status_8(instance)
elif old == 8 and instance.Status != 8:
# 成交时长:在清空 SettlementTime 前挂到实例上供 post_save 指标使用
instance._deal_settle_started_at = instance.SettlementTime
apply_leave_status_8(instance)
except Exception as e:
logger.error(f'结算中信号处理失败: {e}')
@receiver(post_save, sender='orders.Order')
def handle_order_deal_stats(sender, instance, created, **kwargs):
"""成交指标:入池 / 完成 / 退款(统计开关关闭或 since 前订单自动跳过)。"""
try:
from jituan.services import deal_stats as ds
if created:
return
# 入池:有打手且进行中/结算中/已完成等
if instance.PlayerID and instance.Status in (2, 3, 4, 5, 6, 8):
ds.on_order_enter_pool(instance)
if instance.Status == 3:
started = getattr(instance, '_deal_settle_started_at', None)
ds.on_order_completed(instance, settle_started_at=started)
elif instance.Status == 5:
ds.on_order_refunded(instance)
except Exception as e:
logger.error(f'成交指标信号处理失败: {e}')