fix: 冻结条件可评估+分期规范化,定时扫描更及时

后台条件真正按罚款率/单量等评估;选项式配置所需字段规范化;Celery 改为每5分钟;小程序说明文案接口补齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-26 19:36:10 +08:00
parent a505a1ab2d
commit 6c96eb3bcb
6 changed files with 485 additions and 41 deletions

View File

@@ -89,7 +89,7 @@ app.conf.beat_schedule = {
# 5. 资金冻结到期解冻(默认无冻结单时几乎空跑) # 5. 资金冻结到期解冻(默认无冻结单时几乎空跑)
'scan_fund_freeze_due': { 'scan_fund_freeze_due': {
'task': 'jituan.tasks.scan_fund_freeze_due', 'task': 'jituan.tasks.scan_fund_freeze_due',
'schedule': crontab(minute='*/15'), 'schedule': crontab(minute='*/5'),
'options': { 'options': {
'queue': 'periodic_tasks', 'queue': 'periodic_tasks',
'priority': 4, 'priority': 4,

View File

@@ -118,6 +118,8 @@ def _config_snapshot(cfg: ClubFundFreezeConfig) -> Dict[str, Any]:
'installment_json': cfg.installment_json, 'installment_json': cfg.installment_json,
'condition_json': cfg.condition_json, 'condition_json': cfg.condition_json,
'freeze_min_days': cfg.freeze_min_days, '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], order_id=(order_id or '')[:64],
czjilu_id=(czjilu_id or '')[:64], czjilu_id=(czjilu_id or '')[:64],
biz_id=biz_id[: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), config_snapshot=_config_snapshot(cfg),
current_step=1, current_step=1,
next_eligible_at=next_at, next_eligible_at=next_at,
@@ -381,12 +387,14 @@ def apply_unfreeze_step(
force: bool = False, force: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
执行一笔冻结单的当前分期步(骨架:先支持到期/手动;条件解冻 P4 再接指标) 执行一笔冻结单的当前分期步。
force=True 时忽略 next_eligible_at后台手动 force=True:后台手动,跳过时间/条件门槛
条件未达标时不推进步数、不改 next_eligible_at供定时任务下次重试。
""" """
from django.db import transaction from django.db import transaction
from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan
from jituan.models import FundUnfreezeEvent from jituan.models import FundUnfreezeEvent
from jituan.services.fund_freeze_conditions import evaluate_unfreeze_conditions
with transaction.atomic(): with transaction.atomic():
ledger = ( ledger = (
@@ -400,29 +408,37 @@ def apply_unfreeze_step(
return {'ok': False, 'msg': '已完结或已作废'} return {'ok': False, 'msg': '已完结或已作废'}
now = timezone.now() 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 {} snap = ledger.config_snapshot or {}
trigger_mode = (snap.get('unfreeze_trigger') or 'time_only') 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: if trigger_mode == 'manual_only' and not force:
return {'ok': False, 'msg': '仅允许手动解冻'} return {'ok': False, 'msg': '仅允许手动解冻'}
# condition_*自动任务暂不评估指标P4手动 force 可解
if trigger_mode == 'condition_only' and not force:
return {'ok': False, 'msg': '条件解冻尚未启用自动评估'}
steps = _plan_steps(snap) steps = _plan_steps(snap)
step_idx = max(0, int(ledger.current_step or 1) - 1) step_idx = max(0, int(ledger.current_step or 1) - 1)
if step_idx >= len(steps): 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: else:
step = steps[step_idx] step = steps[step_idx]
need_step_cond = bool(step.get('need_condition'))
amount = _step_unfreeze_amount(ledger, step) cond_ok = True
if amount <= 0: cond_snap: Dict[str, Any] = {}
ledger.status = FundFreezeLedger.STATUS_DONE need_eval = (
ledger.save(update_fields=['status', 'UpdateTime']) not force
return {'ok': True, 'amount': Decimal('0.00'), 'done': True} and (
need_step_cond
or trigger_mode in (
'condition_only', 'time_and_condition', 'time_or_condition',
)
)
)
profile_map = { profile_map = {
ROLE_DASHOU: UserDashou, ROLE_DASHOU: UserDashou,
@@ -442,6 +458,63 @@ def apply_unfreeze_step(
if not profile: if not profile:
return {'ok': False, 'msg': '角色扩展表不存在'} 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) cur_frozen = _money(getattr(profile, 'dongjie_yue', 0) or 0)
if cur_frozen < amount: if cur_frozen < amount:
@@ -472,19 +545,22 @@ def apply_unfreeze_step(
else: else:
ledger.status = FundFreezeLedger.STATUS_PARTIAL ledger.status = FundFreezeLedger.STATUS_PARTIAL
ledger.current_step = next_step ledger.current_step = next_step
# 下一步到期时间
if next_step - 1 < len(steps): if next_step - 1 < len(steps):
nxt = steps[next_step - 1] nxt = steps[next_step - 1]
days = int(nxt.get('after_days') or 0) days = int(nxt.get('after_days') or 0)
# after_days 相对冻结日 ledger.next_eligible_at = frozen_at + timedelta(days=days)
ledger.next_eligible_at = ledger.frozen_at + timedelta(days=days)
else: else:
# 无更多配置步:剩余立即进入可扫状态,下一步按 remain 吃完
ledger.next_eligible_at = now ledger.next_eligible_at = now
ledger.save(update_fields=[ ledger.save(update_fields=[
'amount_unfrozen', 'status', 'current_step', 'next_eligible_at', 'UpdateTime', '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( FundUnfreezeEvent.query.create(
ledger_id=ledger.id, ledger_id=ledger.id,
club_id=ledger.club_id, club_id=ledger.club_id,
@@ -492,10 +568,10 @@ def apply_unfreeze_step(
role=ledger.role, role=ledger.role,
step_seq=int(step.get('seq') or ledger.current_step), step_seq=int(step.get('seq') or ledger.current_step),
amount=amount, amount=amount,
trigger=trigger, trigger=event_trigger,
reason=(reason or f'{trigger}解冻')[:255], reason=(reason or f'{event_trigger}解冻')[:255],
operator_id=(operator_id or '')[:32], operator_id=(operator_id or '')[:32],
condition_snapshot={}, condition_snapshot=cond_snap or {},
) )
return { return {
@@ -504,29 +580,65 @@ def apply_unfreeze_step(
'remain': max(remain, Decimal('0.00')), 'remain': max(remain, Decimal('0.00')),
'done': remain <= 0, 'done': remain <= 0,
'ledger_id': ledger.id, 'ledger_id': ledger.id,
'condition': cond_snap,
} }
def scan_due_freeze_ledgers(limit: int = 200) -> Dict[str, Any]: 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() now = timezone.now()
qs = ( base = FundFreezeLedger.query.filter(
FundFreezeLedger.query.filter( status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL],
status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL],
next_eligible_at__lte=now,
)
.order_by('next_eligible_at', 'id')[:limit]
) )
ok_n = fail_n = 0 due_ids = list(
for row in qs: 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: 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'): if r.get('ok'):
ok_n += 1 ok_n += 1
else: else:
fail_n += 1 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: except Exception as e:
fail_n += 1 fail_n += 1
logger.exception('unfreeze error id=%s: %s', row.id, e) logger.exception('unfreeze error id=%s: %s', lid, e)
return {'ok': ok_n, 'fail': fail_n, 'scanned': ok_n + fail_n} return {
'ok': ok_n,
'fail': fail_n,
'retry': retry_n,
'scanned': len(ids),
'due': len(due_ids),
'cond_scan': len(cond_ids),
}

View File

@@ -72,6 +72,7 @@ def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) -
}, },
'freeze_min_days': 0, 'freeze_min_days': 0,
'remark': '', 'remark': '',
'display_reason': '',
'exists': False, 'exists': False,
} }
return { return {
@@ -90,6 +91,7 @@ def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) -
'condition_json': row.condition_json or {}, 'condition_json': row.condition_json or {},
'freeze_min_days': int(row.freeze_min_days or 0), 'freeze_min_days': int(row.freeze_min_days or 0),
'remark': row.remark or '', 'remark': row.remark or '',
'display_reason': row.remark or '',
'updated_by': row.updated_by or '', 'updated_by': row.updated_by or '',
'exists': True, 'exists': True,
} }
@@ -158,8 +160,40 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict]
installment_json = json.loads(installment_json) installment_json = json.loads(installment_json)
except Exception: except Exception:
return None, 'installment_json 不是合法 JSON' return None, 'installment_json 不是合法 JSON'
if schedule_mode == 'installment' and not (installment_json.get('steps') or []): if schedule_mode == 'installment':
installment_json = DEFAULT_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 {} condition_json = data.get('condition_json') or {}
if isinstance(condition_json, str): 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: if freeze_min_days < 0:
return None, '最短冻结天数不能为负' 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() row = ClubFundFreezeConfig.query.filter(club_id=club_id, role=role).first()
if not row: if not row:
row = ClubFundFreezeConfig( row = ClubFundFreezeConfig(
@@ -190,7 +227,7 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict]
row.installment_json = installment_json row.installment_json = installment_json
row.condition_json = condition_json row.condition_json = condition_json
row.freeze_min_days = freeze_min_days 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.updated_by = (operator or '')[:32]
row.save() row.save()
invalidate_freeze_config_cache(club_id, role) invalidate_freeze_config_cache(club_id, role)

View File

@@ -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

View File

@@ -13,7 +13,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView 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.club_user import get_user_club_id
from jituan.services.fund_freeze import ( from jituan.services.fund_freeze import (
ROLE_DASHOU, ROLE_DASHOU,
@@ -109,6 +109,19 @@ class FundFreezeMineView(APIView):
] ]
total_dongjie = sum((Decimal(r['dongjie_yue']) for r in roles), Decimal('0.00')) 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')) 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({ return Response({
'code': 200, 'code': 200,
'msg': '获取成功', 'msg': '获取成功',
@@ -119,6 +132,10 @@ class FundFreezeMineView(APIView):
'total_dongjie': _money(total_dongjie), 'total_dongjie': _money(total_dongjie),
'total_withdrawable': _money(total_withdrawable), 'total_withdrawable': _money(total_withdrawable),
'has_freeze': total_dongjie > 0, 'has_freeze': total_dongjie > 0,
'display_reason': display_reason or (
'部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;'
'按俱乐部规则到期或达标后自动转入可提现余额,冻结中金额不可提现。'
),
}, },
}) })
@@ -173,7 +190,11 @@ class FundFreezeMyLedgerView(APIView):
'source_type': r.source_type, 'source_type': r.source_type,
'source_label': SOURCE_LABEL.get(r.source_type, r.source_type or '入账'), 'source_label': SOURCE_LABEL.get(r.source_type, r.source_type or '入账'),
'order_id': r.order_id 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, 'current_step': r.current_step,
}) })
@@ -246,7 +267,11 @@ class FundFreezeMyLedgerDetailView(APIView):
'source_type': ledger.source_type, 'source_type': ledger.source_type,
'source_label': SOURCE_LABEL.get(ledger.source_type, ledger.source_type or '入账'), 'source_label': SOURCE_LABEL.get(ledger.source_type, ledger.source_type or '入账'),
'order_id': ledger.order_id 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, 'current_step': ledger.current_step,
'events': event_list, 'events': event_list,
}, },

View File

@@ -1112,10 +1112,30 @@ class TixianAssetView(APIView):
from jituan.services.club_user import get_user_club_id from jituan.services.club_user import get_user_club_id
from jituan.services.withdraw_config import build_tixian_asset_rates from jituan.services.withdraw_config import build_tixian_asset_rates
from jituan.models import ClubFundFreezeConfig
club_id = get_user_club_id(user) club_id = get_user_club_id(user)
data.update(build_tixian_asset_rates(club_id)) 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({ return Response({
'code': 200, 'code': 200,
'msg': '获取成功', 'msg': '获取成功',