Files
Django/jituan/services/fund_freeze_admin.py

385 lines
14 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': '',
'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 '',
'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' and not (installment_json.get('steps') or []):
installment_json = DEFAULT_INSTALLMENT
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, '最短冻结天数不能为负'
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 = (data.get('remark') or '')[:255]
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,
}