feat: 提现审核支持状态筛选、待收款改状态与驳回返还选项

This commit is contained in:
XingQue
2026-07-30 18:44:10 +08:00
parent 7cda8ccbe1
commit 9df450b278
2 changed files with 265 additions and 67 deletions

View File

@@ -1314,6 +1314,136 @@ def mark_transfer_success(auto_record, *, wechat_transfer_no=None, with_accounti
return True
def admin_mark_pending_collect_success(audit, tixian, kefu_uid, *, with_accounting=True):
"""
客服强制:待收款(6) → 提现成功(2)。
顺序:先同步审核单+提现单状态,再处理打款记录与平台记账。
须在 transaction.atomic 内调用。
"""
audit = TixianShenheJilu.objects.select_for_update().get(pk=audit.pk)
tixian = Tixianjilu.objects.select_for_update().get(pk=tixian.pk)
if int(audit.zhuangtai) != 6 or int(tixian.zhuangtai) != 6:
raise ValueError('仅待收款状态可标记为已收款')
# 1) 先改状态
sync_audit_and_jilu_status(audit, 2, shenhe_ren_id=kefu_uid or '')
tixian.shenheid = kefu_uid or tixian.shenheid or ''
tixian.save(update_fields=['shenheid', 'UpdateTime'])
# 2) 关联打款单:处理中→成功;无打款单则直接按审核金额记账
auto = None
auto_id = (getattr(audit, 'tixian_auto_id', None) or '').strip()
if auto_id:
auto = TixianAutoRecord.objects.select_for_update().filter(pk=auto_id).first()
if auto is None and audit.shenhe_danhao:
auto = (
TixianAutoRecord.objects.select_for_update()
.filter(shenhe_danhao=audit.shenhe_danhao)
.order_by('-CreateTime')
.first()
)
if auto is not None:
if int(auto.zhuangtai) == 0:
auto.zhuangtai = 1
auto.fail_reason = '客服标记已收款'
auto.save(update_fields=['zhuangtai', 'fail_reason', 'UpdateTime'])
if not _quota_already_reserved_for_record(auto):
apply_transfer_success_quota(auto)
if with_accounting:
_apply_platform_accounting(auto)
elif int(auto.zhuangtai) == 1:
# 已成功过,不再重复记账
pass
else:
# 失败单:改成功并记账(客服人工确认已打款)
auto.zhuangtai = 1
auto.fail_reason = '客服标记已收款(原失败单)'
auto.save(update_fields=['zhuangtai', 'fail_reason', 'UpdateTime'])
if with_accounting:
_apply_platform_accounting(auto)
elif with_accounting:
from jituan.services.szjilu_accounting import apply_szjilu_expense
from jituan.services.tixian_club import resolve_payout_club_id
amount = decimal.Decimal(str(audit.shijidaozhang or tixian.jine or 0))
if amount > 0:
payout_club = resolve_payout_club_id(audit=audit, jilu=tixian, yonghuid=tixian.yonghuid)
update_daily_payout(amount, payout_club)
apply_szjilu_expense(amount, payout_club)
return True
def admin_reject_pending_or_auditing(
audit,
tixian,
user_main,
kefu_uid,
reason,
*,
refund_principal=True,
refund_fee=False,
apply_entry_freeze=False,
):
"""
客服驳回:审核中(1) 或 待收款(6) → 审核失败(5)。
顺序:先关打款预占(若有)→ 先改状态 → 再按选项退款。
须在 transaction.atomic 内调用。
"""
audit = TixianShenheJilu.objects.select_for_update().get(pk=audit.pk)
tixian = Tixianjilu.objects.select_for_update().get(pk=tixian.pk)
cur = int(audit.zhuangtai)
if cur not in (1, 6):
raise ValueError('仅审核中/待收款可驳回')
if int(tixian.zhuangtai) != cur:
raise ValueError('提现单与审核单状态不一致')
reason = (reason or '').strip()[:500] or '客服驳回'
# 待收款可能已有打款单:先失败释放限额,避免用户还能收款
auto_id = (getattr(audit, 'tixian_auto_id', None) or '').strip()
auto = None
if auto_id:
auto = TixianAutoRecord.objects.select_for_update().filter(pk=auto_id).first()
if auto is None and audit.shenhe_danhao:
auto = (
TixianAutoRecord.objects.select_for_update()
.filter(shenhe_danhao=audit.shenhe_danhao, zhuangtai=0)
.order_by('-CreateTime')
.first()
)
if auto is not None and int(auto.zhuangtai) == 0:
_close_auto_record_failed(auto, reason)
# 1) 先改状态
sync_audit_and_jilu_status(
audit, 5,
bhliyou=reason,
bo_hui_ren_id=kefu_uid or '',
tixian_auto_id=None,
)
tixian.shenheid = kefu_uid or tixian.shenheid or ''
tixian.bhliyou = reason
tixian.save(update_fields=['shenheid', 'bhliyou', 'UpdateTime'])
# 2) 再按选项退款(可不退)
refund_result = None
principal = decimal.Decimal(str(audit.shijidaozhang or 0)) if refund_principal else decimal.Decimal('0.00')
if refund_principal or refund_fee:
refund_result = refund_withdraw_reject(
user_main,
audit.leixing,
shijidaozhang=principal,
shouxufei=audit.shouxufei,
refund_fee=bool(refund_fee),
apply_entry_freeze=bool(apply_entry_freeze),
biz_id=f'withdraw_reject:admin:{tixian.id}:{cur}',
freeze_reason=f'提现驳回退款 #{tixian.id}',
)
return refund_result
def _sync_record_from_wx_query(auto_record, wx_data):
"""根据微信查单结果修正本地打款记录,返回 (action, payload)"""
if wx_data.get('_not_found'):

View File

@@ -74,6 +74,8 @@ from ..tixian_shenhe_services import (
close_audit_wechat_failed, query_wechat_transfer_bill,
check_shijidaozhang_within_limit,
check_dashou_trial_blocks_commission_withdraw,
admin_mark_pending_collect_success,
admin_reject_pending_or_auditing,
WX_STATE_FAIL, WX_STATE_SUCCESS, WX_STATE_WAIT, WX_STATE_CANCELING,
)
from ..tixian_shenhe_views import process_audit_collect
@@ -208,6 +210,7 @@ class KefuWithdrawListView(APIView):
page_size = min(max(page_size, 1), 100)
status_filter = request.data.get('status')
zhuangtai_exact = request.data.get('zhuangtai')
type_filter = request.data.get('type')
payment_filter = request.data.get('payment')
search_uid = (request.data.get('search_uid') or '').strip()
@@ -230,6 +233,15 @@ class KefuWithdrawListView(APIView):
except (TypeError, ValueError):
status_val = None
try:
zhuangtai_val = (
int(zhuangtai_exact)
if zhuangtai_exact is not None and str(zhuangtai_exact).strip() != ''
else None
)
except (TypeError, ValueError):
zhuangtai_val = None
try:
dakuan_mode_val = int(dakuan_mode_filter) if dakuan_mode_filter is not None and str(dakuan_mode_filter).strip() != '' else None
except (TypeError, ValueError):
@@ -238,13 +250,17 @@ class KefuWithdrawListView(APIView):
# 未传打款方式参数 → 默认手动打款(1)
effective_dakuan = dakuan_mode_val if dakuan_mode_val in (1, 2) else 1
if status_val == 1:
# 精确状态优先1审核中 2成功 3失败 4审核成功 5审核失败 6待收款
if zhuangtai_val in (1, 2, 3, 4, 5, 6):
q &= Q(zhuangtai=zhuangtai_val)
elif status_val == 1:
q &= Q(zhuangtai=1)
elif status_val == 2:
if effective_dakuan == 2:
q &= Q(zhuangtai__in=[2, 3, 4, 5, 6])
else:
q &= Q(zhuangtai__in=[2, 3])
# status_val 为空或 0不按桶过滤配合精确状态或看全部
if type_filter is not None and str(type_filter).strip() != '':
try:
@@ -265,8 +281,9 @@ class KefuWithdrawListView(APIView):
if search_uid:
q &= Q(yonghuid=search_uid)
# 按申请日筛选CreateTime不是处理日
if date_filter:
q &= Q(UpdateTime__date=date_filter)
q &= Q(CreateTime__date=date_filter)
if min_amount is not None and str(min_amount).strip() != '':
try:
@@ -341,6 +358,8 @@ class KefuWithdrawListView(APIView):
meta = audit_meta_map.get(shenhe_jilu_id, {}) if shenhe_jilu_id else {}
if shenhe_jilu_id and not shenhe_danhao:
shenhe_danhao = meta.get('shenhe_danhao', '')
create_time = item.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if item.CreateTime else ''
update_time = item.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if item.UpdateTime else ''
list_data.append({
'id': item.id,
'shenhe_jilu_id': shenhe_jilu_id,
@@ -360,8 +379,11 @@ class KefuWithdrawListView(APIView):
'dakuan_mode': self._resolve_row_dakuan_mode(item, audit_mode_map, shenhe_field_ok),
'shenheid': item.shenheid or '',
'bhliyou': item.bhliyou or '',
'CreateTime': item.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if item.CreateTime else '',
'UpdateTime': item.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if item.UpdateTime else '',
# 同时返回两种命名,兼容前后端
'CreateTime': create_time,
'UpdateTime': update_time,
'create_time': create_time,
'update_time': update_time,
})
from jituan.services.club_user_access import list_response_meta
@@ -465,7 +487,9 @@ class KefuWithdrawDetailView(APIView):
except User.DoesNotExist:
user = None
# 构建基础返回数据(字段必须与前端一致)
# 构建基础返回数据(字段必须与前端一致;时间同时给 snake_case
create_time = record.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if record.CreateTime else ''
update_time = record.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if record.UpdateTime else ''
data = {
'id': record.id,
'yonghuid': record.yonghuid,
@@ -480,10 +504,29 @@ class KefuWithdrawDetailView(APIView):
'fangshi': record.fangshi,
'shenheid': record.shenheid or '',
'bhliyou': record.bhliyou or '',
'CreateTime': record.CreateTime,
'UpdateTime': record.UpdateTime,
'CreateTime': create_time,
'UpdateTime': update_time,
'create_time': create_time,
'update_time': update_time,
'shenhe_jilu_id': getattr(record, 'shenhe_jilu_id', None),
'shenhe_danhao': getattr(record, 'shenhe_danhao', None) or '',
'dakuan_mode': 2 if getattr(record, 'shenhe_jilu_id', None) else 1,
'shouxufei': '0.00',
'shijidaozhang': str(record.jine) if record.jine else '0.00',
}
sid = getattr(record, 'shenhe_jilu_id', None)
if sid:
try:
meta_map = load_audit_meta_map([sid])
meta = meta_map.get(sid) or {}
data['shenhe_danhao'] = data['shenhe_danhao'] or meta.get('shenhe_danhao', '')
data['shouxufei'] = meta.get('shouxufei', '0.00')
data['shijidaozhang'] = meta.get('shijidaozhang') or data['shijidaozhang']
data['dakuan_mode'] = meta.get('dakuan_mode', 2) or 2
except Exception:
pass
# 根据提现类型添加扩展信息
if user:
if record.leixing == 1: # 打手
@@ -536,13 +579,19 @@ class KefuWithdrawActionView(APIView):
单条:{ phone, action, tixian_id, yonghuid, leixing, reason? }
批量:{ phone, action, batch_list: [{tixian_id, yonghuid, leixing}, ...], reason? }
驳回(action=2)可选(默认 false与现网一致
refund_fee: 是否退还手续费
apply_entry_freeze: 退回余额是否走入账冻结(按俱乐部配置;押金/商家无效
action:
1 = 同意(审核中→待收款)
2 = 驳回(审核中/待收款→审核失败
3 = 标记已收款(仅待收款→提现成功)
自动打款(dakuan_mode=2)同意→6待收款拒绝→5+驳回原因+退款,不更新平台收支/每日统计
手动打款(dakuan_mode=1):原逻辑不变
状态要求Tixianjilu.zhuangtai=1 且 TixianShenheJilu.zhuangtai=1审核中其他状态一律拒绝
驳回(action=2)可选:
refund_principal: 是否返还到账本金(默认 true
refund_fee: 是否退还手续费(默认 false
apply_entry_freeze: 退回余额是否走入账冻结(默认 false
三者均可关:只改状态不退钱
自动打款(dakuan_mode=2)同意→6待收款拒绝→5标记已收款→2
手动打款(dakuan_mode=1):原逻辑不变(仅审核中)
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
@@ -622,7 +671,8 @@ class KefuWithdrawActionView(APIView):
return [item], None
def _process_auto_item(self, request, item, action, reason, kefu_user,
refund_fee=False, apply_entry_freeze=False):
refund_fee=False, apply_entry_freeze=False,
refund_principal=True, with_accounting=True):
"""自动打款单条处理(须在 transaction.atomic 内调用)"""
tixian_id = item['tixian_id']
req_yonghuid = item['yonghuid']
@@ -645,8 +695,6 @@ class KefuWithdrawActionView(APIView):
raise ValueError(f'提现记录{tixian_id}用户ID不匹配')
if tixian.leixing != req_leixing:
raise ValueError(f'提现记录{tixian_id}:提现类型不匹配')
if tixian.zhuangtai != 1:
raise ValueError(f'提现记录{tixian_id}:非审核中状态,无法处理')
if not tixian.shenhe_jilu_id:
raise ValueError(f'提现记录{tixian_id}:非自动打款审核记录')
@@ -659,53 +707,59 @@ class KefuWithdrawActionView(APIView):
raise ValueError(f'提现记录{tixian_id}审核记录用户ID不匹配')
if audit.leixing != req_leixing:
raise ValueError(f'提现记录{tixian_id}:审核记录提现类型不匹配')
if audit.zhuangtai != 1:
raise ValueError(f'提现记录{tixian_id}:审核记录非审核中状态')
try:
user = User.objects.select_for_update().get(UserUID=req_yonghuid)
except User.DoesNotExist:
raise ValueError(f'用户{req_yonghuid}不存在')
if action == 1:
limit_ok, limit_msg = check_shijidaozhang_within_limit(audit.shijidaozhang)
if not limit_ok:
sync_audit_and_jilu_status(
audit, 5,
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
tixian.save(update_fields=['shenheid', 'bhliyou', 'UpdateTime'])
raise ValueError(f'提现记录{tixian_id}{limit_msg},已自动驳回并退款')
sync_audit_and_jilu_status(audit, 6, shenhe_ren_id=kefu_user.UserUID)
tixian.shenheid = kefu_user.UserUID
tixian.save(update_fields=['shenheid', 'UpdateTime'])
else:
sync_audit_and_jilu_status(
audit, 5,
bhliyou=reason,
bo_hui_ren_id=kefu_user.UserUID,
cur = int(tixian.zhuangtai)
kefu_uid = getattr(kefu_user, 'UserUID', '') or ''
# action=3待收款 → 已收款
if action == 3:
if cur != 6 or int(audit.zhuangtai) != 6:
raise ValueError(f'提现记录{tixian_id}:仅待收款可标记已收款')
admin_mark_pending_collect_success(
audit, tixian, kefu_uid, with_accounting=with_accounting,
)
refund_withdraw_reject(
user,
audit.leixing,
shijidaozhang=audit.shijidaozhang,
shouxufei=audit.shouxufei,
return
# action=2审核中/待收款 → 驳回
if action == 2:
if cur not in (1, 6) or int(audit.zhuangtai) not in (1, 6):
raise ValueError(f'提现记录{tixian_id}:仅审核中/待收款可驳回')
admin_reject_pending_or_auditing(
audit, tixian, user, kefu_uid, reason,
refund_principal=refund_principal,
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'])
return
# action=1仅审核中 → 待收款
if action == 1:
if cur != 1 or int(audit.zhuangtai) != 1:
raise ValueError(f'提现记录{tixian_id}:非审核中状态,无法同意')
limit_ok, limit_msg = check_shijidaozhang_within_limit(audit.shijidaozhang)
if not limit_ok:
admin_reject_pending_or_auditing(
audit, tixian, user, kefu_uid, limit_msg,
refund_principal=True,
refund_fee=False,
apply_entry_freeze=False,
)
raise ValueError(f'提现记录{tixian_id}{limit_msg},已自动驳回并退款')
sync_audit_and_jilu_status(audit, 6, shenhe_ren_id=kefu_uid)
tixian.shenheid = kefu_uid
tixian.save(update_fields=['shenheid', 'UpdateTime'])
return
raise ValueError(f'不支持的 action={action}')
def _process_manual_item(self, request, item, action, reason, phone,
refund_fee=False, apply_entry_freeze=False):
refund_fee=False, apply_entry_freeze=False,
refund_principal=True):
"""手动打款单条处理(原逻辑,须在 transaction.atomic 内调用)"""
tixian_id = item['tixian_id']
try:
@@ -747,22 +801,25 @@ 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_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
tixian.bhliyou = reason
if refund_principal or refund_fee:
fee = estimate_manual_shouxufei(user, tixian.leixing, tixian.jine) if refund_fee else Decimal('0')
principal = tixian.jine if refund_principal else Decimal('0')
try:
refund_withdraw_reject(
user,
tixian.leixing,
shijidaozhang=principal,
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.shenheid = phone
tixian.UpdateTime = timezone.now()
@@ -776,13 +833,19 @@ class KefuWithdrawActionView(APIView):
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)
# 驳回默认返还本金;可显式关掉只改状态
refund_principal = self._parse_bool(request.data.get('refund_principal'), True)
with_accounting = self._parse_bool(request.data.get('with_accounting'), True)
try:
action = int(action_raw)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': 'action 参数必须为数字'}, status=status.HTTP_400_BAD_REQUEST)
if action not in [1, 2]:
return Response({'code': 400, 'msg': 'action 必须为1(同意)或2(拒绝)'}, status=status.HTTP_400_BAD_REQUEST)
if action not in [1, 2, 3]:
return Response(
{'code': 400, 'msg': 'action 必须为1(同意)/2(驳回)/3(标记已收款)'},
status=status.HTTP_400_BAD_REQUEST,
)
if action == 2 and not reason:
return Response({'code': 400, 'msg': '拒绝提现时理由不能为空'}, status=status.HTTP_400_BAD_REQUEST)
@@ -823,14 +886,19 @@ class KefuWithdrawActionView(APIView):
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,
refund_principal=refund_principal if action == 2 else True,
with_accounting=with_accounting if action == 3 else True,
)
else:
if action == 3:
raise ValueError(f'提现记录{tixian_id}:手动打款无「待收款」,不能标记已收款')
if is_batch:
raise ValueError(f'提现记录{tixian_id}:批量操作仅支持自动打款记录')
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,
refund_principal=refund_principal if action == 2 else True,
)
except ValueError as e: