feat: 成交指标新表+可控自动结算(默认关,仅新进结算中eligible)
按设计文档:SettlementTime起表;关开关清eligible;定时任务只结到期eligible单;打款走统一结单;指标从stats_since起幂等记账。不打开旧全局ORDER_AUTO_SETTLEMENT。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
87
jituan/migrations/0020_order_deal_config_stats.py
Normal file
87
jituan/migrations/0020_order_deal_config_stats.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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 = '成交指标事件'
|
||||
|
||||
@@ -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)
|
||||
|
||||
220
jituan/services/deal_stats.py
Normal file
220
jituan/services/deal_stats.py
Normal 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,
|
||||
}
|
||||
@@ -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']},
|
||||
|
||||
249
jituan/services/order_deal.py
Normal file
249
jituan/services/order_deal.py
Normal 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)}
|
||||
@@ -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
|
||||
|
||||
@@ -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
116
jituan/views_order_deal.py
Normal 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]},
|
||||
})
|
||||
22
orders/migrations/0016_order_auto_settle_eligible.py
Normal file
22
orders/migrations/0016_order_auto_settle_eligible.py
Normal 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='允许自动结算',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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}')
|
||||
|
||||
Reference in New Issue
Block a user