feat: 结算超时禁派单与商家白名单(与自动结算独立)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -695,6 +695,9 @@ class ClubOrderDealConfig(QModel):
|
||||
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='商家单超时小时')
|
||||
# 超时禁派单:与自动结算独立;关则派单路径零拦截
|
||||
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_since = models.DateTimeField(null=True, blank=True, verbose_name='指标统计起点')
|
||||
updated_by = models.CharField(max_length=32, blank=True, default='')
|
||||
@@ -706,6 +709,23 @@ class ClubOrderDealConfig(QModel):
|
||||
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):
|
||||
"""商家成交/罚款/退款指标快照(仅统计起点之后事件累加)。"""
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
|
||||
@@ -95,6 +95,8 @@ def config_to_dict(row) -> dict:
|
||||
'auto_settle_enabled_at': '',
|
||||
'hours_platform': 48,
|
||||
'hours_merchant': 48,
|
||||
'dispatch_block_enabled': False,
|
||||
'dispatch_block_hours': 24,
|
||||
'stats_enabled': False,
|
||||
'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 '',
|
||||
'hours_platform': int(row.hours_platform 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_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:
|
||||
"""保存配置。关自动结算时清空本俱乐部所有 eligible。"""
|
||||
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:
|
||||
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:
|
||||
enabled = bool(data['auto_settle_enabled'])
|
||||
row.auto_settle_enabled = enabled
|
||||
@@ -189,11 +381,14 @@ def apply_enter_status_8(instance):
|
||||
club_id = (getattr(instance, 'ClubID', None) or '').strip()
|
||||
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
|
||||
merchant_uid = merchant_uid_of_order(instance)
|
||||
skip_wl = bool(merchant_uid and merchant_skips_auto_settle(club_id, merchant_uid))
|
||||
if (
|
||||
cfg
|
||||
and cfg.auto_settle_enabled
|
||||
and 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
|
||||
instance.AutoSettleEligible = True
|
||||
@@ -228,7 +423,7 @@ def mark_order_completed_unified(order, *, source='auto_settle', operator=''):
|
||||
if locked.Status != 8:
|
||||
return False, f'状态不是结算中({locked.Status})'
|
||||
if source == 'auto_settle':
|
||||
# 三重闸:资格位 + 到期时间 + 俱乐部开关仍开(关开关后立刻停,不靠等下一轮扫)
|
||||
# 多重闸:资格位 + 到期 + 开关 + 开启时间 + 商家白名单
|
||||
if not locked.AutoSettleEligible:
|
||||
return False, '非自动结算资格单'
|
||||
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))
|
||||
if enabled_at and settle_at and settle_at < enabled_at:
|
||||
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_fencheng = locked.PlayerCommission
|
||||
|
||||
@@ -29,7 +29,7 @@ def _auth_order(request):
|
||||
class OrderDealConfigView(APIView):
|
||||
"""
|
||||
POST /jituan/houtai/order-deal-config
|
||||
action: get | save
|
||||
action: get | save | whitelist_list | whitelist_save | whitelist_delete
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
@@ -44,14 +44,45 @@ class OrderDealConfigView(APIView):
|
||||
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)})
|
||||
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':
|
||||
try:
|
||||
operator = getattr(request.user, 'UserUID', '') or ''
|
||||
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})
|
||||
except ValueError as 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'})
|
||||
|
||||
|
||||
|
||||
@@ -270,6 +270,25 @@ class ShangjiaPaifaView(APIView):
|
||||
logger.warning(f"商家状态异常: {shangjia.zhuangtai}")
|
||||
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:
|
||||
logger.warning(f"余额不足: {shangjia.yue} < {jiage}")
|
||||
return Response({'code': 400, 'msg': f'余额不足,当前余额{shangjia.yue}元'})
|
||||
@@ -288,7 +307,6 @@ class ShangjiaPaifaView(APIView):
|
||||
return Response({'code': 400, 'msg': '指定用户不是打手'})
|
||||
|
||||
# ----- 10. 利率(打手+管事分成,打手优先) -----
|
||||
club_id = resolve_club_id_from_request(request)
|
||||
dashou_fencheng, guanshi_fencheng = calc_shangjia_order_fencheng(jiage, club_id=club_id)
|
||||
|
||||
# ----- 11. 生成订单ID -----
|
||||
|
||||
Reference in new issue
Block a user