Files
Django/jituan/services/fund_freeze_admin.py
XingQue 6c96eb3bcb fix: 冻结条件可评估+分期规范化,定时扫描更及时
后台条件真正按罚款率/单量等评估;选项式配置所需字段规范化;Celery 改为每5分钟;小程序说明文案接口补齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 19:36:10 +08:00

422 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""客服后台:资金冻结配置 / 明细 / 风险指标。"""
from __future__ import annotations
import json
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from django.db.models import Q
from jituan.constants import CLUB_ID_DEFAULT
from jituan.models import ClubFundFreezeConfig, FundFreezeLedger, FundUnfreezeEvent
from jituan.services.club_context import filter_user_related_by_club, resolve_club_id_from_request
from jituan.services.fund_freeze import (
ROLE_DASHOU,
ROLE_GUANSHI,
ROLE_SHENHEGUAN,
ROLE_ZUZHANG,
apply_unfreeze_step,
invalidate_freeze_config_cache,
split_preview,
)
ROLES = (
(ROLE_DASHOU, '打手'),
(ROLE_GUANSHI, '管事'),
(ROLE_ZUZHANG, '组长'),
(ROLE_SHENHEGUAN, '考核官'),
)
DEFAULT_INSTALLMENT = {
'steps': [
{'seq': 1, 'after_days': 7, 'type': 'ratio', 'value': 0.3, 'need_condition': False},
{'seq': 2, 'after_days': 14, 'type': 'ratio', 'value': 0.3, 'need_condition': True},
{'seq': 3, 'after_days': 30, 'type': 'remain', 'value': 1, 'need_condition': True},
]
}
def _club(request) -> str:
return (resolve_club_id_from_request(request) or CLUB_ID_DEFAULT).strip() or CLUB_ID_DEFAULT
def _dec(v, default='0') -> str:
try:
return str(Decimal(str(v if v is not None else default)))
except Exception:
return str(default)
def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) -> Dict[str, Any]:
if not row:
return {
'club_id': club_id,
'role': role,
'enabled': False,
'freeze_mode': 1,
'freeze_rate': '0',
'freeze_fixed': '0',
'freeze_cap': '0',
'min_credit_to_freeze': '0',
'source_scope': ['order_settle'],
'unfreeze_trigger': 'time_only',
'schedule_mode': 'all_at_once',
'installment_json': {'steps': [{'seq': 1, 'after_days': 15, 'type': 'remain', 'value': 1, 'need_condition': False}]},
'condition_json': {
'min_completed_orders': 0,
'max_fine_rate': 1,
'fine_rate_window_days': 30,
'max_complaint_rate': 1,
'complaint_rate_window_days': 30,
'min_valid_invites': 0,
},
'freeze_min_days': 0,
'remark': '',
'display_reason': '',
'exists': False,
}
return {
'club_id': row.club_id,
'role': row.role,
'enabled': bool(row.enabled),
'freeze_mode': int(row.freeze_mode or 1),
'freeze_rate': _dec(row.freeze_rate),
'freeze_fixed': _dec(row.freeze_fixed),
'freeze_cap': _dec(row.freeze_cap),
'min_credit_to_freeze': _dec(row.min_credit_to_freeze),
'source_scope': row.source_scope or [],
'unfreeze_trigger': row.unfreeze_trigger or 'time_only',
'schedule_mode': row.schedule_mode or 'all_at_once',
'installment_json': row.installment_json or {},
'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,
}
def build_config_payload(request) -> Dict[str, Any]:
club_id = _club(request)
rows = {
r.role: r
for r in ClubFundFreezeConfig.query.filter(club_id=club_id)
}
items = []
for role, name in ROLES:
d = _cfg_to_dict(rows.get(role), club_id, role)
d['role_name'] = name
avail, frozen = split_preview(club_id, role, Decimal('100'), 'order_settle')
d['preview_100'] = {'available': str(avail), 'frozen': str(frozen)}
items.append(d)
return {'club_id': club_id, 'roles': items}
def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
club_id = _club(request)
role = (data.get('role') or '').strip().lower()
if role not in {r[0] for r in ROLES}:
return None, '无效角色'
enabled = bool(data.get('enabled'))
freeze_mode = int(data.get('freeze_mode') or 1)
if freeze_mode not in (1, 2, 3):
return None, 'freeze_mode 无效'
try:
freeze_rate = Decimal(str(data.get('freeze_rate') or 0))
freeze_fixed = Decimal(str(data.get('freeze_fixed') or 0))
freeze_cap = Decimal(str(data.get('freeze_cap') or 0))
min_credit = Decimal(str(data.get('min_credit_to_freeze') or 0))
except Exception:
return None, '金额/比例格式错误'
if freeze_rate < 0 or freeze_rate > 1:
return None, '冻结比例须在 0~1 之间(如 0.2=20%'
source_scope = data.get('source_scope')
if source_scope is None:
source_scope = ['order_settle']
if isinstance(source_scope, str):
try:
source_scope = json.loads(source_scope)
except Exception:
source_scope = [source_scope]
unfreeze_trigger = (data.get('unfreeze_trigger') or 'time_only').strip()
if unfreeze_trigger not in (
'time_only', 'condition_only', 'time_and_condition', 'time_or_condition', 'manual_only',
):
return None, '解冻触发模式无效'
schedule_mode = (data.get('schedule_mode') or 'all_at_once').strip()
if schedule_mode not in ('all_at_once', 'installment'):
return None, '分期模式无效'
installment_json = data.get('installment_json') or {}
if isinstance(installment_json, str):
try:
installment_json = json.loads(installment_json)
except Exception:
return None, 'installment_json 不是合法 JSON'
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):
try:
condition_json = json.loads(condition_json)
except Exception:
return None, 'condition_json 不是合法 JSON'
freeze_min_days = int(data.get('freeze_min_days') or 0)
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(
club_id=club_id,
role=role,
)
row.enabled = enabled
row.freeze_mode = freeze_mode
row.freeze_rate = freeze_rate
row.freeze_fixed = freeze_fixed
row.freeze_cap = freeze_cap
row.min_credit_to_freeze = min_credit
row.source_scope = source_scope
row.unfreeze_trigger = unfreeze_trigger
row.schedule_mode = schedule_mode
row.installment_json = installment_json
row.condition_json = condition_json
row.freeze_min_days = freeze_min_days
row.remark = display_reason
row.updated_by = (operator or '')[:32]
row.save()
invalidate_freeze_config_cache(club_id, role)
out = _cfg_to_dict(row, club_id, role)
out['role_name'] = dict(ROLES).get(role, role)
return out, None
STATUS_LABEL = {
FundFreezeLedger.STATUS_FROZEN: '冻结中',
FundFreezeLedger.STATUS_PARTIAL: '部分解冻',
FundFreezeLedger.STATUS_DONE: '已解冻',
FundFreezeLedger.STATUS_VOID: '已作废',
}
def list_ledgers(request, data: dict) -> Dict[str, Any]:
club_id = _club(request)
page = max(1, int(data.get('page') or 1))
page_size = min(100, max(1, int(data.get('page_size') or 20)))
role = (data.get('role') or '').strip().lower()
status = data.get('status')
user_id = (data.get('user_id') or data.get('yonghuid') or '').strip()
order_id = (data.get('order_id') or '').strip()
kw = (data.get('keyword') or '').strip()
qs = FundFreezeLedger.query.filter(club_id=club_id)
if role:
qs = qs.filter(role=role)
if status not in (None, ''):
try:
qs = qs.filter(status=int(status))
except (TypeError, ValueError):
pass
if user_id:
qs = qs.filter(user_id=user_id)
if order_id:
qs = qs.filter(order_id=order_id)
if kw:
qs = qs.filter(Q(user_id__icontains=kw) | Q(order_id__icontains=kw) | Q(biz_id__icontains=kw))
total = qs.count()
rows = qs.order_by('-id')[(page - 1) * page_size: page * page_size]
items = []
for r in rows:
remain = Decimal(str(r.amount_total or 0)) - Decimal(str(r.amount_unfrozen or 0))
items.append({
'id': r.id,
'club_id': r.club_id,
'user_id': r.user_id,
'role': r.role,
'role_name': dict(ROLES).get(r.role, r.role),
'amount_total': _dec(r.amount_total),
'amount_unfrozen': _dec(r.amount_unfrozen),
'amount_remain': _dec(remain),
'status': r.status,
'status_label': STATUS_LABEL.get(r.status, str(r.status)),
'frozen_at': r.frozen_at.isoformat() if r.frozen_at else '',
'next_eligible_at': r.next_eligible_at.isoformat() if r.next_eligible_at else '',
'source_type': r.source_type,
'order_id': r.order_id,
'czjilu_id': r.czjilu_id,
'biz_id': r.biz_id,
'freeze_reason': r.freeze_reason,
'current_step': r.current_step,
})
return {
'club_id': club_id,
'list': items,
'total': total,
'page': page,
'page_size': page_size,
}
def list_unfreeze_events(ledger_id: int, club_id: str) -> List[dict]:
qs = FundUnfreezeEvent.query.filter(ledger_id=ledger_id, club_id=club_id).order_by('-id')[:50]
return [{
'id': e.id,
'step_seq': e.step_seq,
'amount': _dec(e.amount),
'unfrozen_at': e.unfrozen_at.isoformat() if e.unfrozen_at else '',
'trigger': e.trigger,
'reason': e.reason,
'operator_id': e.operator_id,
} for e in qs]
def manual_unfreeze(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
club_id = _club(request)
try:
ledger_id = int(data.get('ledger_id') or data.get('id') or 0)
except (TypeError, ValueError):
return None, 'ledger_id 无效'
if ledger_id <= 0:
return None, '缺少 ledger_id'
ledger = FundFreezeLedger.query.filter(id=ledger_id, club_id=club_id).first()
if not ledger:
return None, '冻结单不存在或不属于当前俱乐部'
mode = (data.get('mode') or 'step').strip() # step | all
if mode == 'all':
# 循环解完(最多 20 步防死循环)
last = None
for _ in range(20):
last = apply_unfreeze_step(
ledger_id,
trigger='manual',
operator_id=operator,
reason=(data.get('reason') or '后台手动全部解冻')[:255],
force=True,
)
if not last.get('ok'):
return None, last.get('msg') or '解冻失败'
if last.get('done'):
break
return last, None
result = apply_unfreeze_step(
ledger_id,
trigger='manual',
operator_id=operator,
reason=(data.get('reason') or '后台手动解冻一步')[:255],
force=True,
)
if not result.get('ok'):
return None, result.get('msg') or '解冻失败'
return result, None
def list_risk_users(request, data: dict) -> Dict[str, Any]:
"""从角色扩展表读风险快照 + 冻结池(按俱乐部用户过滤)。"""
from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan
from jituan.services.club_context import filter_user_related_by_club
club_id = _club(request)
role = (data.get('role') or ROLE_DASHOU).strip().lower()
page = max(1, int(data.get('page') or 1))
page_size = min(100, max(1, int(data.get('page_size') or 20)))
keyword = (data.get('keyword') or '').strip()
only_frozen = bool(data.get('only_frozen'))
mapping = {
ROLE_DASHOU: UserDashou,
ROLE_GUANSHI: UserGuanshi,
ROLE_ZUZHANG: UserZuzhang,
ROLE_SHENHEGUAN: UserShenheguan,
}
model = mapping.get(role)
if not model:
return {'club_id': club_id, 'list': [], 'total': 0, 'page': page, 'page_size': page_size}
qs = model.query.select_related('user').all()
qs = filter_user_related_by_club(qs, request, user_club_path='user__ClubID')
if keyword:
qs = qs.filter(user__UserUID__icontains=keyword)
if only_frozen:
qs = qs.filter(dongjie_yue__gt=0)
total = qs.count()
rows = qs.order_by('-dongjie_yue', '-id')[(page - 1) * page_size: page * page_size]
items = []
for p in rows:
uid = getattr(p.user, 'UserUID', '') if p.user_id else ''
if role == ROLE_ZUZHANG:
available = _dec(getattr(p, 'ketixian_jine', 0))
else:
available = _dec(getattr(p, 'yue', 0))
items.append({
'user_id': uid,
'role': role,
'role_name': dict(ROLES).get(role, role),
'available': available,
'dongjie_yue': _dec(getattr(p, 'dongjie_yue', 0)),
'risk_fine_rate': _dec(getattr(p, 'risk_fine_rate', 0)),
'risk_complaint_rate': _dec(getattr(p, 'risk_complaint_rate', 0)),
'risk_completed_orders': int(getattr(p, 'risk_completed_orders', 0) or 0),
'risk_valid_invites': int(getattr(p, 'risk_valid_invites', 0) or 0),
'risk_stat_at': p.risk_stat_at.isoformat() if getattr(p, 'risk_stat_at', None) else '',
'nicheng': getattr(p, 'nicheng', '') or '',
})
return {
'club_id': club_id,
'role': role,
'list': items,
'total': total,
'page': page,
'page_size': page_size,
}