feat: 提现驳回可选返还手续费与入账冻结(单笔/批量)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user