From d84999138054b7520856858bb42a868f0061dfa8 Mon Sep 17 00:00:00 2001 From: XingQue Date: Sun, 9 Aug 2026 08:03:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=93=E7=AE=97=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E7=A6=81=E6=B4=BE=E5=8D=95=E4=B8=8E=E5=95=86=E5=AE=B6=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=EF=BC=88=E4=B8=8E=E8=87=AA=E5=8A=A8=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E7=8B=AC=E7=AB=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- ...6_dispatch_block_and_merchant_whitelist.py | 40 ++++ jituan/models.py | 20 ++ jituan/services/order_deal.py | 204 +++++++++++++++++- jituan/views_order_deal.py | 35 ++- orders/views/merchant.py | 20 +- 5 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 jituan/migrations/0026_dispatch_block_and_merchant_whitelist.py diff --git a/jituan/migrations/0026_dispatch_block_and_merchant_whitelist.py b/jituan/migrations/0026_dispatch_block_and_merchant_whitelist.py new file mode 100644 index 0000000..d876127 --- /dev/null +++ b/jituan/migrations/0026_dispatch_block_and_merchant_whitelist.py @@ -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')}, + }, + ), + ] diff --git a/jituan/models.py b/jituan/models.py index 64174d3..f6cc034 100644 --- a/jituan/models.py +++ b/jituan/models.py @@ -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) diff --git a/jituan/services/order_deal.py b/jituan/services/order_deal.py index b56d5e5..af35aa5 100644 --- a/jituan/services/order_deal.py +++ b/jituan/services/order_deal.py @@ -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 diff --git a/jituan/views_order_deal.py b/jituan/views_order_deal.py index ffe0889..c4aa396 100644 --- a/jituan/views_order_deal.py +++ b/jituan/views_order_deal.py @@ -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'}) diff --git a/orders/views/merchant.py b/orders/views/merchant.py index 52d970d..eefccf9 100644 --- a/orders/views/merchant.py +++ b/orders/views/merchant.py @@ -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 -----