diff --git a/users/tixian_shenhe_services.py b/users/tixian_shenhe_services.py index c0313c2..fae9e0d 100644 --- a/users/tixian_shenhe_services.py +++ b/users/tixian_shenhe_services.py @@ -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'): diff --git a/users/views/kefu_withdraw.py b/users/views/kefu_withdraw.py index d7bdaf7..ef28b36 100644 --- a/users/views/kefu_withdraw.py +++ b/users/views/kefu_withdraw.py @@ -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: