feat: 结算超时禁派单与商家白名单(与自动结算独立)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQueandCursor committed 2026-08-09 08:03:15 +08:00
1 parent 228c362415
commit d849991380
5 files changed
+315 -4

No files matched your search

@@ -0,0 +1,40 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0025_club_withdraw_auto_pass_leixings'),
]
operations = [
migrations.AddField(
model_name='cluborderdealconfig',
name='dispatch_block_enabled',
field=models.BooleanField(default=False, verbose_name='结算超时禁派单开关'),
),
migrations.AddField(
model_name='cluborderdealconfig',
name='dispatch_block_hours',
field=models.PositiveIntegerField(default=24, verbose_name='禁派单超时小时'),
),
migrations.CreateModel(
name='ClubMerchantDealWhitelist',
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, verbose_name='俱乐部')),
('merchant_uid', models.CharField(db_index=True, max_length=32, verbose_name='商家UserUID')),
('skip_auto_settle', models.BooleanField(default=False, verbose_name='跳过自动结算')),
('skip_dispatch_block', models.BooleanField(default=False, verbose_name='跳过超时禁派单')),
('remark', models.CharField(blank=True, default='', max_length=200)),
('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_merchant_deal_whitelist',
'unique_together': {('club_id', 'merchant_uid')},
},
),
]
+20
View File
@@ -695,6 +695,9 @@ class ClubOrderDealConfig(QModel):
auto_settle_enabled_at = models.DateTimeField(null=True, blank=True, verbose_name='本次打开自动结算时间') auto_settle_enabled_at = models.DateTimeField(null=True, blank=True, verbose_name='本次打开自动结算时间')
hours_platform = models.PositiveIntegerField(default=48, verbose_name='平台单超时小时') hours_platform = models.PositiveIntegerField(default=48, verbose_name='平台单超时小时')
hours_merchant = models.PositiveIntegerField(default=48, verbose_name='商家单超时小时') hours_merchant = models.PositiveIntegerField(default=48, verbose_name='商家单超时小时')
# 超时禁派单:与自动结算独立;关则派单路径零拦截
dispatch_block_enabled = models.BooleanField(default=False, verbose_name='结算超时禁派单开关')
dispatch_block_hours = models.PositiveIntegerField(default=24, verbose_name='禁派单超时小时')
stats_enabled = models.BooleanField(default=False, verbose_name='成交指标统计开关') stats_enabled = models.BooleanField(default=False, verbose_name='成交指标统计开关')
stats_since = models.DateTimeField(null=True, blank=True, verbose_name='指标统计起点') stats_since = models.DateTimeField(null=True, blank=True, verbose_name='指标统计起点')
updated_by = models.CharField(max_length=32, blank=True, default='') updated_by = models.CharField(max_length=32, blank=True, default='')
@@ -706,6 +709,23 @@ class ClubOrderDealConfig(QModel):
verbose_name = '成交与自动结算配置' verbose_name = '成交与自动结算配置'
class ClubMerchantDealWhitelist(QModel):
"""商家白名单:跳过自动结算 / 跳过超时禁派单,两项独立。"""
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部')
merchant_uid = models.CharField(max_length=32, db_index=True, verbose_name='商家UserUID')
skip_auto_settle = models.BooleanField(default=False, verbose_name='跳过自动结算')
skip_dispatch_block = models.BooleanField(default=False, verbose_name='跳过超时禁派单')
remark = models.CharField(max_length=200, blank=True, default='')
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_merchant_deal_whitelist'
verbose_name = '商家成交白名单'
unique_together = [['club_id', 'merchant_uid']]
class MerchantDealStat(QModel): class MerchantDealStat(QModel):
"""商家成交/罚款/退款指标快照(仅统计起点之后事件累加)。""" """商家成交/罚款/退款指标快照(仅统计起点之后事件累加)。"""
club_id = models.CharField(max_length=16, db_index=True) club_id = models.CharField(max_length=16, db_index=True)
+203 -1
View File
@@ -95,6 +95,8 @@ def config_to_dict(row) -> dict:
'auto_settle_enabled_at': '', 'auto_settle_enabled_at': '',
'hours_platform': 48, 'hours_platform': 48,
'hours_merchant': 48, 'hours_merchant': 48,
'dispatch_block_enabled': False,
'dispatch_block_hours': 24,
'stats_enabled': False, 'stats_enabled': False,
'stats_since': '', 'stats_since': '',
} }
@@ -104,11 +106,196 @@ def config_to_dict(row) -> dict:
'auto_settle_enabled_at': row.auto_settle_enabled_at.isoformat() if row.auto_settle_enabled_at else '', '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_platform': int(row.hours_platform or 48),
'hours_merchant': int(row.hours_merchant or 48), 'hours_merchant': int(row.hours_merchant or 48),
'dispatch_block_enabled': bool(getattr(row, 'dispatch_block_enabled', False)),
'dispatch_block_hours': int(getattr(row, 'dispatch_block_hours', None) or 24),
'stats_enabled': bool(row.stats_enabled), 'stats_enabled': bool(row.stats_enabled),
'stats_since': row.stats_since.isoformat() if row.stats_since else '', 'stats_since': row.stats_since.isoformat() if row.stats_since else '',
} }
def merchant_uid_of_order(order) -> str:
"""商家单 MerchantID;非商家单返回空。"""
if not order:
return ''
try:
if int(getattr(order, 'Platform', 0) or 0) != 2:
return ''
except (TypeError, ValueError):
return ''
ext = getattr(order, 'shangjia_kuozhan', None)
mid = (getattr(ext, 'MerchantID', None) or '') if ext else ''
if mid:
return str(mid).strip()
try:
from orders.models import MerchantOrderExt
row = MerchantOrderExt.objects.filter(Order_id=order.pk).only('MerchantID').first()
return str(getattr(row, 'MerchantID', None) or '').strip() if row else ''
except Exception:
return ''
def get_merchant_whitelist(club_id: str, merchant_uid: str):
from jituan.models import ClubMerchantDealWhitelist
cid = (club_id or '').strip()
uid = (merchant_uid or '').strip()
if not cid or not uid:
return None
return ClubMerchantDealWhitelist.query.filter(club_id=cid, merchant_uid=uid).first()
def merchant_skips_auto_settle(club_id: str, merchant_uid: str) -> bool:
row = get_merchant_whitelist(club_id, merchant_uid)
return bool(row and row.skip_auto_settle)
def merchant_skips_dispatch_block(club_id: str, merchant_uid: str) -> bool:
row = get_merchant_whitelist(club_id, merchant_uid)
return bool(row and row.skip_dispatch_block)
def list_merchant_whitelist(club_id: str) -> list:
from jituan.models import ClubMerchantDealWhitelist
cid = (club_id or '').strip()
if not cid:
return []
rows = ClubMerchantDealWhitelist.query.filter(club_id=cid).order_by('-UpdateTime')[:500]
return [
{
'merchant_uid': r.merchant_uid,
'skip_auto_settle': bool(r.skip_auto_settle),
'skip_dispatch_block': bool(r.skip_dispatch_block),
'remark': r.remark or '',
'updated_by': r.updated_by or '',
'UpdateTime': r.UpdateTime.isoformat() if r.UpdateTime else '',
}
for r in rows
]
def save_merchant_whitelist(
club_id: str,
merchant_uid: str,
*,
skip_auto_settle: bool = False,
skip_dispatch_block: bool = False,
remark: str = '',
updated_by: str = '',
) -> dict:
"""新增/更新白名单。两项开关独立;都关则删除该行。"""
from jituan.models import ClubMerchantDealWhitelist
cid = (club_id or '').strip()
uid = (merchant_uid or '').strip()
if not cid or not uid:
raise ValueError('俱乐部与商家UID不能为空')
skip_as = bool(skip_auto_settle)
skip_db = bool(skip_dispatch_block)
if not skip_as and not skip_db:
ClubMerchantDealWhitelist.objects.filter(club_id=cid, merchant_uid=uid).delete()
return {'merchant_uid': uid, 'deleted': True}
row = ClubMerchantDealWhitelist.query.filter(club_id=cid, merchant_uid=uid).first()
if not row:
row = ClubMerchantDealWhitelist(club_id=cid, merchant_uid=uid)
row.skip_auto_settle = skip_as
row.skip_dispatch_block = skip_db
row.remark = (remark or '')[:200]
row.updated_by = (updated_by or '')[:32]
row.save()
# 勾选跳过自动结算:立刻清掉该商家本俱乐部未结完的 eligible,避免再被扫到
if skip_as:
_clear_merchant_auto_settle_eligible(cid, uid)
return {
'merchant_uid': uid,
'skip_auto_settle': skip_as,
'skip_dispatch_block': skip_db,
'remark': row.remark,
'deleted': False,
}
def delete_merchant_whitelist(club_id: str, merchant_uid: str) -> bool:
from jituan.models import ClubMerchantDealWhitelist
cid = (club_id or '').strip()
uid = (merchant_uid or '').strip()
if not cid or not uid:
return False
n, _ = ClubMerchantDealWhitelist.objects.filter(club_id=cid, merchant_uid=uid).delete()
return n > 0
def _clear_merchant_auto_settle_eligible(club_id: str, merchant_uid: str):
from orders.models import Order
Order.objects.filter(
ClubID=club_id,
Platform=2,
AutoSettleEligible=True,
shangjia_kuozhan__MerchantID=merchant_uid,
).update(
AutoSettleEligible=False,
AutoExpireAt=None,
PendingDispatch=False,
AutoTaskID='',
)
def check_merchant_dispatch_block(club_id: str, merchant_uid: str) -> dict:
"""
派单前检查:功能开且非白名单时,该商家任意 Status=8 且结算停留超时 → 禁止派单。
不改订单状态、不打款。关闭开关立即放行。
返回 {ok: True} 或 {ok: False, msg, overdue_order_ids, hours}
"""
from orders.models import Order
from django.db.models import Q
cid = (club_id or '').strip()
uid = (merchant_uid or '').strip()
if not cid or not uid:
return {'ok': True}
cfg = get_or_create_deal_config(cid)
if not cfg or not bool(getattr(cfg, 'dispatch_block_enabled', False)):
return {'ok': True}
if merchant_skips_dispatch_block(cid, uid):
return {'ok': True}
hours = max(1, min(int(getattr(cfg, 'dispatch_block_hours', None) or 24), 720))
now = _db_datetime(timezone.now())
cutoff = now - timedelta(hours=hours)
# 全部结算中订单:SettlementTime 超时;无 SettlementTime 的脏数据也按超时拦(逼商家处理)
qs = (
Order.objects.filter(
ClubID=cid,
Platform=2,
Status=8,
shangjia_kuozhan__MerchantID=uid,
)
.filter(Q(SettlementTime__isnull=True) | Q(SettlementTime__lte=cutoff))
.order_by('SettlementTime', 'CreateTime')
.values_list('OrderID', flat=True)[:20]
)
overdue_ids = [str(x) for x in qs if x]
if not overdue_ids:
return {'ok': True}
show = overdue_ids[:5]
more = len(overdue_ids) - len(show)
id_text = '、'.join(show) + (f' 等{more}单' if more > 0 else '')
msg = (
f'您有结算中订单已超过{hours}小时未处理(如 {id_text}),'
f'请先完成结算或退款后再派单'
)
return {
'ok': False,
'msg': msg,
'hours': hours,
'overdue_order_ids': overdue_ids,
'overdue_count': len(overdue_ids),
}
def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict: def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict:
"""保存配置。关自动结算时清空本俱乐部所有 eligible。""" """保存配置。关自动结算时清空本俱乐部所有 eligible。"""
row = get_or_create_deal_config(club_id) row = get_or_create_deal_config(club_id)
@@ -124,6 +311,11 @@ def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict:
if 'hours_merchant' in data and data['hours_merchant'] is not None: if 'hours_merchant' in data and data['hours_merchant'] is not None:
row.hours_merchant = max(1, min(int(data['hours_merchant']), 720)) row.hours_merchant = max(1, min(int(data['hours_merchant']), 720))
if 'dispatch_block_hours' in data and data['dispatch_block_hours'] is not None:
row.dispatch_block_hours = max(1, min(int(data['dispatch_block_hours']), 720))
if 'dispatch_block_enabled' in data and data['dispatch_block_enabled'] is not None:
row.dispatch_block_enabled = bool(data['dispatch_block_enabled'])
if 'auto_settle_enabled' in data and data['auto_settle_enabled'] is not None: if 'auto_settle_enabled' in data and data['auto_settle_enabled'] is not None:
enabled = bool(data['auto_settle_enabled']) enabled = bool(data['auto_settle_enabled'])
row.auto_settle_enabled = enabled row.auto_settle_enabled = enabled
@@ -189,11 +381,14 @@ def apply_enter_status_8(instance):
club_id = (getattr(instance, 'ClubID', None) or '').strip() club_id = (getattr(instance, 'ClubID', None) or '').strip()
cfg = get_or_create_deal_config(club_id) if club_id else None cfg = get_or_create_deal_config(club_id) if club_id else None
enabled_at = _db_datetime(getattr(cfg, 'auto_settle_enabled_at', None)) if cfg else None enabled_at = _db_datetime(getattr(cfg, 'auto_settle_enabled_at', None)) if cfg else None
merchant_uid = merchant_uid_of_order(instance)
skip_wl = bool(merchant_uid and merchant_skips_auto_settle(club_id, merchant_uid))
if ( if (
cfg cfg
and cfg.auto_settle_enabled and cfg.auto_settle_enabled
and enabled_at and enabled_at
and now >= enabled_at and now >= enabled_at
and not skip_wl
): ):
hours = int(cfg.hours_merchant if instance.Platform == 2 else cfg.hours_platform) or 48 hours = int(cfg.hours_merchant if instance.Platform == 2 else cfg.hours_platform) or 48
instance.AutoSettleEligible = True instance.AutoSettleEligible = True
@@ -228,7 +423,7 @@ def mark_order_completed_unified(order, *, source='auto_settle', operator=''):
if locked.Status != 8: if locked.Status != 8:
return False, f'状态不是结算中({locked.Status})' return False, f'状态不是结算中({locked.Status})'
if source == 'auto_settle': if source == 'auto_settle':
# 三重闸:资格位 + 到期时间 + 俱乐部开关仍开(关开关后立刻停,不靠等下一轮扫) # 多重闸:资格位 + 到期 + 开关 + 开启时间 + 商家白名单
if not locked.AutoSettleEligible: if not locked.AutoSettleEligible:
return False, '非自动结算资格单' return False, '非自动结算资格单'
now = _db_datetime(timezone.now()) now = _db_datetime(timezone.now())
@@ -244,6 +439,13 @@ def mark_order_completed_unified(order, *, source='auto_settle', operator=''):
settle_at = _db_datetime(getattr(locked, 'SettlementTime', None)) settle_at = _db_datetime(getattr(locked, 'SettlementTime', None))
if enabled_at and settle_at and settle_at < enabled_at: if enabled_at and settle_at and settle_at < enabled_at:
return False, '订单进入结算中早于本次开启,不自动结算' return False, '订单进入结算中早于本次开启,不自动结算'
mid = merchant_uid_of_order(locked)
if mid and merchant_skips_auto_settle(club_id, mid):
# 防御:白名单商家即使误带 eligible 也不打款
locked.AutoSettleEligible = False
locked.AutoExpireAt = None
locked.save(update_fields=['AutoSettleEligible', 'AutoExpireAt', 'UpdateTime'])
return False, '商家在自动结算白名单,跳过'
dashou_id = locked.PlayerID dashou_id = locked.PlayerID
dashou_fencheng = locked.PlayerCommission dashou_fencheng = locked.PlayerCommission
+33 -2
View File
@@ -29,7 +29,7 @@ def _auth_order(request):
class OrderDealConfigView(APIView): class OrderDealConfigView(APIView):
""" """
POST /jituan/houtai/order-deal-config POST /jituan/houtai/order-deal-config
action: get | save action: get | save | whitelist_list | whitelist_save | whitelist_delete
""" """
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
parser_classes = [JSONParser] parser_classes = [JSONParser]
@@ -44,14 +44,45 @@ class OrderDealConfigView(APIView):
action = (request.data.get('action') or 'get').strip().lower() action = (request.data.get('action') or 'get').strip().lower()
if action == 'get': if action == 'get':
row = deal_cfg.get_or_create_deal_config(club_id) row = deal_cfg.get_or_create_deal_config(club_id)
return Response({'code': 0, 'msg': 'ok', 'data': deal_cfg.config_to_dict(row)}) data = deal_cfg.config_to_dict(row)
data['whitelist'] = deal_cfg.list_merchant_whitelist(club_id)
return Response({'code': 0, 'msg': 'ok', 'data': data})
if action == 'save': if action == 'save':
try: try:
operator = getattr(request.user, 'UserUID', '') or '' operator = getattr(request.user, 'UserUID', '') or ''
data = deal_cfg.save_deal_config(club_id, request.data, updated_by=operator) data = deal_cfg.save_deal_config(club_id, request.data, updated_by=operator)
data['whitelist'] = deal_cfg.list_merchant_whitelist(club_id)
return Response({'code': 0, 'msg': '已保存', 'data': data}) return Response({'code': 0, 'msg': '已保存', 'data': data})
except ValueError as e: except ValueError as e:
return Response({'code': 400, 'msg': str(e)}) return Response({'code': 400, 'msg': str(e)})
if action == 'whitelist_list':
return Response({
'code': 0,
'msg': 'ok',
'data': {'list': deal_cfg.list_merchant_whitelist(club_id)},
})
if action == 'whitelist_save':
try:
operator = getattr(request.user, 'UserUID', '') or ''
row = deal_cfg.save_merchant_whitelist(
club_id,
(request.data.get('merchant_uid') or '').strip(),
skip_auto_settle=bool(request.data.get('skip_auto_settle')),
skip_dispatch_block=bool(request.data.get('skip_dispatch_block')),
remark=(request.data.get('remark') or '').strip(),
updated_by=operator,
)
return Response({'code': 0, 'msg': '白名单已更新', 'data': row})
except ValueError as e:
return Response({'code': 400, 'msg': str(e)})
if action == 'whitelist_delete':
ok = deal_cfg.delete_merchant_whitelist(
club_id, (request.data.get('merchant_uid') or '').strip(),
)
return Response({
'code': 0 if ok else 400,
'msg': '已删除' if ok else '未找到该白名单',
})
return Response({'code': 400, 'msg': '无效 action'}) return Response({'code': 400, 'msg': '无效 action'})
+19 -1
View File
@@ -270,6 +270,25 @@ class ShangjiaPaifaView(APIView):
logger.warning(f"商家状态异常: {shangjia.zhuangtai}") logger.warning(f"商家状态异常: {shangjia.zhuangtai}")
return Response({'code': 403, 'msg': '商家状态异常'}) return Response({'code': 403, 'msg': '商家状态异常'})
# ----- 8.1 结算超时禁派单(独立开关;关则零效果;白名单可跳过)-----
club_id = resolve_club_id_from_request(request)
try:
from jituan.services.order_deal import check_merchant_dispatch_block
block = check_merchant_dispatch_block(club_id, request.user.UserUID)
if not block.get('ok', True):
return Response({
'code': 400,
'msg': block.get('msg') or '存在超时未处理的结算中订单,暂不可派单',
'data': {
'overdue_order_ids': block.get('overdue_order_ids') or [],
'overdue_count': block.get('overdue_count') or 0,
'hours': block.get('hours'),
},
})
except Exception as e:
logger.exception('结算超时禁派单检查异常,为资金安全阻断派单: %s', e)
return Response({'code': 500, 'msg': '派单前检查失败,请稍后重试'})
if jiage > shangjia.yue: if jiage > shangjia.yue:
logger.warning(f"余额不足: {shangjia.yue} < {jiage}") logger.warning(f"余额不足: {shangjia.yue} < {jiage}")
return Response({'code': 400, 'msg': f'余额不足,当前余额{shangjia.yue}元'}) return Response({'code': 400, 'msg': f'余额不足,当前余额{shangjia.yue}元'})
@@ -288,7 +307,6 @@ class ShangjiaPaifaView(APIView):
return Response({'code': 400, 'msg': '指定用户不是打手'}) return Response({'code': 400, 'msg': '指定用户不是打手'})
# ----- 10. 利率(打手+管事分成,打手优先) ----- # ----- 10. 利率(打手+管事分成,打手优先) -----
club_id = resolve_club_id_from_request(request)
dashou_fencheng, guanshi_fencheng = calc_shangjia_order_fencheng(jiage, club_id=club_id) dashou_fencheng, guanshi_fencheng = calc_shangjia_order_fencheng(jiage, club_id=club_id)
# ----- 11. 生成订单ID ----- # ----- 11. 生成订单ID -----