diff --git a/jituan/services/fund_freeze.py b/jituan/services/fund_freeze.py index 94aea54..76fbb8b 100644 --- a/jituan/services/fund_freeze.py +++ b/jituan/services/fund_freeze.py @@ -29,6 +29,7 @@ ROLE_SHENHEGUAN = 'shenheguan' SOURCE_ORDER_SETTLE = 'order_settle' SOURCE_FENHONG = 'fenhong' SOURCE_KAOHE_FEE = 'kaohe_fee' +SOURCE_WITHDRAW_REJECT = 'withdraw_reject' _CFG_CACHE_TTL = 60 @@ -103,11 +104,11 @@ def _source_allowed(cfg: ClubFundFreezeConfig, source_type: str, role: str = '') return True if source_type in items: return True - # 管事/组长几乎没有 order_settle 入账;历史配置只勾了「订单结算」时仍允许分红冻结 + # 管事/组长几乎没有 order_settle 入账;历史配置只勾了「订单结算」时仍允许分红/驳回退款冻结 role = (role or '').strip().lower() if ( role in (ROLE_GUANSHI, ROLE_ZUZHANG, ROLE_SHENHEGUAN) - and source_type == SOURCE_FENHONG + and source_type in (SOURCE_FENHONG, SOURCE_WITHDRAW_REJECT) and set(items) == {'order_settle'} ): return True @@ -184,12 +185,14 @@ def credit_role_balance( czjilu_id: str = '', freeze_reason: str = '', also_credit_zonge: bool = False, + ignore_source_scope: bool = False, ) -> Dict[str, Any]: """ 统一入账。返回 {available, frozen, ledger_id, skipped_freeze}。 调用方须在外层 transaction.atomic() 内,并已持有/即将更新的业务一致性。 profile: 已加载的角色扩展实例(UserDashou 等);若空则按 role+user_id 查。 + ignore_source_scope: True 时跳过 source_scope 检查(仍受 enabled / 金额规则约束)。 """ role = (role or '').strip().lower() cid = _club(club_id) @@ -206,16 +209,15 @@ def credit_role_balance( raise ValueError(f'找不到角色资料 role={role} user_id={user_id}') cfg = get_freeze_config(cid, role) - freeze_on = bool( - cfg and cfg.enabled and _source_allowed(cfg, source_type, role=role) and biz_id - ) + source_ok = True if ignore_source_scope else _source_allowed(cfg, source_type, role=role) if cfg else False + freeze_on = bool(cfg and cfg.enabled and source_ok and biz_id) skip_reason = '' if not freeze_on: if not cfg: skip_reason = 'no_config' elif not cfg.enabled: skip_reason = 'disabled' - elif not _source_allowed(cfg, source_type, role=role): + elif not source_ok: skip_reason = f'source_not_in_scope:{source_type}' elif not biz_id: skip_reason = 'empty_biz_id' diff --git a/jituan/services/fund_freeze_admin.py b/jituan/services/fund_freeze_admin.py index 7948081..072106f 100644 --- a/jituan/services/fund_freeze_admin.py +++ b/jituan/services/fund_freeze_admin.py @@ -138,7 +138,7 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict] source_scope = data.get('source_scope') if source_scope is None: # 默认同时覆盖订单结算与分红(管事/组长主要靠分红入账) - source_scope = ['order_settle', 'fenhong'] + source_scope = ['order_settle', 'fenhong', 'withdraw_reject'] if isinstance(source_scope, str): try: source_scope = json.loads(source_scope) diff --git a/users/tixian_shenhe_services.py b/users/tixian_shenhe_services.py index d44f450..c0313c2 100644 --- a/users/tixian_shenhe_services.py +++ b/users/tixian_shenhe_services.py @@ -187,11 +187,11 @@ def load_audit_dakuan_mode_map(audit_ids): def load_audit_meta_map(audit_ids): """ 批量取审核表元数据(供后台列表等) - 返回 {审核记录id: {'dakuan_mode': int, 'shenhe_danhao': str}} + 返回 {审核记录id: {'dakuan_mode': int, 'shenhe_danhao': str, 'shouxufei': str, 'shijidaozhang': str}} """ if not audit_ids: return {} - value_fields = ['id', 'shenhe_danhao'] + value_fields = ['id', 'shenhe_danhao', 'shouxufei', 'shijidaozhang'] if has_dakuan_mode_field(): value_fields.append('dakuan_mode') result = {} @@ -199,6 +199,8 @@ def load_audit_meta_map(audit_ids): result[row['id']] = { 'shenhe_danhao': row.get('shenhe_danhao') or '', 'dakuan_mode': row.get('dakuan_mode', DAKUAN_MODE_AUTO), + 'shouxufei': str(row.get('shouxufei') if row.get('shouxufei') is not None else '0.00'), + 'shijidaozhang': str(row.get('shijidaozhang') if row.get('shijidaozhang') is not None else '0.00'), } return result @@ -952,6 +954,113 @@ def refund_balance(user_main, leixing, jine): raise ValueError('无效的提现类型') +_LEIXING_FREEZE_ROLE = { + 1: 'dashou', + 2: 'guanshi', + 3: 'zuzhang', + 4: 'shenheguan', +} + + +def estimate_manual_shouxufei(user_main, leixing, shijidaozhang): + """ + 手动打款单未存手续费:按当前俱乐部费率从到账额反推。 + shijidaozhang = shenqing * (1-rate) → fee = shijidaozhang * rate / (1-rate) + """ + from decimal import Decimal, ROUND_HALF_UP + shijidaozhang = Decimal(str(shijidaozhang or 0)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + if shijidaozhang <= 0: + return Decimal('0.00') + try: + from jituan.services.club_user import get_user_club_id + from jituan.services.withdraw_config import get_tixian_feilv + club_id = get_user_club_id(user_main) + rate = Decimal(str(get_tixian_feilv(club_id, leixing) or 0)) + except Exception: + rate = Decimal('0') + if rate <= 0: + return Decimal('0.00') + if rate >= 1: + return Decimal('0.00') + fee = (shijidaozhang * rate / (Decimal('1') - rate)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + return fee if fee > 0 else Decimal('0.00') + + +def refund_withdraw_reject( + user_main, + leixing, + *, + shijidaozhang, + shouxufei=None, + refund_fee=False, + apply_entry_freeze=False, + biz_id='', + freeze_reason='', +): + """ + 提现驳回退款(须在 transaction.atomic 内)。 + + - 默认:只退 shijidaozhang,不走冻结(与现网一致) + - refund_fee=True:额外退还手续费 + - apply_entry_freeze=True:打手/管事/组长/考核官走冻结网关(按俱乐部配置;押金/商家仍直退) + - leixing 只信提现单上的类型,不按用户当前身份推断 + """ + from decimal import Decimal, ROUND_HALF_UP + from jituan.services.fund_freeze import ( + SOURCE_WITHDRAW_REJECT, + credit_role_balance, + settle_freeze_club_id, + ) + + shijidaozhang = Decimal(str(shijidaozhang or 0)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + fee = Decimal(str(shouxufei or 0)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + if fee < 0: + fee = Decimal('0.00') + amount = shijidaozhang + (fee if refund_fee else Decimal('0.00')) + if amount <= 0: + return { + 'amount': Decimal('0.00'), + 'shijidaozhang': shijidaozhang, + 'shouxufei_refunded': Decimal('0.00'), + 'frozen': Decimal('0.00'), + 'available': Decimal('0.00'), + } + + role = _LEIXING_FREEZE_ROLE.get(int(leixing)) + use_freeze = bool(apply_entry_freeze and role) + biz = (biz_id or '').strip() + + if use_freeze: + result = credit_role_balance( + club_id=settle_freeze_club_id(user_id=str(getattr(user_main, 'UserUID', '') or '')), + user_id=str(user_main.UserUID), + role=role, + amount=amount, + source_type=SOURCE_WITHDRAW_REJECT, + biz_id=biz or f'withdraw_reject:{user_main.UserUID}:{leixing}:{amount}', + freeze_reason=freeze_reason or '提现驳回退款', + ignore_source_scope=True, + ) + return { + 'amount': amount, + 'shijidaozhang': shijidaozhang, + 'shouxufei_refunded': fee if refund_fee else Decimal('0.00'), + 'frozen': result.get('frozen') or Decimal('0.00'), + 'available': result.get('available') or Decimal('0.00'), + 'ledger_id': result.get('ledger_id'), + 'apply_entry_freeze': True, + } + + refund_balance(user_main, leixing, amount) + return { + 'amount': amount, + 'shijidaozhang': shijidaozhang, + 'shouxufei_refunded': fee if refund_fee else Decimal('0.00'), + 'frozen': Decimal('0.00'), + 'available': amount, + 'apply_entry_freeze': False, + } + def deduct_balance(user_main, leixing, jine): """扣减申请金额 jine(必须在 transaction.atomic 内调用)""" if leixing == 1: diff --git a/users/views/kefu_withdraw.py b/users/views/kefu_withdraw.py index 63ddbb6..d7bdaf7 100644 --- a/users/views/kefu_withdraw.py +++ b/users/views/kefu_withdraw.py @@ -68,7 +68,8 @@ from ..models import ( ) from users.business_models import User from ..tixian_shenhe_services import ( - load_audit_meta_map, refund_balance, sync_audit_and_jilu_status, + load_audit_meta_map, refund_balance, refund_withdraw_reject, + estimate_manual_shouxufei, sync_audit_and_jilu_status, mark_transfer_success, release_collect_quota_for_record, close_audit_wechat_failed, query_wechat_transfer_bill, check_shijidaozhang_within_limit, @@ -337,8 +338,9 @@ class KefuWithdrawListView(APIView): for item in records: shenhe_jilu_id = getattr(item, 'shenhe_jilu_id', None) if shenhe_field_ok else None shenhe_danhao = (getattr(item, 'shenhe_danhao', None) or '') if shenhe_field_ok else '' + meta = audit_meta_map.get(shenhe_jilu_id, {}) if shenhe_jilu_id else {} if shenhe_jilu_id and not shenhe_danhao: - shenhe_danhao = audit_meta_map.get(shenhe_jilu_id, {}).get('shenhe_danhao', '') + shenhe_danhao = meta.get('shenhe_danhao', '') list_data.append({ 'id': item.id, 'shenhe_jilu_id': shenhe_jilu_id, @@ -351,6 +353,8 @@ class KefuWithdrawListView(APIView): 'zhifu': item.zhifu or '', 'skzhanghao': item.skzhanghao or '', 'jine': str(item.jine) if item.jine is not None else '0.00', + 'shouxufei': meta.get('shouxufei', '0.00') if shenhe_jilu_id else '0.00', + 'shijidaozhang': meta.get('shijidaozhang') or (str(item.jine) if item.jine is not None else '0.00'), 'zhuangtai': item.zhuangtai, 'fangshi': item.fangshi, 'dakuan_mode': self._resolve_row_dakuan_mode(item, audit_mode_map, shenhe_field_ok), @@ -532,13 +536,32 @@ class KefuWithdrawActionView(APIView): 单条:{ phone, action, tixian_id, yonghuid, leixing, reason? } 批量:{ phone, action, batch_list: [{tixian_id, yonghuid, leixing}, ...], reason? } - 自动打款(dakuan_mode=2):同意→6待收款;拒绝→5+驳回原因+退还可到账金额(shijidaozhang),手续费不退,不更新平台收支/每日统计 + 驳回(action=2)可选(默认 false,与现网一致): + refund_fee: 是否退还手续费 + apply_entry_freeze: 退回余额是否走入账冻结(按俱乐部配置;押金/商家无效) + + 自动打款(dakuan_mode=2):同意→6待收款;拒绝→5+驳回原因+退款,不更新平台收支/每日统计 手动打款(dakuan_mode=1):原逻辑不变 状态要求:Tixianjilu.zhuangtai=1 且 TixianShenheJilu.zhuangtai=1(审核中),其他状态一律拒绝 """ permission_classes = [IsAuthenticated] parser_classes = [JSONParser] + @staticmethod + def _parse_bool(raw, default=False): + if raw is None: + return bool(default) + if isinstance(raw, bool): + return raw + if isinstance(raw, (int, float)): + return bool(raw) + s = str(raw).strip().lower() + if s in ('1', 'true', 'yes', 'y', 'on'): + return True + if s in ('0', 'false', 'no', 'n', 'off', ''): + return False + return bool(default) + def _verify_kefu(self, request, phone): """与 menu-access / verify_kefu_permission 对齐:按后台账号放行,不因 UserType 推断失败而 401。""" from jituan.services.admin_context import is_kefu_backend_account, is_system_super_admin @@ -598,7 +621,8 @@ class KefuWithdrawActionView(APIView): return None, Response({'code': 400, 'msg': '参数格式错误'}, status=status.HTTP_400_BAD_REQUEST) return [item], None - def _process_auto_item(self, request, item, action, reason, kefu_user): + def _process_auto_item(self, request, item, action, reason, kefu_user, + refund_fee=False, apply_entry_freeze=False): """自动打款单条处理(须在 transaction.atomic 内调用)""" tixian_id = item['tixian_id'] req_yonghuid = item['yonghuid'] @@ -651,6 +675,7 @@ class KefuWithdrawActionView(APIView): bhliyou=limit_msg, bo_hui_ren_id=kefu_user.UserUID, ) + # 超限自动驳回:保持现网(不退手续费、不走冻结) refund_balance(user, audit.leixing, audit.shijidaozhang) tixian.shenheid = kefu_user.UserUID tixian.bhliyou = limit_msg @@ -665,13 +690,22 @@ class KefuWithdrawActionView(APIView): bhliyou=reason, bo_hui_ren_id=kefu_user.UserUID, ) - # 退还可到账金额,申请时扣的 shenqing_jine 中的手续费(shouxufei)不退 - refund_balance(user, audit.leixing, audit.shijidaozhang) + refund_withdraw_reject( + user, + audit.leixing, + shijidaozhang=audit.shijidaozhang, + shouxufei=audit.shouxufei, + refund_fee=refund_fee, + apply_entry_freeze=apply_entry_freeze, + biz_id=f'withdraw_reject:auto:{tixian_id}', + freeze_reason=f'提现驳回退款 #{tixian_id}', + ) tixian.shenheid = kefu_user.UserUID tixian.bhliyou = reason tixian.save(update_fields=['shenheid', 'bhliyou', 'UpdateTime']) - def _process_manual_item(self, request, item, action, reason, phone): + def _process_manual_item(self, request, item, action, reason, phone, + refund_fee=False, apply_entry_freeze=False): """手动打款单条处理(原逻辑,须在 transaction.atomic 内调用)""" tixian_id = item['tixian_id'] try: @@ -713,8 +747,18 @@ class KefuWithdrawActionView(APIView): except Exception as e: logger.error(f'更新收支记录失败: {e}') else: + fee = estimate_manual_shouxufei(user, tixian.leixing, tixian.jine) if refund_fee else Decimal('0') try: - refund_balance(user, tixian.leixing, tixian.jine) + refund_withdraw_reject( + user, + tixian.leixing, + shijidaozhang=tixian.jine, + shouxufei=fee, + refund_fee=refund_fee, + apply_entry_freeze=apply_entry_freeze, + biz_id=f'withdraw_reject:manual:{tixian_id}', + freeze_reason=f'提现驳回退款 #{tixian_id}', + ) except ValueError as e: raise ValueError(str(e)) tixian.zhuangtai = 3 @@ -730,6 +774,8 @@ class KefuWithdrawActionView(APIView): phone = request.data.get('phone', '').strip() action_raw = request.data.get('action') reason = request.data.get('reason', '').strip() + refund_fee = self._parse_bool(request.data.get('refund_fee'), False) + apply_entry_freeze = self._parse_bool(request.data.get('apply_entry_freeze'), False) try: action = int(action_raw) @@ -773,11 +819,19 @@ class KefuWithdrawActionView(APIView): is_auto = bool(getattr(tixian_probe, 'shenhe_jilu_id', None)) if is_auto: - self._process_auto_item(request, item, action, reason, kefu_user) + self._process_auto_item( + request, item, action, reason, kefu_user, + refund_fee=refund_fee if action == 2 else False, + apply_entry_freeze=apply_entry_freeze if action == 2 else False, + ) else: if is_batch: raise ValueError(f'提现记录{tixian_id}:批量操作仅支持自动打款记录') - self._process_manual_item(request, item, action, reason, phone) + self._process_manual_item( + request, item, action, reason, phone, + refund_fee=refund_fee if action == 2 else False, + apply_entry_freeze=apply_entry_freeze if action == 2 else False, + ) except ValueError as e: return Response({'code': 400, 'msg': str(e)}, status=status.HTTP_400_BAD_REQUEST)