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