Files
Django/jituan/services/fund_freeze_admin.py

517 lines
19 KiB
Python
Raw Permalink 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_DASHOU_YAJIN,
ROLE_GUANSHI,
ROLE_SHENHEGUAN,
ROLE_ZUZHANG,
SOURCE_YAJIN_EXCHANGE,
SOURCE_YAJIN_RECHARGE,
YAJIN_SOURCE_TYPES,
apply_unfreeze_step,
invalidate_freeze_config_cache,
split_preview,
)
ROLES = (
(ROLE_DASHOU, '打手佣金'),
(ROLE_DASHOU_YAJIN, '打手押金'),
(ROLE_GUANSHI, '管事'),
(ROLE_ZUZHANG, '组长'),
(ROLE_SHENHEGUAN, '考核官'),
)
YAJIN_DEFAULT_SCOPE = [SOURCE_YAJIN_RECHARGE, SOURCE_YAJIN_EXCHANGE]
COMMISSION_DEFAULT_SCOPE = ['order_settle', 'fenhong', 'withdraw_reject', 'club_migrate']
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]:
default_scope = (
list(YAJIN_DEFAULT_SCOPE) if role == ROLE_DASHOU_YAJIN else ['order_settle']
)
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': default_scope,
'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,
'is_yajin_config': role == ROLE_DASHOU_YAJIN,
}
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,
'is_yajin_config': role == ROLE_DASHOU_YAJIN,
}
def _migrate_legacy_yajin_scope(club_id: str, rows: Dict[str, ClubFundFreezeConfig]) -> None:
"""
一次性兼容:旧「打手」配置里勾了押金来源时,拆到独立 dashou_yajin 行,
并从打手佣金 scope 去掉押金项,避免双轨。
"""
dashou = rows.get(ROLE_DASHOU)
if not dashou:
return
if rows.get(ROLE_DASHOU_YAJIN):
return
scope = list(dashou.source_scope or [])
yajin_items = [s for s in scope if s in YAJIN_SOURCE_TYPES]
if not yajin_items:
return
new_row = ClubFundFreezeConfig(
club_id=club_id,
role=ROLE_DASHOU_YAJIN,
enabled=bool(dashou.enabled),
freeze_mode=dashou.freeze_mode,
freeze_rate=dashou.freeze_rate,
freeze_fixed=dashou.freeze_fixed,
freeze_cap=dashou.freeze_cap,
min_credit_to_freeze=dashou.min_credit_to_freeze,
source_scope=yajin_items,
unfreeze_trigger=dashou.unfreeze_trigger,
schedule_mode=dashou.schedule_mode,
installment_json=dashou.installment_json or {},
condition_json=dashou.condition_json or {},
freeze_min_days=dashou.freeze_min_days or 0,
remark=(dashou.remark or '')[:255],
updated_by='migrate_yajin_split',
)
new_row.save()
dashou.source_scope = [s for s in scope if s not in YAJIN_SOURCE_TYPES]
dashou.save(update_fields=['source_scope', 'UpdateTime'])
invalidate_freeze_config_cache(club_id, ROLE_DASHOU)
invalidate_freeze_config_cache(club_id, ROLE_DASHOU_YAJIN)
rows[ROLE_DASHOU_YAJIN] = new_row
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)
}
try:
_migrate_legacy_yajin_scope(club_id, rows)
except Exception:
# 不阻断后台读取
pass
items = []
for role, name in ROLES:
d = _cfg_to_dict(rows.get(role), club_id, role)
d['role_name'] = name
preview_src = (
SOURCE_YAJIN_RECHARGE if role == ROLE_DASHOU_YAJIN else 'order_settle'
)
avail, frozen = split_preview(club_id, role, Decimal('100'), preview_src)
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:
if role == ROLE_DASHOU_YAJIN:
source_scope = list(YAJIN_DEFAULT_SCOPE)
else:
# 默认同时覆盖订单结算与分红(管事/组长主要靠分红入账)
source_scope = list(COMMISSION_DEFAULT_SCOPE)
if isinstance(source_scope, str):
try:
source_scope = json.loads(source_scope)
except Exception:
source_scope = [source_scope]
if not isinstance(source_scope, list):
return None, 'source_scope 格式错误'
source_scope = [str(s).strip() for s in source_scope if str(s).strip()]
# 押金配置与佣金配置严格分离:互不污染 source_scope
if role == ROLE_DASHOU_YAJIN:
source_scope = [s for s in source_scope if s in YAJIN_SOURCE_TYPES]
if not source_scope:
source_scope = list(YAJIN_DEFAULT_SCOPE)
elif role == ROLE_DASHOU:
source_scope = [s for s in source_scope if s not in YAJIN_SOURCE_TYPES]
if not source_scope:
return None, '打手佣金请至少选择一个非押金入账来源(押金请到「打手押金」页配置)'
else:
# 其他角色也不允许勾押金来源
source_scope = [s for s in source_scope if s not in YAJIN_SOURCE_TYPES]
if not source_scope:
return None, '请至少选择一个入账来源'
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,
'source_label': {
'order_settle': '订单结算',
'fenhong': '分红/分佣',
'withdraw_reject': '提现驳回',
'club_migrate': '跨店迁移',
'kaohe_fee': '考核代收',
'yajin_recharge': '押金充值',
'yajin_exchange': '兑换押金',
}.get(r.source_type or '', r.source_type or ''),
'credit_target': ((r.config_snapshot or {}).get('credit_target')
or ('yajin' if (r.source_type or '') in (
'yajin_recharge', 'yajin_exchange') else 'yue')),
'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,
}