385 lines
14 KiB
Python
385 lines
14 KiB
Python
"""客服后台:资金冻结配置 / 明细 / 风险指标。"""
|
||
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,
|
||
}
|