From 6c96eb3bcb33b19a22b5cdcbb8d52a3fa562c112 Mon Sep 17 00:00:00 2001 From: XingQue Date: Sun, 26 Jul 2026 19:36:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=86=BB=E7=BB=93=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E5=8F=AF=E8=AF=84=E4=BC=B0+=E5=88=86=E6=9C=9F=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=8C=96=EF=BC=8C=E5=AE=9A=E6=97=B6=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E6=9B=B4=E5=8F=8A=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台条件真正按罚款率/单量等评估;选项式配置所需字段规范化;Celery 改为每5分钟;小程序说明文案接口补齐。 Co-authored-by: Cursor --- a_long_dianjing/celery.py | 2 +- jituan/services/fund_freeze.py | 180 +++++++++++++--- jituan/services/fund_freeze_admin.py | 43 +++- jituan/services/fund_freeze_conditions.py | 250 ++++++++++++++++++++++ users/views/fund_freeze_mp.py | 31 ++- users/views/tixian.py | 20 ++ 6 files changed, 485 insertions(+), 41 deletions(-) create mode 100644 jituan/services/fund_freeze_conditions.py diff --git a/a_long_dianjing/celery.py b/a_long_dianjing/celery.py index 5ea0dc0..9a30ae8 100644 --- a/a_long_dianjing/celery.py +++ b/a_long_dianjing/celery.py @@ -89,7 +89,7 @@ app.conf.beat_schedule = { # 5. 资金冻结到期解冻(默认无冻结单时几乎空跑) 'scan_fund_freeze_due': { 'task': 'jituan.tasks.scan_fund_freeze_due', - 'schedule': crontab(minute='*/15'), + 'schedule': crontab(minute='*/5'), 'options': { 'queue': 'periodic_tasks', 'priority': 4, diff --git a/jituan/services/fund_freeze.py b/jituan/services/fund_freeze.py index 874ec45..d8f9f85 100644 --- a/jituan/services/fund_freeze.py +++ b/jituan/services/fund_freeze.py @@ -118,6 +118,8 @@ def _config_snapshot(cfg: ClubFundFreezeConfig) -> Dict[str, Any]: 'installment_json': cfg.installment_json, 'condition_json': cfg.condition_json, 'freeze_min_days': cfg.freeze_min_days, + 'display_reason': (cfg.remark or '')[:255], + 'remark': (cfg.remark or '')[:255], } @@ -233,7 +235,11 @@ def credit_role_balance( order_id=(order_id or '')[:64], czjilu_id=(czjilu_id or '')[:64], biz_id=biz_id[:64], - freeze_reason=(freeze_reason or f'{source_type}冻结')[:255], + freeze_reason=( + freeze_reason + or (getattr(cfg, 'remark', None) if cfg else None) + or f'{source_type}冻结' + )[:255], config_snapshot=_config_snapshot(cfg), current_step=1, next_eligible_at=next_at, @@ -381,12 +387,14 @@ def apply_unfreeze_step( force: bool = False, ) -> Dict[str, Any]: """ - 执行一笔冻结单的当前分期步(骨架:先支持到期/手动;条件解冻 P4 再接指标)。 - force=True 时忽略 next_eligible_at(后台手动)。 + 执行一笔冻结单的当前分期步。 + force=True:后台手动,跳过时间/条件门槛。 + 条件未达标时不推进步数、不改 next_eligible_at,供定时任务下次重试。 """ from django.db import transaction from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan from jituan.models import FundUnfreezeEvent + from jituan.services.fund_freeze_conditions import evaluate_unfreeze_conditions with transaction.atomic(): ledger = ( @@ -400,29 +408,37 @@ def apply_unfreeze_step( return {'ok': False, 'msg': '已完结或已作废'} now = timezone.now() - if not force and ledger.next_eligible_at and ledger.next_eligible_at > now: - return {'ok': False, 'msg': '未到解冻时间', 'next_eligible_at': ledger.next_eligible_at} - snap = ledger.config_snapshot or {} trigger_mode = (snap.get('unfreeze_trigger') or 'time_only') + min_days = max(0, int(snap.get('freeze_min_days') or 0)) + frozen_at = ledger.frozen_at or now + min_days_ok = force or (now >= frozen_at + timedelta(days=min_days)) + time_ok = force or ( + not ledger.next_eligible_at or ledger.next_eligible_at <= now + ) + if trigger_mode == 'manual_only' and not force: return {'ok': False, 'msg': '仅允许手动解冻'} - # condition_*:自动任务暂不评估指标(P4);手动 force 可解 - if trigger_mode == 'condition_only' and not force: - return {'ok': False, 'msg': '条件解冻尚未启用自动评估'} steps = _plan_steps(snap) step_idx = max(0, int(ledger.current_step or 1) - 1) if step_idx >= len(steps): - step = {'seq': ledger.current_step, 'type': 'remain', 'value': 1} + step = {'seq': ledger.current_step, 'type': 'remain', 'value': 1, 'need_condition': False} else: step = steps[step_idx] + need_step_cond = bool(step.get('need_condition')) - amount = _step_unfreeze_amount(ledger, step) - if amount <= 0: - ledger.status = FundFreezeLedger.STATUS_DONE - ledger.save(update_fields=['status', 'UpdateTime']) - return {'ok': True, 'amount': Decimal('0.00'), 'done': True} + cond_ok = True + cond_snap: Dict[str, Any] = {} + need_eval = ( + not force + and ( + need_step_cond + or trigger_mode in ( + 'condition_only', 'time_and_condition', 'time_or_condition', + ) + ) + ) profile_map = { ROLE_DASHOU: UserDashou, @@ -442,6 +458,63 @@ def apply_unfreeze_step( if not profile: return {'ok': False, 'msg': '角色扩展表不存在'} + if need_eval: + cond_ok, cond_snap = evaluate_unfreeze_conditions( + ledger.club_id, + ledger.user_id, + ledger.role, + snap.get('condition_json') or {}, + profile=profile, + ) + + if not force: + if trigger_mode == 'time_only': + if not time_ok: + return {'ok': False, 'msg': '未到解冻时间', 'next_eligible_at': ledger.next_eligible_at} + elif trigger_mode == 'condition_only': + if not min_days_ok: + return {'ok': False, 'msg': f'未满最短冻结天数({min_days}天)'} + if not cond_ok: + return { + 'ok': False, + 'msg': '条件未达标', + 'condition': cond_snap, + 'retry': True, + } + elif trigger_mode == 'time_and_condition': + if not time_ok: + return {'ok': False, 'msg': '未到解冻时间', 'next_eligible_at': ledger.next_eligible_at} + if not cond_ok: + return { + 'ok': False, + 'msg': '已到期但条件未达标,等待达标后自动解', + 'condition': cond_snap, + 'retry': True, + } + elif trigger_mode == 'time_or_condition': + if not (time_ok or cond_ok): + return { + 'ok': False, + 'msg': '未到期且条件未达标', + 'condition': cond_snap, + 'retry': True, + } + + # 分期单步要求达标时,即使全局是 time_only / time_or 已到期,也仍需条件 + if need_step_cond and not cond_ok: + return { + 'ok': False, + 'msg': '本分期步要求条件达标', + 'condition': cond_snap, + 'retry': True, + } + + amount = _step_unfreeze_amount(ledger, step) + if amount <= 0: + ledger.status = FundFreezeLedger.STATUS_DONE + ledger.save(update_fields=['status', 'UpdateTime']) + return {'ok': True, 'amount': Decimal('0.00'), 'done': True} + # 冻结池不足则按实际可解 cur_frozen = _money(getattr(profile, 'dongjie_yue', 0) or 0) if cur_frozen < amount: @@ -472,19 +545,22 @@ def apply_unfreeze_step( else: ledger.status = FundFreezeLedger.STATUS_PARTIAL ledger.current_step = next_step - # 下一步到期时间 if next_step - 1 < len(steps): nxt = steps[next_step - 1] days = int(nxt.get('after_days') or 0) - # after_days 相对冻结日 - ledger.next_eligible_at = ledger.frozen_at + timedelta(days=days) + ledger.next_eligible_at = frozen_at + timedelta(days=days) else: + # 无更多配置步:剩余立即进入可扫状态,下一步按 remain 吃完 ledger.next_eligible_at = now ledger.save(update_fields=[ 'amount_unfrozen', 'status', 'current_step', 'next_eligible_at', 'UpdateTime', ]) + event_trigger = trigger + if not force and trigger_mode in ('condition_only', 'time_or_condition') and cond_ok and not time_ok: + event_trigger = 'condition' + FundUnfreezeEvent.query.create( ledger_id=ledger.id, club_id=ledger.club_id, @@ -492,10 +568,10 @@ def apply_unfreeze_step( role=ledger.role, step_seq=int(step.get('seq') or ledger.current_step), amount=amount, - trigger=trigger, - reason=(reason or f'{trigger}解冻')[:255], + trigger=event_trigger, + reason=(reason or f'{event_trigger}解冻')[:255], operator_id=(operator_id or '')[:32], - condition_snapshot={}, + condition_snapshot=cond_snap or {}, ) return { @@ -504,29 +580,65 @@ def apply_unfreeze_step( 'remain': max(remain, Decimal('0.00')), 'done': remain <= 0, 'ledger_id': ledger.id, + 'condition': cond_snap, } def scan_due_freeze_ledgers(limit: int = 200) -> Dict[str, Any]: - """定时任务入口:扫到期冻结单并尝试解冻一步。""" + """ + 定时任务入口。 + 1) 到期(next_eligible_at<=now)的冻结中/部分解冻单 + 2) 条件触发类:condition_only / time_or_condition —— 即使未到期也扫描,避免「条件先达标却要干等到期」 + 临界:条件未达标只记 fail+retry,不改账本;下次 beat 再试。 + """ + from django.db.models import Q + now = timezone.now() - qs = ( - FundFreezeLedger.query.filter( - status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL], - next_eligible_at__lte=now, - ) - .order_by('next_eligible_at', 'id')[:limit] + base = FundFreezeLedger.query.filter( + status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL], ) - ok_n = fail_n = 0 - for row in qs: + due_ids = list( + base.filter(next_eligible_at__lte=now) + .order_by('next_eligible_at', 'id') + .values_list('id', flat=True)[:limit] + ) + # JSON 字段路径:MySQL/Postgres Django JSONField 支持 key 查询 + cond_ids = [] + remain = max(0, limit - len(due_ids)) + if remain > 0: try: - r = apply_unfreeze_step(row.id, trigger='schedule') + cond_ids = list( + base.filter( + Q(config_snapshot__unfreeze_trigger='condition_only') + | Q(config_snapshot__unfreeze_trigger='time_or_condition') + ) + .exclude(id__in=due_ids) + .order_by('id') + .values_list('id', flat=True)[:remain] + ) + except Exception as e: + logger.warning('scan condition branch skipped: %s', e) + + ids = due_ids + cond_ids + ok_n = fail_n = retry_n = 0 + for lid in ids: + try: + r = apply_unfreeze_step(lid, trigger='schedule') if r.get('ok'): ok_n += 1 else: fail_n += 1 - logger.info('unfreeze skip id=%s msg=%s', row.id, r.get('msg')) + if r.get('retry'): + retry_n += 1 + logger.info('unfreeze skip id=%s msg=%s', lid, r.get('msg')) except Exception as e: fail_n += 1 - logger.exception('unfreeze error id=%s: %s', row.id, e) - return {'ok': ok_n, 'fail': fail_n, 'scanned': ok_n + fail_n} + logger.exception('unfreeze error id=%s: %s', lid, e) + return { + 'ok': ok_n, + 'fail': fail_n, + 'retry': retry_n, + 'scanned': len(ids), + 'due': len(due_ids), + 'cond_scan': len(cond_ids), + } diff --git a/jituan/services/fund_freeze_admin.py b/jituan/services/fund_freeze_admin.py index e95cb82..d2b19bc 100644 --- a/jituan/services/fund_freeze_admin.py +++ b/jituan/services/fund_freeze_admin.py @@ -72,6 +72,7 @@ def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) - }, 'freeze_min_days': 0, 'remark': '', + 'display_reason': '', 'exists': False, } return { @@ -90,6 +91,7 @@ def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) - 'condition_json': row.condition_json or {}, 'freeze_min_days': int(row.freeze_min_days or 0), 'remark': row.remark or '', + 'display_reason': row.remark or '', 'updated_by': row.updated_by or '', 'exists': True, } @@ -158,8 +160,40 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict] installment_json = json.loads(installment_json) except Exception: return None, 'installment_json 不是合法 JSON' - if schedule_mode == 'installment' and not (installment_json.get('steps') or []): - installment_json = DEFAULT_INSTALLMENT + if schedule_mode == 'installment': + steps = installment_json.get('steps') if isinstance(installment_json, dict) else None + if not steps: + installment_json = dict(DEFAULT_INSTALLMENT) + steps = installment_json['steps'] + # 规范化:自由添加的分期重新编号,校验类型 + normalized = [] + for i, s in enumerate(steps): + if not isinstance(s, dict): + return None, f'第{i + 1}期格式错误' + typ = (s.get('type') or 'ratio').strip().lower() + if typ not in ('ratio', 'fixed', 'remain'): + return None, f'第{i + 1}期类型无效(ratio/fixed/remain)' + try: + after_days = max(0, int(s.get('after_days') or 0)) + except (TypeError, ValueError): + return None, f'第{i + 1}期天数无效' + try: + value = float(s.get('value') or 0) + except (TypeError, ValueError): + return None, f'第{i + 1}期数值无效' + if typ == 'ratio' and (value < 0 or value > 1): + return None, f'第{i + 1}期比例须在 0~1' + normalized.append({ + 'seq': i + 1, + 'after_days': after_days, + 'type': typ, + 'value': value if typ != 'remain' else 1, + 'need_condition': bool(s.get('need_condition')), + }) + if not normalized: + return None, '请至少添加一期分期' + # 最后一期建议 remain,若全是 ratio 且合计偏离太大仅告警不拦截——尾差由 remain 或末步吃完 + installment_json = {'steps': normalized} condition_json = data.get('condition_json') or {} if isinstance(condition_json, str): @@ -172,6 +206,9 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict] if freeze_min_days < 0: return None, '最短冻结天数不能为负' + # 用户可见冻结说明:优先 display_reason,兼容 remark + display_reason = (data.get('display_reason') or data.get('remark') or '')[:255] + row = ClubFundFreezeConfig.query.filter(club_id=club_id, role=role).first() if not row: row = ClubFundFreezeConfig( @@ -190,7 +227,7 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict] row.installment_json = installment_json row.condition_json = condition_json row.freeze_min_days = freeze_min_days - row.remark = (data.get('remark') or '')[:255] + row.remark = display_reason row.updated_by = (operator or '')[:32] row.save() invalidate_freeze_config_cache(club_id, role) diff --git a/jituan/services/fund_freeze_conditions.py b/jituan/services/fund_freeze_conditions.py new file mode 100644 index 0000000..160e141 --- /dev/null +++ b/jituan/services/fund_freeze_conditions.py @@ -0,0 +1,250 @@ +"""资金冻结:解冻条件评估(实时算窗口指标,并回写角色 risk_* 快照)。""" +from __future__ import annotations + +import logging +from datetime import timedelta +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Dict, Optional, Tuple + +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +def _dec(v, default='0') -> Decimal: + try: + return Decimal(str(v if v is not None else default)).quantize( + Decimal('0.000001'), rounding=ROUND_HALF_UP + ) + except Exception: + return Decimal(default) + + +def _int(v, default=0) -> int: + try: + return int(v) + except (TypeError, ValueError): + return default + + +def _flag(cfg: dict, enable_key: str, legacy_active: bool) -> bool: + """新配置用 enable_*;旧配置无 enable 时按 legacy_active 推断。""" + if enable_key in cfg: + return bool(cfg.get(enable_key)) + return legacy_active + + +def compute_risk_metrics( + club_id: str, + user_id: str, + role: str, + condition_json: Optional[dict] = None, +) -> Dict[str, Any]: + """按窗口统计完成单量 / 罚款率 / 投诉率 / 有效邀请。失败时返回安全默认。""" + cfg = condition_json or {} + fine_days = max(1, _int(cfg.get('fine_rate_window_days'), 30)) + complaint_days = max(1, _int(cfg.get('complaint_rate_window_days'), 30)) + order_days = max(fine_days, complaint_days, _int(cfg.get('order_window_days'), 30)) + now = timezone.now() + since_order = now - timedelta(days=order_days) + since_fine = now - timedelta(days=fine_days) + since_complaint = now - timedelta(days=complaint_days) + + completed = 0 + fine_count = 0 + complaint_count = 0 + valid_invites = 0 + + try: + from orders.models import Order, Penalty + + if role == 'dashou': + completed = Order.query.filter( + ClubID=club_id, + PlayerID=user_id, + Status=3, + CreateTime__gte=since_order, + ).count() + fine_count = Penalty.query.filter( + PenalizedUserID=user_id, + Identity=1, + Status__in=[1, 2, 3, 5], + CreateTime__gte=since_fine, + ).count() + # 投诉:窗口内该打手完成单关联的工单数 + try: + from gongdan.models import Gongdan + order_ids = list( + Order.query.filter( + ClubID=club_id, + PlayerID=user_id, + Status=3, + CreateTime__gte=since_complaint, + ).values_list('OrderID', flat=True)[:800] + ) + if order_ids: + complaint_count = Gongdan.query.filter( + order_id__in=order_ids, + ).count() + except Exception as e: + logger.warning('complaint metrics skip: %s', e) + valid_invites = 0 + + elif role == 'guanshi': + from users.models import User, UserGuanshi, UserDashou + u = User.query.filter(UserUID=user_id).first() + gs = UserGuanshi.query.filter(user=u).first() if u else None + if gs is not None: + valid_invites = int(getattr(gs, 'yaogingshuliang', 0) or 0) + # 管事「完成单」近似:窗口内其邀请打手的成交单量 + dashou_uids = list( + UserDashou.query.filter(yaoqingren=user_id).values_list( + 'user__UserUID', flat=True + )[:500] + ) + if dashou_uids: + completed = Order.query.filter( + ClubID=club_id, + PlayerID__in=dashou_uids, + Status=3, + CreateTime__gte=since_order, + ).count() + fine_count = Penalty.query.filter( + PenalizedUserID=user_id, + Identity=2, + Status__in=[1, 2, 3, 5], + CreateTime__gte=since_fine, + ).count() + try: + from gongdan.models import Gongdan + complaint_count = Gongdan.query.filter( + guanshi_id=user_id, + CreateTime__gte=since_complaint, + ).count() + except Exception: + pass + + elif role == 'zuzhang': + from users.models import User, UserGuanshi, UserZuzhang + u = User.query.filter(UserUID=user_id).first() + zz = UserZuzhang.query.filter(user=u).first() if u else None + if zz is not None: + valid_invites = UserGuanshi.query.filter(yaoqingren=user_id).count() + fine_count = Penalty.query.filter( + PenalizedUserID=user_id, + Identity=4, + Status__in=[1, 2, 3, 5], + CreateTime__gte=since_fine, + ).count() + completed = valid_invites # 组长无直接结单,用邀请管事数作「完成」近似时可被 min_orders 覆盖 + + else: # shenheguan + fine_count = Penalty.query.filter( + PenalizedUserID=user_id, + Status__in=[1, 2, 3, 5], + CreateTime__gte=since_fine, + ).count() + except Exception as e: + logger.exception('compute_risk_metrics failed user=%s role=%s: %s', user_id, role, e) + + denom = max(completed, 1) + fine_rate = _dec(fine_count) / _dec(denom) + complaint_rate = _dec(complaint_count) / _dec(denom) + + return { + 'completed_orders': completed, + 'fine_count': fine_count, + 'complaint_count': complaint_count, + 'fine_rate': str(fine_rate), + 'complaint_rate': str(complaint_rate), + 'valid_invites': valid_invites, + 'order_window_days': order_days, + 'fine_rate_window_days': fine_days, + 'complaint_rate_window_days': complaint_days, + } + + +def write_risk_snapshot(profile, metrics: dict) -> None: + if profile is None: + return + try: + profile.__class__.query.filter(pk=profile.pk).update( + risk_fine_rate=_dec(metrics.get('fine_rate')), + risk_complaint_rate=_dec(metrics.get('complaint_rate')), + risk_completed_orders=_int(metrics.get('completed_orders')), + risk_valid_invites=_int(metrics.get('valid_invites')), + risk_stat_at=timezone.now(), + ) + except Exception as e: + logger.warning('write_risk_snapshot failed: %s', e) + + +def evaluate_unfreeze_conditions( + club_id: str, + user_id: str, + role: str, + condition_json: Optional[dict], + profile=None, +) -> Tuple[bool, Dict[str, Any]]: + """ + 返回 (是否达标, 指标快照)。 + 未勾选的条件不参与;全部未勾选视为「无条件限制」→ 达标。 + """ + cfg = condition_json or {} + metrics = compute_risk_metrics(club_id, user_id, role, cfg) + write_risk_snapshot(profile, metrics) + + checks = [] + reasons = [] + + # 最低完成单量 + min_orders = _int(cfg.get('min_completed_orders'), 0) + if _flag(cfg, 'enable_min_completed_orders', min_orders > 0): + ok = metrics['completed_orders'] >= min_orders + checks.append(ok) + if not ok: + reasons.append( + f"完成单量 {metrics['completed_orders']}/{min_orders}" + ) + + # 最高罚款率 + max_fine = _dec(cfg.get('max_fine_rate'), '1') + if _flag(cfg, 'enable_max_fine_rate', 0 <= max_fine < 1): + ok = _dec(metrics['fine_rate']) <= max_fine + checks.append(ok) + if not ok: + reasons.append( + f"罚款率 {metrics['fine_rate']} > {max_fine}" + ) + + # 最高投诉率 + max_comp = _dec(cfg.get('max_complaint_rate'), '1') + if _flag(cfg, 'enable_max_complaint_rate', 0 <= max_comp < 1): + ok = _dec(metrics['complaint_rate']) <= max_comp + checks.append(ok) + if not ok: + reasons.append( + f"投诉率 {metrics['complaint_rate']} > {max_comp}" + ) + + # 最低有效邀请 + min_inv = _int(cfg.get('min_valid_invites'), 0) + if _flag(cfg, 'enable_min_valid_invites', min_inv > 0): + ok = metrics['valid_invites'] >= min_inv + checks.append(ok) + if not ok: + reasons.append( + f"有效邀请 {metrics['valid_invites']}/{min_inv}" + ) + + if not checks: + passed = True + else: + passed = all(checks) + + snap = { + **metrics, + 'passed': passed, + 'fail_reasons': reasons, + } + return passed, snap diff --git a/users/views/fund_freeze_mp.py b/users/views/fund_freeze_mp.py index c2545c1..42c2436 100644 --- a/users/views/fund_freeze_mp.py +++ b/users/views/fund_freeze_mp.py @@ -13,7 +13,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from jituan.models import FundFreezeLedger, FundUnfreezeEvent +from jituan.models import ClubFundFreezeConfig, FundFreezeLedger, FundUnfreezeEvent from jituan.services.club_user import get_user_club_id from jituan.services.fund_freeze import ( ROLE_DASHOU, @@ -109,6 +109,19 @@ class FundFreezeMineView(APIView): ] total_dongjie = sum((Decimal(r['dongjie_yue']) for r in roles), Decimal('0.00')) total_withdrawable = sum((Decimal(r['withdrawable']) for r in roles), Decimal('0.00')) + + # 用户可见说明:优先打手规则,否则取任意已配置角色 + display_reason = '' + if club_id: + cfgs = list(ClubFundFreezeConfig.query.filter(club_id=club_id, enabled=True)) + prefer = next((c for c in cfgs if c.role == ROLE_DASHOU), None) or (cfgs[0] if cfgs else None) + if prefer and prefer.remark: + display_reason = prefer.remark + if not display_reason: + any_cfg = ClubFundFreezeConfig.query.filter(club_id=club_id).exclude(remark='').first() + if any_cfg: + display_reason = any_cfg.remark or '' + return Response({ 'code': 200, 'msg': '获取成功', @@ -119,6 +132,10 @@ class FundFreezeMineView(APIView): 'total_dongjie': _money(total_dongjie), 'total_withdrawable': _money(total_withdrawable), 'has_freeze': total_dongjie > 0, + 'display_reason': display_reason or ( + '部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;' + '按俱乐部规则到期或达标后自动转入可提现余额,冻结中金额不可提现。' + ), }, }) @@ -173,7 +190,11 @@ class FundFreezeMyLedgerView(APIView): 'source_type': r.source_type, 'source_label': SOURCE_LABEL.get(r.source_type, r.source_type or '入账'), 'order_id': r.order_id or '', - 'freeze_reason': r.freeze_reason or '', + 'freeze_reason': r.freeze_reason or ( + (r.config_snapshot or {}).get('display_reason') + or (r.config_snapshot or {}).get('remark') + or '' + ), 'current_step': r.current_step, }) @@ -246,7 +267,11 @@ class FundFreezeMyLedgerDetailView(APIView): 'source_type': ledger.source_type, 'source_label': SOURCE_LABEL.get(ledger.source_type, ledger.source_type or '入账'), 'order_id': ledger.order_id or '', - 'freeze_reason': ledger.freeze_reason or '', + 'freeze_reason': ledger.freeze_reason or ( + (ledger.config_snapshot or {}).get('display_reason') + or (ledger.config_snapshot or {}).get('remark') + or '' + ), 'current_step': ledger.current_step, 'events': event_list, }, diff --git a/users/views/tixian.py b/users/views/tixian.py index 13ec472..5bd3f3e 100644 --- a/users/views/tixian.py +++ b/users/views/tixian.py @@ -1112,10 +1112,30 @@ class TixianAssetView(APIView): from jituan.services.club_user import get_user_club_id from jituan.services.withdraw_config import build_tixian_asset_rates + from jituan.models import ClubFundFreezeConfig club_id = get_user_club_id(user) data.update(build_tixian_asset_rates(club_id)) + display_reason = '' + try: + if club_id: + prefer = ClubFundFreezeConfig.query.filter( + club_id=club_id, role='dashou', enabled=True, + ).first() + if not prefer: + prefer = ClubFundFreezeConfig.query.filter( + club_id=club_id, enabled=True, + ).exclude(remark='').first() + if prefer: + display_reason = prefer.remark or '' + except Exception: + pass + data['freeze_display_reason'] = display_reason or ( + '部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;' + '按规则解冻后自动转入可提现,冻结中不可提现。' + ) + return Response({ 'code': 200, 'msg': '获取成功',