feat: 提现驳回可选返还手续费与入账冻结(单笔/批量)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-29 00:57:12 +08:00
parent 8e7481e25d
commit 36e1bf902a
4 changed files with 184 additions and 19 deletions

View File

@@ -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'

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)