# yonghu/tixian_shenhe_apply_b.py """ 自动提现审核流程业务服务层(仅服务于 zddksh 申请接口 与 tixiansq 收款接口) 流程: ① zddksh 提交审核:资格校验 → 扣款(shenqing_jine) → 写审核表+提现记录表(状态=1) (不在申请阶段校验/占用每日限额) ② 后台审核通过:状态变为 6(待收款)—— 由原有后台接口处理,此处不改动 ③ tixiansq 收款:资格校验 → 只读预估 → 事务内原子预占限额 → 新建打款记录 → 调微信 微信明确失败/取消则回滚预占;待查单期间保留预占防并发超额 ④ 打款成功(tixianqr/回调/查单):若收款前已预占则不再累加;未预占的旧单/查单直成则补记 状态码(与前端 TX_STATUS 一致): 1=审核中 2=提现成功 3=提现失败 4=审核成功 5=审核失败 6=待收款 """ import decimal import json import logging import random import time from datetime import date, timedelta import requests from django.conf import settings from django.db import transaction from django.utils import timezone from dingdan.models import Dingdan, DingdanShangjia, Fadan, Lilubiao from peizhi.models import Szjilu, TixianQuotaDefault from utils.fadan_utils import check_fadan_qiangdan_eligible from .models import ( TixianAutoRecord, TixianShenheJilu, Tixianjilu, UserDashou, UserGuanshi, UserMain, UserShenheguan, UserShangjia, UserZuzhang, ) logger = logging.getLogger('yonghu.tixian_shenhe') ORDER_STATUS_COMPLETED = 3 ORDER_STATUS_REFUNDED = 5 LEIXING_RATE_KEY = { 1: '5', 2: '6', 3: '8', 4: '9', 5: '11', 6: '10', } # 进行中的审核状态(不允许重复提交申请) PENDING_AUDIT_STATUSES = [1, 4, 6] DAKUAN_MODE_MANUAL = 1 DAKUAN_MODE_AUTO = 2 # 微信转账单状态(查单接口返回 state 字段) WX_STATE_SUCCESS = ('SUCCESS',) WX_STATE_WAIT = ('WAIT_USER_CONFIRM', 'TRANSFERING', 'ACCEPTED', 'PROCESSING') WX_STATE_FAIL = ('FAIL', 'CLOSED', 'CANCELLED') WX_STATE_CANCELING = ('CANCELING',) # 撤销中,非终态,禁止换新单号 RECONCILE_COMPLETED = 'completed' RECONCILE_WAIT_CONFIRM = 'wait_confirm' RECONCILE_ALLOW_NEW = 'allow_new' RECONCILE_PENDING = 'pending_query' # 查单失败且本地 zhuangtai=0 超过该分钟数,才允许新建打款记录 PENDING_QUERY_GRACE_MINUTES = 10 def has_dakuan_mode_field(): """生产库未跑 0032 迁移时模型无此字段,需兼容""" try: TixianShenheJilu._meta.get_field('dakuan_mode') return True except Exception: return False def is_auto_dakuan_audit(audit): """判断是否自动打款审核单;无 dakuan_mode 属性时,有审核记录即视为 zddksh 自动流程""" if audit is None: return False mode = getattr(audit, 'dakuan_mode', None) if mode is None: return True try: return int(mode) == DAKUAN_MODE_AUTO except (TypeError, ValueError): return True def load_audit_dakuan_mode_map(audit_ids): """批量取审核单打款方式;字段不存在时有关联审核即视为自动(2)""" return { aid: meta.get('dakuan_mode', DAKUAN_MODE_AUTO) for aid, meta in load_audit_meta_map(audit_ids).items() } def load_audit_meta_map(audit_ids): """ 批量取审核表元数据(供后台列表等) 返回 {审核记录id: {'dakuan_mode': int, 'shenhe_danhao': str}} """ if not audit_ids: return {} value_fields = ['id', 'shenhe_danhao'] if has_dakuan_mode_field(): value_fields.append('dakuan_mode') result = {} for row in TixianShenheJilu.objects.filter(id__in=audit_ids).values(*value_fields): result[row['id']] = { 'shenhe_danhao': row.get('shenhe_danhao') or '', 'dakuan_mode': row.get('dakuan_mode', DAKUAN_MODE_AUTO), } return result def generate_shenhe_danhao(): """生成唯一审核单号:TXSH + 时间戳后10位 + 4位随机数""" for _ in range(10): timestamp = str(int(time.time() * 1000))[-10:] random_part = str(random.randint(1000, 9999)) danhao = f'TXSH{timestamp}{random_part}' if not TixianShenheJilu.objects.filter(shenhe_danhao=danhao).exists(): return danhao raise RuntimeError('生成审核单号失败,请重试') def get_tixian_feilv(leixing): rate_key = LEIXING_RATE_KEY.get(leixing) if not rate_key: raise ValueError('无效的提现类型') rate_obj = Lilubiao.objects.filter(fadanpingtai=rate_key).first() if not rate_obj: raise ValueError(f'提现费率未配置(类型{leixing})') return decimal.Decimal(str(rate_obj.lilu)) def calc_fee_amounts(jine, feilv): """申请金额 jine;实际到账 = jine - 手续费""" shouxufei = (jine * feilv).quantize(decimal.Decimal('0.01')) shijidaozhang = (jine - shouxufei).quantize(decimal.Decimal('0.01')) return shouxufei, shijidaozhang def check_dashou_has_valid_huiyuan(yonghuid): """ 打手至少持有一个未过期会员(Huiyuangoumai + jiance_shifou_daoqi 同步状态) 返回 (True, '') 或 (False, 原因) """ from shangpin.models import Huiyuangoumai records = Huiyuangoumai.objects.filter(yonghu_id=yonghuid) if not records.exists(): return False, '您暂无会员记录,无法提现' for record in records: is_expired = record.jiance_shifou_daoqi() if not is_expired and record.huiyuan_zhuangtai == 1: return True, '' return False, '您的会员已全部过期,请先续费后再操作' def validate_withdraw_eligibility(user_main, leixing, jine): """ 提现资格校验(对齐手动提现 TixianShenqingView) """ yonghuid = user_main.yonghuid fadan_ok, fadan_msg = check_fadan_qiangdan_eligible(yonghuid, 2) if not fadan_ok: return False, fadan_msg, {} if leixing == 1: try: dashou = UserDashou.objects.get(user=user_main) except UserDashou.DoesNotExist: return False, '您不是打手身份,无法提现佣金', {} if dashou.zhanghaozhuangtai != 1: return False, '打手账号已被封禁,无法提现', {} if dashou.zhuangtai != 1: return False, '有订单进行中,请完成后提现', {} if dashou.yue < jine: return False, f'余额不足,当前余额: {dashou.yue}', {} return True, '', {'nicheng': dashou.nicheng or ''} if leixing == 2: try: guanshi = UserGuanshi.objects.get(user=user_main) except UserGuanshi.DoesNotExist: return False, '您不是管事身份,无法提现分红', {} if guanshi.zhuangtai != 1: return False, '管事账号已被封禁,无法提现', {} if guanshi.yue < jine: return False, f'余额不足,当前余额: {guanshi.yue}', {} return True, '', {'nicheng': '管事用户'} if leixing == 3: try: zuzhang = UserZuzhang.objects.get(user=user_main) except UserZuzhang.DoesNotExist: return False, '您不是组长身份,无法提现组长分红', {} if zuzhang.zhuangtai != 1: return False, '组长账号已被封禁,无法提现', {} if zuzhang.ketixian_jine < jine: return False, f'可提现金额不足,当前可提: {zuzhang.ketixian_jine}', {} return True, '', {'nicheng': ''} if leixing == 4: try: kaoheguan = UserShenheguan.objects.get(user=user_main) except UserShenheguan.DoesNotExist: return False, '您不是考核官身份,无法提现分佣', {} if kaoheguan.zhuangtai != 1: return False, '考核官账号已被封禁,无法提现', {} if kaoheguan.yue < jine: return False, f'考核官可提现金额不足,当前余额: {kaoheguan.yue}', {} return True, '', {'nicheng': '考核官'} if leixing == 5: try: dashou = UserDashou.objects.get(user=user_main) except UserDashou.DoesNotExist: return False, '您不是打手身份,无法提取押金', {} if dashou.zhanghaozhuangtai != 1: return False, '打手账号已禁用,无法提取押金', {} if dashou.zhuangtai != 1: return False, '您有订单进行中,请完成后提取押金', {} if dashou.yajin < jine: return False, f'押金余额不足,当前押金: {dashou.yajin}', {} unfinished = Dingdan.objects.filter( jiedan_dashou_id=yonghuid, ).exclude(zhuangtai__in=[ORDER_STATUS_COMPLETED, ORDER_STATUS_REFUNDED]).exists() if unfinished: return False, '您还有未完成或未退款的订单,请先处理完毕再提取押金', {} self_fadan = Fadan.objects.filter(beichufa_id=yonghuid).exclude(zhuangtai__in=[2, 4]).exists() if self_fadan: return False, '您有未处理完毕的自身罚单(待缴纳/申诉中),请先处理', {} return True, '', {'nicheng': dashou.nicheng or ''} if leixing == 6: try: shangjia = UserShangjia.objects.get(user=user_main) except UserShangjia.DoesNotExist: return False, '您不是商家身份,无法提现商家余额', {} if shangjia.zhuangtai != 1: return False, '商家账号已被禁用,无法提现', {} if shangjia.yue < jine: return False, f'商家余额不足,当前余额: {shangjia.yue}', {} unfinished_orders = DingdanShangjia.objects.filter( shangjia_id=yonghuid, ).exclude(dingdan__zhuangtai__in=[ORDER_STATUS_COMPLETED, ORDER_STATUS_REFUNDED]).exists() if unfinished_orders: return False, '您还有未完成或未退款的订单,请先处理完毕再提现余额', {} self_fadan = Fadan.objects.filter(beichufa_id=yonghuid).exclude(zhuangtai__in=[2, 4]).exists() if self_fadan: return False, '您有未处理完毕的自身罚单(待缴纳/申诉中),请先处理', {} applied_fadan = Fadan.objects.filter(shenqing_chufa=yonghuid).exclude(zhuangtai__in=[2, 4]).exists() if applied_fadan: return False, '您发起的对其他用户的处罚尚未处理完毕,请等待完结', {} return True, '', {'nicheng': shangjia.nicheng or ''} return False, '无效的提现类型', {} def validate_collect_eligibility(user_main, leixing): """ 收款前二次校验(申请时已扣款,不再校余额/金额)。 打手佣金/押金:罚单、封禁、接单中、积分、会员等; 其他身份:账号状态、罚单/订单等(不含金额)。 """ yonghuid = user_main.yonghuid fadan_ok, fadan_msg = check_fadan_qiangdan_eligible(yonghuid, 2) if not fadan_ok: return False, fadan_msg if leixing == 1: try: dashou = UserDashou.objects.get(user=user_main) except UserDashou.DoesNotExist: return False, '您不是打手身份,无法收款' if dashou.zhanghaozhuangtai != 1: return False, '打手账号已被封禁,无法收款' if dashou.zhuangtai != 1: return False, '您有订单进行中,请完成后再收款' return True, '' if leixing == 5: try: dashou = UserDashou.objects.get(user=user_main) except UserDashou.DoesNotExist: return False, '您不是打手身份,无法收款' if dashou.zhanghaozhuangtai != 1: return False, '打手账号已禁用,无法收款' if dashou.zhuangtai != 1: return False, '您有订单进行中,请完成后再收款' unfinished = Dingdan.objects.filter( jiedan_dashou_id=yonghuid, ).exclude(zhuangtai__in=[ORDER_STATUS_COMPLETED, ORDER_STATUS_REFUNDED]).exists() if unfinished: return False, '您还有未完成或未退款的订单,无法收款' self_fadan = Fadan.objects.filter(beichufa_id=yonghuid).exclude(zhuangtai__in=[2, 4]).exists() if self_fadan: return False, '您有未处理完毕的自身罚单,无法收款' return True, '' if leixing == 2: try: guanshi = UserGuanshi.objects.get(user=user_main) except UserGuanshi.DoesNotExist: return False, '您不是管事身份,无法收款' if guanshi.zhuangtai != 1: return False, '管事账号已被封禁,无法收款' return True, '' if leixing == 3: try: zuzhang = UserZuzhang.objects.get(user=user_main) except UserZuzhang.DoesNotExist: return False, '您不是组长身份,无法收款' if zuzhang.zhuangtai != 1: return False, '组长账号已被封禁,无法收款' return True, '' if leixing == 4: try: kaoheguan = UserShenheguan.objects.get(user=user_main) except UserShenheguan.DoesNotExist: return False, '您不是考核官身份,无法收款' if kaoheguan.zhuangtai != 1: return False, '考核官账号已被封禁,无法收款' return True, '' if leixing == 6: try: shangjia = UserShangjia.objects.get(user=user_main) except UserShangjia.DoesNotExist: return False, '您不是商家身份,无法收款' if shangjia.zhuangtai != 1: return False, '商家账号已被禁用,无法收款' unfinished_orders = DingdanShangjia.objects.filter( shangjia_id=yonghuid, ).exclude(dingdan__zhuangtai__in=[ORDER_STATUS_COMPLETED, ORDER_STATUS_REFUNDED]).exists() if unfinished_orders: return False, '您还有未完成或未退款的订单,无法收款' self_fadan = Fadan.objects.filter(beichufa_id=yonghuid).exclude(zhuangtai__in=[2, 4]).exists() if self_fadan: return False, '您有未处理完毕的自身罚单,无法收款' applied_fadan = Fadan.objects.filter(shenqing_chufa=yonghuid).exclude(zhuangtai__in=[2, 4]).exists() if applied_fadan: return False, '您发起的处罚尚未处理完毕,无法收款' return True, '' return False, '无效的提现类型' def _get_personal_daily_quota(leixing, profile): """打手/管事/组长个人每日限额:额外开启优先,否则默认配置""" if leixing == 1: if profile.kaioi_ewai_xiane and profile.ewai_xiane > 0: return profile.ewai_xiane return TixianQuotaDefault.objects.get(leixing=1).default_quota if leixing == 2: if profile.kaioi_ewai_xiane and profile.ewai_xiane > 0: return profile.ewai_xiane return TixianQuotaDefault.objects.get(leixing=2).default_quota if leixing == 3: try: default_quota = TixianQuotaDefault.objects.get(leixing=3).default_quota except TixianQuotaDefault.DoesNotExist: default_quota = decimal.Decimal('0.00') if profile.kaioi_ewai_tixian and profile.ewai_tixian_xiane > 0: return profile.ewai_tixian_xiane return default_quota raise ValueError('无效的个人限额身份') def _get_active_auto_record(shenhe_danhao): if not shenhe_danhao: return None return ( TixianAutoRecord.objects.filter( shenhe_danhao=shenhe_danhao, zhuangtai__in=[0, 1], ) .order_by('-create_time') .first() ) def _get_platform_daily_total(leixing, today=None): """平台今日已提现总额:只读 houtai.TixianRiTongji(到账金额口径)""" from houtai.models import TixianRiTongji if today is None: today = date.today() try: return TixianRiTongji.objects.get(riqi=today, leixing=leixing).total_amount except TixianRiTongji.DoesNotExist: return decimal.Decimal('0.00') def _calc_platform_projected(leixing, shijidaozhang, shenhe_danhao=''): """ 平台今日预占后总额(收款前只读预估)。 本审核单若已有进行中打款记录,其金额尚未写入统计表,但不应重复加计。 返回 (daily_used, projected_total, additional_amount) """ daily_used = _get_platform_daily_total(leixing) active_rec = _get_active_auto_record(shenhe_danhao) if active_rec: return daily_used, daily_used, decimal.Decimal('0.00') additional = shijidaozhang or decimal.Decimal('0.00') return daily_used, daily_used + additional, additional def _projected_personal_total(personal_today, jine, shenhe_danhao=''): """个人今日占用预估(申请金额口径)""" rec = _get_active_auto_record(shenhe_danhao) already_counted = rec.jine if rec else decimal.Decimal('0.00') return personal_today - already_counted + jine def _get_platform_daily_limit(leixing): """ 平台每日总限额(peizhi.TixianQuotaDefault): 提现身份1打手→配置4,2管事→5,3组长→6,4考核官→7,5押金→8,6商家→9 """ quota_leixing = leixing + 3 try: return TixianQuotaDefault.objects.get(leixing=quota_leixing).default_quota except TixianQuotaDefault.DoesNotExist: logger.warning( '平台每日总限额未配置 leixing=%s(读取quota_leixing=%s),按0处理禁止新增收款', leixing, quota_leixing, ) return decimal.Decimal('0.00') def _check_personal_quota_readonly(user_main, leixing, jine, shenhe_danhao=''): """个人每日限额只读校验(打手/管事/组长)""" if leixing not in (1, 2, 3): return True, '', '' today = date.today() if leixing == 1: try: profile = UserDashou.objects.get(user=user_main) except UserDashou.DoesNotExist: return False, '打手信息不存在', 'personal' if profile.last_tixian_date != today: personal_today = decimal.Decimal('0.00') else: personal_today = profile.jinritixian_jine elif leixing == 2: try: profile = UserGuanshi.objects.get(user=user_main) except UserGuanshi.DoesNotExist: return False, '管事信息不存在', 'personal' if profile.last_tixian_date != today: personal_today = decimal.Decimal('0.00') else: personal_today = profile.jinritixian_jine else: try: profile = UserZuzhang.objects.get(user=user_main) except UserZuzhang.DoesNotExist: return False, '组长信息不存在', 'personal' if profile.last_tixian_time and profile.last_tixian_time.date() == today: personal_today = profile.jinri_tixian else: personal_today = decimal.Decimal('0.00') personal_quota = _get_personal_daily_quota(leixing, profile) projected_personal = _projected_personal_total(personal_today, jine, shenhe_danhao) if projected_personal > personal_quota: return ( False, f'今日提现已达个人限额,今日已提{personal_today}元,本次申请{jine}元,限额{personal_quota}元', 'personal', ) return True, '', '' def _check_platform_quota_readonly(leixing, shijidaozhang, shenhe_danhao=''): """平台每日总限额只读校验:今日已用 + 本笔到账 > 总限额 则拒绝""" platform_limit = _get_platform_daily_limit(leixing) shijidaozhang = shijidaozhang or decimal.Decimal('0.00') if platform_limit <= 0 and shijidaozhang > 0: return False, '今日该角色提现已关闭(总限额为0),暂无法收款', 'platform' daily_used, projected, additional = _calc_platform_projected( leixing, shijidaozhang, shenhe_danhao, ) if projected > platform_limit: logger.warning( '平台总限额拦截 leixing=%s shenhe_danhao=%s daily_used=%s additional=%s ' 'projected=%s limit=%s', leixing, shenhe_danhao, daily_used, additional, projected, platform_limit, ) return ( False, f'今日该角色提现总额已达上限,今日已提{daily_used}元,本次到账{additional}元,限额{platform_limit}元', 'platform', ) return True, '', '' def check_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe_danhao=''): """ 收款限额只读校验(复用待确认打款单等场景,不写入)。 返回 (True, '', '') 或 (False, msg, 'personal'|'platform') """ ok, msg, kind = _check_personal_quota_readonly(user_main, leixing, jine, shenhe_danhao) if not ok: return False, msg, kind return _check_platform_quota_readonly(leixing, shijidaozhang, shenhe_danhao) def reserve_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe_danhao=''): """ 收款前原子预占每日限额(必须在 transaction.atomic 内调用)。 锁定 TixianRiTongji + 用户行,校验通过后立即写入统计,防止高并发超额。 本审核单已有进行中/成功打款记录则视为已预占,不重复加计。 返回 (True, '', '') 或 (False, msg, 'personal'|'platform') """ from houtai.models import TixianRiTongji today = date.today() shijidaozhang = shijidaozhang or decimal.Decimal('0.00') if _get_active_auto_record(shenhe_danhao): return True, '', '' personal_profile = None if leixing in (1, 2, 3): if leixing == 1: personal_profile = UserDashou.objects.select_for_update().get(user=user_main) if personal_profile.last_tixian_date != today: personal_profile.jinritixian_jine = decimal.Decimal('0.00') personal_profile.last_tixian_date = today personal_today = personal_profile.jinritixian_jine elif leixing == 2: personal_profile = UserGuanshi.objects.select_for_update().get(user=user_main) if personal_profile.last_tixian_date != today: personal_profile.jinritixian_jine = decimal.Decimal('0.00') personal_profile.last_tixian_date = today personal_today = personal_profile.jinritixian_jine else: personal_profile = UserZuzhang.objects.select_for_update().get(user=user_main) if personal_profile.last_tixian_time and personal_profile.last_tixian_time.date() != today: personal_profile.jinri_tixian = decimal.Decimal('0.00') elif not personal_profile.last_tixian_time: personal_profile.jinri_tixian = decimal.Decimal('0.00') personal_today = personal_profile.jinri_tixian personal_quota = _get_personal_daily_quota(leixing, personal_profile) if personal_today + jine > personal_quota: return ( False, f'今日提现已达个人限额,今日已提{personal_today}元,本次申请{jine}元,限额{personal_quota}元', 'personal', ) platform_stat, _ = TixianRiTongji.objects.select_for_update().get_or_create( riqi=today, leixing=leixing, defaults={'total_amount': decimal.Decimal('0.00')}, ) platform_limit = _get_platform_daily_limit(leixing) if platform_limit <= 0 and shijidaozhang > 0: return False, '今日该角色提现已关闭(总限额为0),暂无法收款', 'platform' if platform_stat.total_amount + shijidaozhang > platform_limit: return ( False, f'今日该角色提现总额已达上限,今日已提{platform_stat.total_amount}元,' f'本次到账{shijidaozhang}元,限额{platform_limit}元', 'platform', ) if personal_profile is not None: if leixing == 1: personal_profile.jinritixian_jine += jine personal_profile.save(update_fields=['jinritixian_jine', 'last_tixian_date']) elif leixing == 2: personal_profile.jinritixian_jine += jine personal_profile.save(update_fields=['jinritixian_jine', 'last_tixian_date']) else: personal_profile.jinri_tixian += jine personal_profile.last_tixian_time = timezone.now() personal_profile.save(update_fields=['jinri_tixian', 'last_tixian_time']) platform_stat.total_amount += shijidaozhang platform_stat.save(update_fields=['total_amount']) logger.info( '收款限额预占成功 yonghuid=%s leixing=%s shenhe_danhao=%s jine=%s shijidaozhang=%s platform_total=%s', user_main.yonghuid, leixing, shenhe_danhao, jine, shijidaozhang, platform_stat.total_amount, ) return True, '', '' def release_collect_quota_reservation(user_main, leixing, jine, shijidaozhang): """微信打款明确失败/用户取消时回滚收款预占的限额""" from houtai.models import TixianRiTongji today = date.today() shijidaozhang = shijidaozhang or decimal.Decimal('0.00') try: with transaction.atomic(): if leixing == 1: dashou = UserDashou.objects.select_for_update().get(user=user_main) if dashou.last_tixian_date == today and dashou.jinritixian_jine >= jine: dashou.jinritixian_jine -= jine dashou.save(update_fields=['jinritixian_jine']) elif leixing == 2: guanshi = UserGuanshi.objects.select_for_update().get(user=user_main) if guanshi.last_tixian_date == today and guanshi.jinritixian_jine >= jine: guanshi.jinritixian_jine -= jine guanshi.save(update_fields=['jinritixian_jine']) elif leixing == 3: zuzhang = UserZuzhang.objects.select_for_update().get(user=user_main) if zuzhang.jinri_tixian >= jine: zuzhang.jinri_tixian -= jine zuzhang.save(update_fields=['jinri_tixian']) try: stat = TixianRiTongji.objects.select_for_update().get( riqi=today, leixing=leixing, ) stat.total_amount = max( stat.total_amount - shijidaozhang, decimal.Decimal('0.00'), ) stat.save(update_fields=['total_amount']) except TixianRiTongji.DoesNotExist: pass except Exception as e: logger.error( '回滚收款限额预占失败 yonghuid=%s leixing=%s err=%s', user_main.yonghuid, leixing, e, exc_info=True, ) return logger.info( '收款限额预占已回滚 yonghuid=%s leixing=%s jine=%s shijidaozhang=%s', user_main.yonghuid, leixing, jine, shijidaozhang, ) def release_collect_quota_for_record(auto_record): """幂等回滚单笔打款对应的收款预占(查单/回调/取消等多路径共用,防重复扣减)""" if not auto_record.shenhe_danhao: return if not _quota_already_reserved_for_record(auto_record): return try: user_main = UserMain.objects.get(yonghuid=auto_record.yonghuid) except UserMain.DoesNotExist: logger.error('回滚限额预占失败:用户不存在 %s', auto_record.yonghuid) return release_collect_quota_reservation( user_main, auto_record.leixing, auto_record.jine, auto_record.shijidaozhang, ) def _quota_already_reserved_for_record(auto_record): """判断本笔打款是否已在收款预占时写入日统计(避免成功时重复累加)""" if not auto_record.shenhe_danhao: return False from django.db.models import Sum today = date.today() shijidaozhang = auto_record.shijidaozhang or decimal.Decimal('0.00') stat_total = _get_platform_daily_total(auto_record.leixing, today) other_sum = TixianAutoRecord.objects.filter( leixing=auto_record.leixing, zhuangtai__in=[0, 1], create_time__date=today, ).exclude(tixian_id=auto_record.tixian_id).aggregate( total=Sum('shijidaozhang'), )['total'] or decimal.Decimal('0.00') return stat_total >= other_sum + shijidaozhang def apply_transfer_success_quota(auto_record): """ 打款成功时累加每日限额统计(仅自动审核流程 shenhe_danhao 非空)。 - houtai.TixianRiTongji.total_amount += 实际到账 - 用户扩展表个人今日已提 += 申请金额(打手/管事/组长) 须在 transaction.atomic 内、且打款记录 zhuangtai 由 0→1 时调用一次。 """ if not auto_record.shenhe_danhao: return from houtai.models import TixianRiTongji today = date.today() leixing = auto_record.leixing jine = auto_record.jine shijidaozhang = auto_record.shijidaozhang or decimal.Decimal('0.00') try: user_main = UserMain.objects.get(yonghuid=auto_record.yonghuid) except UserMain.DoesNotExist: logger.error('打款成功累加限额失败:用户不存在 %s', auto_record.yonghuid) return platform_stat, _ = TixianRiTongji.objects.select_for_update().get_or_create( riqi=today, leixing=leixing, defaults={'total_amount': decimal.Decimal('0.00')}, ) platform_stat.total_amount += shijidaozhang platform_stat.save(update_fields=['total_amount']) if leixing == 1: dashou = UserDashou.objects.select_for_update().get(user=user_main) if dashou.last_tixian_date != today: dashou.jinritixian_jine = decimal.Decimal('0.00') dashou.last_tixian_date = today dashou.jinritixian_jine += jine dashou.save(update_fields=['jinritixian_jine', 'last_tixian_date']) elif leixing == 2: guanshi = UserGuanshi.objects.select_for_update().get(user=user_main) if guanshi.last_tixian_date != today: guanshi.jinritixian_jine = decimal.Decimal('0.00') guanshi.last_tixian_date = today guanshi.jinritixian_jine += jine guanshi.save(update_fields=['jinritixian_jine', 'last_tixian_date']) elif leixing == 3: zuzhang = UserZuzhang.objects.select_for_update().get(user=user_main) if zuzhang.last_tixian_time and zuzhang.last_tixian_time.date() != today: zuzhang.jinri_tixian = decimal.Decimal('0.00') elif not zuzhang.last_tixian_time: zuzhang.jinri_tixian = decimal.Decimal('0.00') zuzhang.jinri_tixian += jine zuzhang.last_tixian_time = timezone.now() zuzhang.save(update_fields=['jinri_tixian', 'last_tixian_time']) logger.info( '打款成功已累加限额 bill=%s yonghuid=%s leixing=%s jine=%s shijidaozhang=%s ' 'platform_total=%s', auto_record.tixian_id, auto_record.yonghuid, leixing, jine, shijidaozhang, platform_stat.total_amount, ) def refund_balance(user_main, leixing, jine): """审核拒绝时退还可到账金额 shijidaozhang(须在 transaction.atomic 内;手续费已在申请扣款时扣除不退还)""" if leixing == 1: dashou = UserDashou.objects.select_for_update().get(user=user_main) dashou.yue += jine dashou.save() elif leixing == 2: guanshi = UserGuanshi.objects.select_for_update().get(user=user_main) guanshi.yue += jine guanshi.save() elif leixing == 3: zuzhang = UserZuzhang.objects.select_for_update().get(user=user_main) zuzhang.ketixian_jine += jine zuzhang.save() elif leixing == 4: kaoheguan = UserShenheguan.objects.select_for_update().get(user=user_main) kaoheguan.yue += jine kaoheguan.save() elif leixing == 5: dashou = UserDashou.objects.select_for_update().get(user=user_main) dashou.yajin += jine dashou.zhanghaozhuangtai = 1 dashou.save() elif leixing == 6: shangjia = UserShangjia.objects.select_for_update().get(user=user_main) shangjia.yue += jine shangjia.save() else: raise ValueError('无效的提现类型') def deduct_balance(user_main, leixing, jine): """扣减申请金额 jine(必须在 transaction.atomic 内调用)""" if leixing == 1: dashou = UserDashou.objects.select_for_update().get(user=user_main) if dashou.yue < jine: raise ValueError('余额不足') dashou.yue -= jine dashou.save() return dashou.nicheng or '' if leixing == 2: guanshi = UserGuanshi.objects.select_for_update().get(user=user_main) if guanshi.yue < jine: raise ValueError('余额不足') guanshi.yue -= jine guanshi.save() return '管事用户' if leixing == 3: zuzhang = UserZuzhang.objects.select_for_update().get(user=user_main) if zuzhang.ketixian_jine < jine: raise ValueError('可提现金额不足') zuzhang.ketixian_jine -= jine zuzhang.save() return '' if leixing == 4: kaoheguan = UserShenheguan.objects.select_for_update().get(user=user_main) if kaoheguan.yue < jine: raise ValueError('可提现金额不足') kaoheguan.yue -= jine kaoheguan.save() return '考核官' if leixing == 5: dashou = UserDashou.objects.select_for_update().get(user=user_main) if dashou.yajin < jine: raise ValueError('押金余额不足') dashou.yajin -= jine dashou.zhanghaozhuangtai = 0 dashou.save() return dashou.nicheng or '' if leixing == 6: shangjia = UserShangjia.objects.select_for_update().get(user=user_main) if shangjia.yue < jine: raise ValueError('商家余额不足') shangjia.yue -= jine shangjia.save() return shangjia.nicheng or '' raise ValueError('无效的提现类型') def get_balance_response_data(user_main, leixing): data = {} if leixing == 1: try: dashou = UserDashou.objects.get(user=user_main) data['new_yongjin'] = str(dashou.yue.quantize(decimal.Decimal('0.01'))) except UserDashou.DoesNotExist: pass elif leixing == 2: try: guanshi = UserGuanshi.objects.get(user=user_main) data['new_fenyong'] = str(guanshi.yue.quantize(decimal.Decimal('0.01'))) except UserGuanshi.DoesNotExist: pass elif leixing == 3: try: zuzhang = UserZuzhang.objects.get(user=user_main) data['new_zuzhang_ketixian'] = str(zuzhang.ketixian_jine.quantize(decimal.Decimal('0.01'))) except UserZuzhang.DoesNotExist: pass elif leixing == 4: try: kg = UserShenheguan.objects.get(user=user_main) data['new_kaoheguan_yue'] = str(kg.yue.quantize(decimal.Decimal('0.01'))) except UserShenheguan.DoesNotExist: pass return data def has_pending_audit(yonghuid): return TixianShenheJilu.objects.filter( yonghuid=yonghuid, zhuangtai__in=PENDING_AUDIT_STATUSES, ).exists() def sync_audit_and_jilu_status(audit, new_status, **extra_fields): audit.zhuangtai = new_status for k, v in extra_fields.items(): setattr(audit, k, v) audit.save() if audit.tixianjilu_id: update_fields = {'zhuangtai': new_status, 'update_time': timezone.now()} if 'bhliyou' in extra_fields: update_fields['bhliyou'] = extra_fields['bhliyou'] Tixianjilu.objects.filter(id=audit.tixianjilu_id).update(**update_fields) def query_wechat_transfer_bill(out_bill_no): """ 向微信查转账单状态 GET /v3/fund-app/mch-transfer/transfer-bills/out-bill-no/{out_bill_no} 返回 dict;网络/系统异常返回 None;微信无此单返回 {'_not_found': True} """ from utils.wechat_v3 import build_authorization url = ( 'https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/' f'transfer-bills/out-bill-no/{out_bill_no}' ) try: auth = build_authorization('GET', url, '') headers = { 'Authorization': auth, 'Accept': 'application/json', 'User-Agent': 'Mozilla/5.0', } resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: return resp.json() if resp.status_code == 404: return {'_not_found': True} logger.error( '微信查单失败 bill=%s HTTP%s body=%s', out_bill_no, resp.status_code, resp.text[:300], ) return None except (requests.RequestException, OSError, ValueError) as e: logger.error('微信查单异常 bill=%s err=%s', out_bill_no, e, exc_info=True) return None except Exception as e: logger.error('微信查单未知异常 bill=%s err=%s', out_bill_no, e, exc_info=True) return None def _parse_package_info(raw): if not raw: return None if isinstance(raw, dict): return raw try: return json.loads(raw) except (TypeError, ValueError): return None def _apply_platform_accounting(auto_record): """平台收支统计(幂等由 mark_transfer_success 外层保证只调一次)""" from dingdan.utils import update_daily_payout update_daily_payout(auto_record.shijidaozhang) try: szjilu = Szjilu.objects.select_for_update().get(id=1) szjilu.zongsy -= auto_record.shijidaozhang szjilu.zongzc += auto_record.shijidaozhang szjilu.jrzc += auto_record.shijidaozhang szjilu.save() except Szjilu.DoesNotExist: Szjilu.objects.create( id=1, zongsy=-auto_record.shijidaozhang, zongzc=auto_record.shijidaozhang, jrzc=auto_record.shijidaozhang, ) def mark_transfer_success(auto_record, *, wechat_transfer_no=None, with_accounting=True): """ 打款成功落库(tixianqr / callback / 查单对账 共用) select_for_update + zhuangtai==0 才更新并记账,防双写 返回 True=本次新标记成功;False=已处理过 """ with transaction.atomic(): ar = TixianAutoRecord.objects.select_for_update().get(pk=auto_record.pk) if ar.zhuangtai != 0: return False ar.zhuangtai = 1 update_fields = ['zhuangtai', 'update_time'] if wechat_transfer_no: ar.wechat_transfer_no = wechat_transfer_no update_fields.append('wechat_transfer_no') ar.save(update_fields=update_fields) if ar.shenhe_danhao: try: audit = TixianShenheJilu.objects.select_for_update().get( shenhe_danhao=ar.shenhe_danhao, ) if audit.zhuangtai != 2: sync_audit_and_jilu_status(audit, 2) except TixianShenheJilu.DoesNotExist: logger.warning('打款成功但审核记录不存在: %s', ar.shenhe_danhao) if ar.shenhe_danhao and not _quota_already_reserved_for_record(ar): apply_transfer_success_quota(ar) if with_accounting: _apply_platform_accounting(ar) return True def _sync_record_from_wx_query(auto_record, wx_data): """根据微信查单结果修正本地打款记录,返回 (action, payload)""" if wx_data.get('_not_found'): age = timezone.now() - auto_record.create_time if auto_record.zhuangtai == 0 and age < timedelta(minutes=PENDING_QUERY_GRACE_MINUTES): return RECONCILE_PENDING, { 'msg': '有收款处理中,请稍后再试', 'tixian_id': auto_record.tixian_id, } if auto_record.zhuangtai == 0: auto_record.zhuangtai = 2 auto_record.fail_reason = '微信未找到该打款单,请重新发起收款' auto_record.save(update_fields=['zhuangtai', 'fail_reason', 'update_time']) release_collect_quota_for_record(auto_record) return RECONCILE_ALLOW_NEW, {} state = (wx_data.get('state') or '').upper() if state in WX_STATE_SUCCESS: mark_transfer_success( auto_record, wechat_transfer_no=wx_data.get('transfer_bill_no') or wx_data.get('transfer_no'), ) return RECONCILE_COMPLETED, {'msg': '该提现已完成,请勿重复操作'} if state in WX_STATE_WAIT: package = wx_data.get('package_info') or _parse_package_info(auto_record.wechat_package) if not package: return RECONCILE_PENDING, { 'msg': '有收款处理中,请稍后再试', 'tixian_id': auto_record.tixian_id, } if auto_record.zhuangtai == 0: auto_record.wechat_package = json.dumps(package, ensure_ascii=False) auto_record.save(update_fields=['wechat_package', 'update_time']) wx_cfg = settings.WECHAT_PAY_V3_CONFIG return RECONCILE_WAIT_CONFIRM, { 'tixian_id': auto_record.tixian_id, 'package_info': package, 'shouxufei': str(auto_record.shouxufei), 'shijidaozhang': str(auto_record.shijidaozhang), 'current_rate': str(auto_record.feilv), 'mch_id': wx_cfg.get('MCHID', ''), 'shenhe_danhao': auto_record.shenhe_danhao or '', } if state in WX_STATE_CANCELING: return RECONCILE_PENDING, { 'msg': '收款撤销处理中,请稍后再试', 'tixian_id': auto_record.tixian_id, } if state in WX_STATE_FAIL: if auto_record.zhuangtai != 1: auto_record.zhuangtai = 2 auto_record.fail_reason = ( wx_data.get('fail_reason') or wx_data.get('close_reason') or state )[:500] auto_record.save(update_fields=['zhuangtai', 'fail_reason', 'update_time']) release_collect_quota_for_record(auto_record) return RECONCILE_ALLOW_NEW, {} if auto_record.zhuangtai == 0: return RECONCILE_PENDING, { 'msg': f'收款处理中(微信状态:{state or "未知"}),请稍后再试', 'tixian_id': auto_record.tixian_id, } return RECONCILE_ALLOW_NEW, {} def get_auto_record_for_collect(shenhe_danhao): """ 取本笔提现申请应对接微信的那条打款记录: 优先 zhuangtai=0(进行中),否则取最新一条(可能本地误标失败但微信未终态) """ pending = ( TixianAutoRecord.objects.filter(shenhe_danhao=shenhe_danhao, zhuangtai=0) .order_by('-create_time') .first() ) if pending: return pending return ( TixianAutoRecord.objects.filter(shenhe_danhao=shenhe_danhao) .order_by('-create_time') .first() ) def reconcile_shenhe_wechat_bills(shenhe_danhao): """ P0 防双笔:同一 shenhe_danhao 下所有打款记录逐条问微信 任一 SUCCESS / 进行中 → 禁止新建;全部终态失败才 ALLOW_NEW 返回 (action, payload) """ records = list( TixianAutoRecord.objects.filter(shenhe_danhao=shenhe_danhao).order_by('-create_time') ) if any(r.zhuangtai == 1 for r in records): try: audit = TixianShenheJilu.objects.get(shenhe_danhao=shenhe_danhao) if audit.zhuangtai != 2: sync_audit_and_jilu_status(audit, 2) except TixianShenheJilu.DoesNotExist: pass return RECONCILE_COMPLETED, {'msg': '该提现已完成,请勿重复操作'} if not records: return RECONCILE_ALLOW_NEW, {} pending_hit = None for rec in records: if rec.zhuangtai == 1: return RECONCILE_COMPLETED, {'msg': '该提现已完成,请勿重复操作'} wx_data = query_wechat_transfer_bill(rec.tixian_id) if wx_data is None: logger.error( '对账查单不可用 shenhe_danhao=%s bill=%s', shenhe_danhao, rec.tixian_id, ) return RECONCILE_PENDING, { 'msg': '有收款处理中,系统正在核对微信状态,请稍后再试', 'tixian_id': rec.tixian_id, } action, payload = _sync_record_from_wx_query(rec, wx_data) if action == RECONCILE_COMPLETED: return action, payload if action == RECONCILE_WAIT_CONFIRM: return action, payload if action == RECONCILE_PENDING: if pending_hit is None: pending_hit = (action, payload) continue if pending_hit: return pending_hit return RECONCILE_ALLOW_NEW, {} def resolve_collect_context(yonghuid, tixianjilu_id=None, shenhe_danhao='', shenhe_jilu_id=None): """ 从提现记录 ID 进门(P0 收款主路径): Tixianjilu → TixianShenheJilu,校验归属、状态=6 待收款 返回 ({jilu, audit}, err_msg) """ if tixianjilu_id is None and not shenhe_danhao: return None, '缺少提现记录ID' jilu = None if tixianjilu_id is not None: try: jilu = Tixianjilu.objects.get(id=int(tixianjilu_id), yonghuid=yonghuid) except (Tixianjilu.DoesNotExist, TypeError, ValueError): return None, '提现记录不存在或不属于当前用户' if jilu.zhuangtai != 6: return None, f'当前状态不可收款(状态{jilu.zhuangtai}),请等待审核通过' if not jilu.shenhe_jilu_id or not jilu.shenhe_danhao: return None, '提现记录未关联审核单,无法收款' if shenhe_jilu_id is not None: try: if int(shenhe_jilu_id) != jilu.shenhe_jilu_id: return None, '审核记录ID与提现记录不匹配' except (TypeError, ValueError): return None, '审核记录ID格式错误' if shenhe_danhao and shenhe_danhao != jilu.shenhe_danhao: return None, '审核单号与提现记录不匹配' shenhe_danhao = jilu.shenhe_danhao shenhe_jilu_id = jilu.shenhe_jilu_id try: if shenhe_jilu_id: audit = TixianShenheJilu.objects.get( id=int(shenhe_jilu_id), shenhe_danhao=shenhe_danhao, yonghuid=yonghuid, ) else: audit = TixianShenheJilu.objects.get( shenhe_danhao=shenhe_danhao, yonghuid=yonghuid, ) except (TixianShenheJilu.DoesNotExist, TypeError, ValueError): return None, '审核记录不存在' if audit.zhuangtai == 2: return None, '该提现已完成,请勿重复操作' if audit.zhuangtai != 6: return None, f'当前状态不可收款(状态{audit.zhuangtai}),请等待审核通过' if not is_auto_dakuan_audit(audit): return None, '该提现单为手动打款,无法使用自动收款' if jilu is None: try: jilu = Tixianjilu.objects.get( id=audit.tixianjilu_id, yonghuid=yonghuid, ) if audit.tixianjilu_id else None except Tixianjilu.DoesNotExist: jilu = None return {'jilu': jilu, 'audit': audit, 'shenhe_danhao': shenhe_danhao}, '' def handle_post_transfer_failure(tixian_id, shenhe_danhao, err_code='', err_msg=''): """ 发起转账 HTTP 非成功时:先查微信再决定是否标失败(P0,禁止盲目标失败导致双笔) 返回 (action, payload_or_user_msg) """ try: ar = TixianAutoRecord.objects.get(tixian_id=tixian_id) except TixianAutoRecord.DoesNotExist: return RECONCILE_PENDING, '收款处理中,请稍后再试' wx_data = query_wechat_transfer_bill(tixian_id) if wx_data is None: logger.error( '发起转账失败且查单不可用 bill=%s code=%s msg=%s,保持待查单', tixian_id, err_code, err_msg, ) return RECONCILE_PENDING, '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击' action, payload = _sync_record_from_wx_query(ar, wx_data) if action == RECONCILE_ALLOW_NEW: return RECONCILE_ALLOW_NEW, payload if action == RECONCILE_WAIT_CONFIRM: return RECONCILE_WAIT_CONFIRM, payload if action == RECONCILE_COMPLETED: return RECONCILE_COMPLETED, payload return RECONCILE_PENDING, payload.get('msg', '收款处理中,请稍后再试') def get_audit_for_collect(shenhe_danhao, yonghuid, shenhe_jilu_id=None): """ 收款前数据库校验(须在 transaction.atomic 内调用;微信查单在事务外单独做) """ try: audit = TixianShenheJilu.objects.select_for_update().get( shenhe_danhao=shenhe_danhao, yonghuid=yonghuid, ) except TixianShenheJilu.DoesNotExist: return None, '审核记录不存在' if shenhe_jilu_id is not None: try: if int(shenhe_jilu_id) != audit.id: return None, '审核记录ID不匹配' except (TypeError, ValueError): return None, '审核记录ID格式错误' if audit.zhuangtai == 2: return None, '该提现已完成,请勿重复操作' if audit.zhuangtai != 6: return None, f'当前状态不可收款(状态{audit.zhuangtai}),请等待审核通过' if not is_auto_dakuan_audit(audit): return None, '该提现单为手动打款,无法使用自动收款' return audit, '' def create_audit_application(user_main, leixing, jine): """ 提交审核申请:校验 → 扣款 → 写审核表 + 提现记录表 不创建打款记录表(TixianAutoRecord 仅在收款时创建) """ yonghuid = user_main.yonghuid if has_pending_audit(yonghuid): raise ValueError('账户余额不足或您有进行中的提现申请,请等待处理完成或10分钟后后再提交') ok, msg, extra = validate_withdraw_eligibility(user_main, leixing, jine) if not ok: raise ValueError(msg) feilv = get_tixian_feilv(leixing) shouxufei, shijidaozhang = calc_fee_amounts(jine, feilv) if shijidaozhang <= 0: raise ValueError('提现金额过低,扣除手续费后无实际到账') shenhe_danhao = generate_shenhe_danhao() nicheng = extra.get('nicheng', '') with transaction.atomic(): nicheng = deduct_balance(user_main, leixing, jine) or nicheng audit_kwargs = { 'shenhe_danhao': shenhe_danhao, 'yonghuid': yonghuid, 'leixing': leixing, 'zhuangtai': 1, 'shenqing_jine': jine, 'shouxufei': shouxufei, 'shijidaozhang': shijidaozhang, 'feilv': feilv, } if has_dakuan_mode_field(): audit_kwargs['dakuan_mode'] = DAKUAN_MODE_AUTO audit = TixianShenheJilu.objects.create(**audit_kwargs) tixian_record = Tixianjilu.objects.create( yonghuid=yonghuid, avatar=user_main.avatar or '', phone=user_main.phone or '', nicheng=nicheng, leixing=leixing, zhifu=user_main.zhifu or '', skzhanghao=user_main.skzhanghao or '', jine=shijidaozhang, zhuangtai=1, fangshi=1, shenheid=None, bhliyou='', shenhe_jilu_id=audit.id, shenhe_danhao=shenhe_danhao, ) audit.tixianjilu_id = tixian_record.id audit.save(update_fields=['tixianjilu_id']) response_data = get_balance_response_data(user_main, leixing) response_data.update({ 'shenhe_danhao': shenhe_danhao, 'shenhe_jilu_id': audit.id, 'tixianjilu_id': tixian_record.id, 'shouxufei': str(shouxufei), 'shijidaozhang': str(shijidaozhang), 'current_rate': str(feilv.quantize(decimal.Decimal('0.0001'))), 'leixing': leixing, 'zhuangtai': 1, }) return response_data