1533 lines
61 KiB
Python
1533 lines
61 KiB
Python
# yonghu/tixian_shenhe_services.py
|
||
"""
|
||
自动提现审核流程业务服务层(仅服务于 zddksh 申请接口 与 tixiansq 收款接口)
|
||
|
||
流程:
|
||
① zddksh 提交审核:完整资格校验 → 扣款 → 写审核表+提现记录表(状态=1)
|
||
允许多笔并行申请;单笔实际到账(shijidaozhang)不得超过200元
|
||
② 后台审核通过:校验到账≤200 → 状态 6(待收款)
|
||
③ tixiansq 收款:严格资格+限额 → 到账>200则关单退款(不退手续费),不调微信
|
||
微信官方 FAIL 关单不退款;SUCCESS 落库;进行中禁止新建打款单
|
||
④ 打款成功(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 orders.models import Order, MerchantOrderExt, Penalty
|
||
from jituan.services.wechat_pay import club_id_for_tixian_yonghuid
|
||
from jituan.services.withdraw_config import (
|
||
get_or_create_platform_daily_stat,
|
||
get_personal_daily_quota,
|
||
get_platform_daily_limit,
|
||
get_platform_daily_total,
|
||
get_tixian_feilv as _get_tixian_feilv_for_club,
|
||
)
|
||
|
||
from utils.fadan_utils import check_fadan_qiangdan_eligible
|
||
|
||
from products.models import Huiyuangoumai
|
||
from backend.models import WithdrawalDailyStats
|
||
|
||
from utils.wechat_v3 import build_authorization
|
||
|
||
from .models import (
|
||
TixianAutoRecord,
|
||
TixianShenheJilu,
|
||
Tixianjilu,
|
||
UserDashou,
|
||
UserGuanshi,
|
||
UserShenheguan,
|
||
UserShangjia,
|
||
UserZuzhang,
|
||
)
|
||
from users.business_models import User
|
||
|
||
from orders.utils import update_daily_payout
|
||
|
||
from django.db.models import Sum
|
||
|
||
from gvsdsdk.fluent import db, func, FQ
|
||
|
||
logger = logging.getLogger('yonghu.tixian_shenhe')
|
||
|
||
ORDER_STATUS_COMPLETED = 3
|
||
ORDER_STATUS_REFUNDED = 5
|
||
|
||
# 打手提现申请:仅 jiedan_dashou_id=该用户 且 zhuangtai=2(进行中) 才拦截
|
||
# 1已下单 3已完成 4退款审核中 5已退款 6拒绝退款 7指定中 8结算中 —— 均不拦
|
||
DASHOU_WITHDRAW_APPLY_BLOCKING_ORDER_STATUSES = (2,)
|
||
|
||
LEIXING_RATE_KEY = {
|
||
1: '5',
|
||
2: '6',
|
||
3: '8',
|
||
4: '9',
|
||
5: '11',
|
||
6: '10',
|
||
}
|
||
|
||
def _club_id_for_user(user_main):
|
||
return club_id_for_tixian_yonghuid(user_main.UserUID)
|
||
|
||
|
||
def get_tixian_feilv(leixing, yonghuid=None, club_id=None):
|
||
"""按用户所属俱乐部读取提现手续费率。"""
|
||
if club_id is None:
|
||
if not yonghuid:
|
||
raise ValueError('缺少用户或俱乐部')
|
||
club_id = club_id_for_tixian_yonghuid(yonghuid)
|
||
return _get_tixian_feilv_for_club(club_id, leixing)
|
||
|
||
|
||
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'
|
||
RECONCILE_AUDIT_CLOSED = 'audit_closed'
|
||
|
||
# 单笔实际到账上限(仅收款时校验)
|
||
MAX_SINGLE_COLLECT_AMOUNT = decimal.Decimal('200')
|
||
|
||
# tixianqr 查单时:无 package 且微信 404 超过该秒数才提示处理中
|
||
PENDING_QUERY_GRACE_SECONDS = 20
|
||
|
||
|
||
def _wait_confirm_payload(auto_record, package):
|
||
from jituan.services.wechat_pay import get_wechat_v3_config, club_id_for_tixian_yonghuid
|
||
wx_cfg = get_wechat_v3_config(club_id_for_tixian_yonghuid(auto_record.yonghuid))
|
||
return {
|
||
'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 '',
|
||
}
|
||
|
||
|
||
def _maybe_wait_confirm_from_local(auto_record):
|
||
"""查单不可用时:若本地已有 package,直接让用户调起微信确认收款。"""
|
||
if auto_record.zhuangtai != 0:
|
||
return None
|
||
package = _parse_package_info(auto_record.wechat_package)
|
||
if not package:
|
||
return None
|
||
return RECONCILE_WAIT_CONFIRM, _wait_confirm_payload(auto_record, package)
|
||
|
||
|
||
def _mark_transfer_attempt_failed(auto_record, fail_reason, *, close_audit=False):
|
||
"""
|
||
单次打款尝试失败:标记 TixianAutoRecord,释放限额。
|
||
默认不关审核单,用户可在同一笔已审核通过的单上再次点「收款」。
|
||
"""
|
||
reason = (fail_reason or '打款失败')[:500]
|
||
_close_auto_record_failed(auto_record, reason)
|
||
if not close_audit or not auto_record.shenhe_danhao:
|
||
return
|
||
try:
|
||
audit = TixianShenheJilu.query.get(shenhe_danhao=auto_record.shenhe_danhao)
|
||
with transaction.atomic():
|
||
close_audit_wechat_failed(audit, None, reason)
|
||
except TixianShenheJilu.DoesNotExist:
|
||
logger.warning('打款失败但审核记录不存在: %s', auto_record.shenhe_danhao)
|
||
|
||
|
||
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.query.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.query.filter(shenhe_danhao=danhao).exists():
|
||
return danhao
|
||
raise RuntimeError('生成审核单号失败,请重试')
|
||
|
||
|
||
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):
|
||
"""
|
||
打手至少持有一个未过期会员(含体验,用于接单等场景)
|
||
返回 (True, '') 或 (False, 原因)
|
||
"""
|
||
|
||
records = Huiyuangoumai.query.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 check_dashou_has_formal_huiyuan_for_withdraw(yonghuid):
|
||
"""
|
||
打手佣金提现/收款:必须持有未过期的正式会员。
|
||
体验会员期内一律拒绝;体验到期后须购买正式会员。
|
||
"""
|
||
from jituan.services.member_recharge import (
|
||
dashou_has_active_formal_huiyuan,
|
||
dashou_in_active_trial_period,
|
||
)
|
||
|
||
if dashou_has_active_formal_huiyuan(yonghuid):
|
||
return True, ''
|
||
if dashou_in_active_trial_period(yonghuid):
|
||
return False, '体验会员不可提现佣金,请购买正式会员'
|
||
if not Huiyuangoumai.query.filter(yonghu_id=yonghuid).exists():
|
||
return False, '您暂无会员记录,无法提现'
|
||
return False, '您的会员已全部过期,请购买正式会员后提现'
|
||
|
||
|
||
TRIAL_COMMISSION_WITHDRAW_MSG = (
|
||
'体验会员不可提现佣金,请购买正式会员'
|
||
)
|
||
|
||
|
||
def check_dashou_trial_blocks_commission_withdraw(yonghuid):
|
||
"""打手佣金提现/收款:体验会员期内禁止。"""
|
||
from jituan.services.member_recharge import dashou_in_active_trial_period
|
||
if dashou_in_active_trial_period(yonghuid):
|
||
return False, TRIAL_COMMISSION_WITHDRAW_MSG
|
||
return True, ''
|
||
|
||
|
||
def _dashou_has_unfinished_orders(yonghuid):
|
||
return Order.query.filter(
|
||
PlayerID=yonghuid,
|
||
).exclude(
|
||
Status__in=[ORDER_STATUS_COMPLETED, ORDER_STATUS_REFUNDED],
|
||
).exists()
|
||
|
||
|
||
def _dashou_has_orders_blocking_withdraw_apply(yonghuid):
|
||
"""打手提现申请:仅拦该用户接单的进行中(2)订单。"""
|
||
return Order.query.filter(
|
||
PlayerID=yonghuid,
|
||
Status__in=DASHOU_WITHDRAW_APPLY_BLOCKING_ORDER_STATUSES,
|
||
).exists()
|
||
|
||
|
||
def _dashou_has_open_penalties(yonghuid):
|
||
return Penalty.query.filter(
|
||
PenalizedUserID=yonghuid,
|
||
).exclude(
|
||
Status__in=[2, 4],
|
||
).exists()
|
||
|
||
|
||
def validate_payment_info_for_withdraw(user_main):
|
||
"""手动提现:须已绑定手机号,且收款账号与收款码至少一项。自动提现不走此函数。"""
|
||
phone = (getattr(user_main, 'Phone', None) or '').strip()
|
||
account = (getattr(user_main, 'PaymentAccount', None) or '').strip()
|
||
qr = (getattr(user_main, 'PaymentQRCode', None) or '').strip()
|
||
if not phone:
|
||
return False, '请先完善收款手机号后再提现'
|
||
if not account and not qr:
|
||
return False, '请先设置收款账号或上传收款码后再提现'
|
||
return True, ''
|
||
|
||
|
||
def get_auto_withdraw_openid(user_main):
|
||
"""自动提现打款用 openid:兼容 user_wx_openid / User.OpenID / UserName(openid)。"""
|
||
from jituan.services.club_user import get_wx_openid_for_user
|
||
club_id = club_id_for_tixian_yonghuid(user_main.UserUID)
|
||
return (get_wx_openid_for_user(user_main, club_id) or '').strip()
|
||
|
||
|
||
def validate_auto_withdraw_prerequisites(user_main):
|
||
"""
|
||
自动提现(zddksh / tixiansq):微信企业付款到用户 OpenID。
|
||
不要求手机号、收款码、收款账号;须能解析到微信 OpenID。
|
||
"""
|
||
if not get_auto_withdraw_openid(user_main):
|
||
return False, '请先使用微信登录后再申请自动提现'
|
||
return True, ''
|
||
|
||
|
||
def validate_withdraw_eligibility(user_main, leixing, jine):
|
||
"""
|
||
提现资格校验(打手/管事/押金等业务条件;不校收款码/手机号)。
|
||
手动提现另在 TixianShenqingView 校验收款信息;自动提现另校 OpenID。
|
||
"""
|
||
yonghuid = user_main.UserUID
|
||
|
||
fadan_ok, fadan_msg = check_fadan_qiangdan_eligible(yonghuid, 2)
|
||
if not fadan_ok:
|
||
return False, fadan_msg, {}
|
||
|
||
if leixing == 1:
|
||
try:
|
||
dashou = UserDashou.query.get(user=user_main)
|
||
except UserDashou.DoesNotExist:
|
||
return False, '您不是打手身份,无法提现佣金', {}
|
||
if dashou.zhanghaozhuangtai != 1:
|
||
return False, '打手账号已被封禁,无法提现', {}
|
||
if dashou.jifen < 10:
|
||
return False, '积分不足10分,请补充积分后提现', {}
|
||
huiyuan_ok, huiyuan_msg = check_dashou_has_formal_huiyuan_for_withdraw(yonghuid)
|
||
if not huiyuan_ok:
|
||
return False, huiyuan_msg, {}
|
||
trial_ok, trial_msg = check_dashou_trial_blocks_commission_withdraw(yonghuid)
|
||
if not trial_ok:
|
||
return False, trial_msg, {}
|
||
if _dashou_has_orders_blocking_withdraw_apply(yonghuid):
|
||
return False, '您还有未完成或未退款的订单,请先处理完毕再提现', {}
|
||
if _dashou_has_open_penalties(yonghuid):
|
||
return False, '您有未处理完毕的自身罚单(待缴纳/申诉中),请先处理', {}
|
||
if dashou.yue < jine:
|
||
return False, f'余额不足,当前余额: {dashou.yue}', {}
|
||
return True, '', {'nicheng': dashou.nicheng or ''}
|
||
|
||
if leixing == 2:
|
||
try:
|
||
guanshi = UserGuanshi.query.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.query.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.query.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.query.get(user=user_main)
|
||
except UserDashou.DoesNotExist:
|
||
return False, '您不是打手身份,无法提取押金', {}
|
||
if dashou.zhanghaozhuangtai != 1:
|
||
return False, '打手账号已禁用,无法提取押金', {}
|
||
if dashou.jifen < 10:
|
||
return False, '积分不足10分,请补充积分后提取押金', {}
|
||
huiyuan_ok, huiyuan_msg = check_dashou_has_formal_huiyuan_for_withdraw(yonghuid)
|
||
if not huiyuan_ok:
|
||
return False, huiyuan_msg, {}
|
||
if dashou.yajin < jine:
|
||
return False, f'押金余额不足,当前押金: {dashou.yajin}', {}
|
||
if _dashou_has_orders_blocking_withdraw_apply(yonghuid):
|
||
return False, '您还有未完成或未退款的订单,请先处理完毕再提取押金', {}
|
||
if _dashou_has_open_penalties(yonghuid):
|
||
return False, '您有未处理完毕的自身罚单(待缴纳/申诉中),请先处理', {}
|
||
return True, '', {'nicheng': dashou.nicheng or ''}
|
||
|
||
if leixing == 6:
|
||
try:
|
||
shangjia = UserShangjia.query.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 = MerchantOrderExt.query.filter(
|
||
MerchantID=yonghuid,
|
||
).exclude(Order__Status__in=[ORDER_STATUS_COMPLETED, ORDER_STATUS_REFUNDED]).exists()
|
||
if unfinished_orders:
|
||
return False, '您还有未完成或未退款的订单,请先处理完毕再提现余额', {}
|
||
self_fadan = Penalty.query.filter(PenalizedUserID=yonghuid).exclude(Status__in=[2, 4]).exists()
|
||
if self_fadan:
|
||
return False, '您有未处理完毕的自身罚单(待缴纳/申诉中),请先处理', {}
|
||
applied_fadan = Penalty.query.filter(ApplicantID=yonghuid).exclude(Status__in=[2, 4]).exists()
|
||
if applied_fadan:
|
||
return False, '您发起的对其他用户的处罚尚未处理完毕,请等待完结', {}
|
||
return True, '', {'nicheng': shangjia.nicheng or ''}
|
||
|
||
return False, '无效的提现类型', {}
|
||
|
||
|
||
def check_shijidaozhang_within_limit(shijidaozhang):
|
||
"""校验单笔实际到账不超过上限(申请/审核/收款共用)"""
|
||
if shijidaozhang > MAX_SINGLE_COLLECT_AMOUNT:
|
||
return False, f'单笔实际到账不能超过{MAX_SINGLE_COLLECT_AMOUNT}元,请调整提现金额后重新申请'
|
||
return True, ''
|
||
|
||
|
||
def validate_collect_amount(audit):
|
||
"""收款时校验单笔实际到账上限(超限将关单退款)"""
|
||
return check_shijidaozhang_within_limit(audit.shijidaozhang)
|
||
|
||
|
||
def validate_collect_eligibility(user_main, leixing):
|
||
"""
|
||
自动提现收款前严格校验(申请时已扣款,不再校余额)。
|
||
打手佣金/押金:罚单、封禁、接单中、积分、会员、订单等;
|
||
不要求手机号/收款码(微信打款到 OpenID)。
|
||
"""
|
||
wx_ok, wx_msg = validate_auto_withdraw_prerequisites(user_main)
|
||
if not wx_ok:
|
||
return False, wx_msg
|
||
|
||
yonghuid = user_main.UserUID
|
||
|
||
fadan_ok, fadan_msg = check_fadan_qiangdan_eligible(yonghuid, 2)
|
||
if not fadan_ok:
|
||
return False, fadan_msg
|
||
|
||
if leixing == 1:
|
||
try:
|
||
dashou = UserDashou.query.get(user=user_main)
|
||
except UserDashou.DoesNotExist:
|
||
return False, '您不是打手身份,无法收款'
|
||
if dashou.zhanghaozhuangtai != 1:
|
||
return False, '打手账号已被封禁,无法收款'
|
||
if dashou.zhuangtai != 1:
|
||
return False, '您有订单进行中,请完成后再收款'
|
||
if dashou.jifen < 10:
|
||
return False, '积分不足10分,无法收款'
|
||
huiyuan_ok, huiyuan_msg = check_dashou_has_formal_huiyuan_for_withdraw(yonghuid)
|
||
if not huiyuan_ok:
|
||
return False, huiyuan_msg
|
||
trial_ok, trial_msg = check_dashou_trial_blocks_commission_withdraw(yonghuid)
|
||
if not trial_ok:
|
||
return False, trial_msg
|
||
if _dashou_has_unfinished_orders(yonghuid):
|
||
return False, '您还有未完成或未退款的订单,无法收款'
|
||
if _dashou_has_open_penalties(yonghuid):
|
||
return False, '您有未处理完毕的自身罚单,无法收款'
|
||
return True, ''
|
||
|
||
if leixing == 5:
|
||
try:
|
||
dashou = UserDashou.query.get(user=user_main)
|
||
except UserDashou.DoesNotExist:
|
||
return False, '您不是打手身份,无法收款'
|
||
if dashou.zhanghaozhuangtai != 1:
|
||
return False, '打手账号已禁用,无法收款'
|
||
if dashou.zhuangtai != 1:
|
||
return False, '您有订单进行中,请完成后再收款'
|
||
if dashou.jifen < 10:
|
||
return False, '积分不足10分,无法收款'
|
||
huiyuan_ok, huiyuan_msg = check_dashou_has_formal_huiyuan_for_withdraw(yonghuid)
|
||
if not huiyuan_ok:
|
||
return False, huiyuan_msg
|
||
if _dashou_has_unfinished_orders(yonghuid):
|
||
return False, '您还有未完成或未退款的订单,无法收款'
|
||
if _dashou_has_open_penalties(yonghuid):
|
||
return False, '您有未处理完毕的自身罚单,无法收款'
|
||
return True, ''
|
||
|
||
if leixing == 2:
|
||
try:
|
||
guanshi = UserGuanshi.query.get(user=user_main)
|
||
except UserGuanshi.DoesNotExist:
|
||
return False, '您不是管事身份,无法收款'
|
||
if guanshi.zhuangtai != 1:
|
||
return False, '管事账号已被封禁,无法收款'
|
||
return True, ''
|
||
|
||
if leixing == 6:
|
||
try:
|
||
shangjia = UserShangjia.query.get(user=user_main)
|
||
except UserShangjia.DoesNotExist:
|
||
return False, '您不是商家身份,无法收款'
|
||
if shangjia.zhuangtai != 1:
|
||
return False, '商家账号已被禁用,无法收款'
|
||
unfinished_orders = MerchantOrderExt.query.filter(
|
||
MerchantID=yonghuid,
|
||
).exclude(Order__Status__in=[ORDER_STATUS_COMPLETED, ORDER_STATUS_REFUNDED]).exists()
|
||
if unfinished_orders:
|
||
return False, '您还有未完成或未退款的订单,无法收款'
|
||
self_fadan = Penalty.query.filter(PenalizedUserID=yonghuid).exclude(Status__in=[2, 4]).exists()
|
||
if self_fadan:
|
||
return False, '您有未处理完毕的自身罚单,无法收款'
|
||
applied_fadan = Penalty.query.filter(ApplicantID=yonghuid).exclude(Status__in=[2, 4]).exists()
|
||
if applied_fadan:
|
||
return False, '您发起的处罚尚未处理完毕,无法收款'
|
||
return True, ''
|
||
|
||
if leixing == 3:
|
||
try:
|
||
zuzhang = UserZuzhang.query.get(user=user_main)
|
||
except UserZuzhang.DoesNotExist:
|
||
return False, '您不是组长身份,无法收款'
|
||
if zuzhang.zhuangtai != 1:
|
||
return False, '组长账号已被封禁,无法收款'
|
||
return True, ''
|
||
|
||
if leixing == 4:
|
||
try:
|
||
kaoheguan = UserShenheguan.query.get(user=user_main)
|
||
except UserShenheguan.DoesNotExist:
|
||
return False, '您不是考核官身份,无法收款'
|
||
if kaoheguan.zhuangtai != 1:
|
||
return False, '考核官账号已被封禁,无法收款'
|
||
return True, ''
|
||
|
||
return False, '无效的提现类型'
|
||
|
||
|
||
def _get_personal_daily_quota(club_id, leixing, profile):
|
||
return get_personal_daily_quota(club_id, leixing, profile)
|
||
|
||
|
||
def _get_active_auto_record(shenhe_danhao):
|
||
if not shenhe_danhao:
|
||
return None
|
||
return (
|
||
TixianAutoRecord.query.filter(
|
||
shenhe_danhao=shenhe_danhao,
|
||
zhuangtai__in=[0, 1],
|
||
)
|
||
.order_by('-CreateTime')
|
||
.first()
|
||
)
|
||
|
||
|
||
def _get_platform_daily_total(club_id, leixing, today=None):
|
||
return get_platform_daily_total(club_id, leixing, today)
|
||
|
||
|
||
def _calc_platform_projected(club_id, leixing, shijidaozhang, shenhe_danhao=''):
|
||
daily_used = _get_platform_daily_total(club_id, 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(club_id, leixing):
|
||
limit = get_platform_daily_limit(club_id, leixing)
|
||
if limit <= 0:
|
||
logger.warning(
|
||
'平台每日总限额为0 club_id=%s leixing=%s',
|
||
club_id, leixing,
|
||
)
|
||
return limit
|
||
|
||
|
||
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.query.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.query.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.query.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(_club_id_for_user(user_main), 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(club_id, leixing, shijidaozhang, shenhe_danhao=''):
|
||
"""平台每日总限额只读校验:今日已用 + 本笔到账 > 总限额 则拒绝"""
|
||
platform_limit = _get_platform_daily_limit(club_id, 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(
|
||
club_id, leixing, shijidaozhang, shenhe_danhao,
|
||
)
|
||
if projected > platform_limit:
|
||
logger.warning(
|
||
'平台总限额拦截 club_id=%s leixing=%s shenhe_danhao=%s daily_used=%s additional=%s '
|
||
'projected=%s limit=%s',
|
||
club_id, 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
|
||
club_id = _club_id_for_user(user_main)
|
||
return _check_platform_quota_readonly(club_id, leixing, shijidaozhang, shenhe_danhao)
|
||
|
||
|
||
def reserve_collect_quota_limits(user_main, leixing, jine, shijidaozhang, shenhe_danhao=''):
|
||
"""
|
||
收款前原子预占每日限额(必须在 transaction.atomic 内调用)。
|
||
锁定 WithdrawalDailyStats + 用户行,校验通过后立即写入统计,防止高并发超额。
|
||
本审核单已有进行中/成功打款记录则视为已预占,不重复加计。
|
||
返回 (True, '', '') 或 (False, msg, 'personal'|'platform')
|
||
"""
|
||
|
||
today = date.today()
|
||
shijidaozhang = shijidaozhang or decimal.Decimal('0.00')
|
||
club_id = _club_id_for_user(user_main)
|
||
|
||
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(club_id, leixing, personal_profile)
|
||
if personal_today + jine > personal_quota:
|
||
return (
|
||
False,
|
||
f'今日提现已达个人限额,今日已提{personal_today}元,本次申请{jine}元,限额{personal_quota}元',
|
||
'personal',
|
||
)
|
||
|
||
platform_stat = get_or_create_platform_daily_stat(club_id, leixing, today)
|
||
platform_limit = _get_platform_daily_limit(club_id, 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.UserUID, leixing, shenhe_danhao, jine, shijidaozhang, platform_stat.total_amount,
|
||
)
|
||
return True, '', ''
|
||
|
||
|
||
def release_collect_quota_reservation(user_main, leixing, jine, shijidaozhang):
|
||
"""微信打款明确失败/用户取消时回滚收款预占的限额"""
|
||
today = date.today()
|
||
shijidaozhang = shijidaozhang or decimal.Decimal('0.00')
|
||
club_id = _club_id_for_user(user_main)
|
||
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 = WithdrawalDailyStats.objects.select_for_update().get(
|
||
club_id=club_id, Date=today, WithdrawalType=leixing,
|
||
)
|
||
stat.total_amount = max(
|
||
stat.total_amount - shijidaozhang,
|
||
decimal.Decimal('0.00'),
|
||
)
|
||
stat.save(update_fields=['total_amount'])
|
||
except WithdrawalDailyStats.DoesNotExist:
|
||
pass
|
||
except Exception as e:
|
||
logger.error(
|
||
'回滚收款限额预占失败 yonghuid=%s leixing=%s err=%s',
|
||
user_main.UserUID, leixing, e, exc_info=True,
|
||
)
|
||
return
|
||
|
||
logger.info(
|
||
'收款限额预占已回滚 yonghuid=%s leixing=%s jine=%s shijidaozhang=%s',
|
||
user_main.UserUID, 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 = User.query.get(UserUID=auto_record.yonghuid)
|
||
except User.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
|
||
|
||
today = date.today()
|
||
shijidaozhang = auto_record.shijidaozhang or decimal.Decimal('0.00')
|
||
club_id = club_id_for_tixian_yonghuid(auto_record.yonghuid)
|
||
stat_total = _get_platform_daily_total(club_id, auto_record.leixing, today)
|
||
other_sum = TixianAutoRecord.query.filter(
|
||
club_id=club_id,
|
||
leixing=auto_record.leixing,
|
||
zhuangtai__in=[0, 1],
|
||
CreateTime__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.WithdrawalDailyStats.total_amount += 实际到账
|
||
- 用户扩展表个人今日已提 += 申请金额(打手/管事/组长)
|
||
须在 transaction.atomic 内、且打款记录 zhuangtai 由 0→1 时调用一次。
|
||
"""
|
||
if not auto_record.shenhe_danhao:
|
||
return
|
||
today = date.today()
|
||
leixing = auto_record.leixing
|
||
jine = auto_record.jine
|
||
shijidaozhang = auto_record.shijidaozhang or decimal.Decimal('0.00')
|
||
|
||
try:
|
||
user_main = User.query.get(UserUID=auto_record.yonghuid)
|
||
except User.DoesNotExist:
|
||
logger.error('打款成功累加限额失败:用户不存在 %s', auto_record.yonghuid)
|
||
return
|
||
|
||
club_id = _club_id_for_user(user_main)
|
||
platform_stat = get_or_create_platform_daily_stat(club_id, leixing, today)
|
||
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.save(update_fields=['yajin'])
|
||
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.save(update_fields=['yajin'])
|
||
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.query.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.query.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.query.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.query.get(user=user_main)
|
||
data['new_kaoheguan_yue'] = str(kg.yue.quantize(decimal.Decimal('0.01')))
|
||
except UserShenheguan.DoesNotExist:
|
||
pass
|
||
return data
|
||
|
||
|
||
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, 'UpdateTime': timezone.now()}
|
||
if 'bhliyou' in extra_fields:
|
||
update_fields['bhliyou'] = extra_fields['bhliyou']
|
||
Tixianjilu.query.filter(id=audit.tixianjilu_id).update(**update_fields)
|
||
|
||
|
||
def _close_auto_record_failed(auto_record, fail_reason):
|
||
"""标记打款记录失败并释放收款预占限额"""
|
||
if auto_record is None or auto_record.zhuangtai == 1:
|
||
return
|
||
reason = (fail_reason or '打款失败')[:500]
|
||
auto_record.zhuangtai = 2
|
||
auto_record.fail_reason = reason
|
||
auto_record.save(update_fields=['zhuangtai', 'fail_reason', 'UpdateTime'])
|
||
release_collect_quota_for_record(auto_record)
|
||
|
||
|
||
def close_audit_wechat_failed(audit, auto_record=None, fail_reason='微信转账失败'):
|
||
"""
|
||
微信官方终态 FAIL/CANCELLED:审核单关闭(3),不退款(申请时已扣款,手续费不退)。
|
||
须在 transaction.atomic 内调用。
|
||
"""
|
||
_close_auto_record_failed(auto_record, fail_reason)
|
||
|
||
audit = TixianShenheJilu.objects.select_for_update().get(pk=audit.pk)
|
||
if audit.zhuangtai in (2, 3, 5):
|
||
return False
|
||
|
||
reason = (fail_reason or '微信转账失败')[:500]
|
||
sync_audit_and_jilu_status(
|
||
audit, 3,
|
||
bhliyou=reason,
|
||
fail_reason=reason,
|
||
tixian_auto_id=None,
|
||
)
|
||
logger.warning(
|
||
'微信失败关单(不退款) yonghuid=%s shenhe_danhao=%s reason=%s',
|
||
audit.yonghuid, audit.shenhe_danhao, reason,
|
||
)
|
||
return True
|
||
|
||
|
||
def close_audit_over_limit_refund(audit, auto_record=None, fail_reason='单笔实际到账超过限额'):
|
||
"""
|
||
到账超过200元等:审核单关闭(3) + 退还可到账金额 shijidaozhang(手续费不退)。
|
||
须在 transaction.atomic 内调用。
|
||
"""
|
||
reason = (fail_reason or '单笔实际到账超过限额')[:500]
|
||
_close_auto_record_failed(auto_record, reason)
|
||
|
||
audit = TixianShenheJilu.objects.select_for_update().get(pk=audit.pk)
|
||
if audit.zhuangtai in (2, 3, 5):
|
||
return False
|
||
|
||
user_main = User.objects.select_for_update().get(UserUID=audit.yonghuid)
|
||
refund_balance(user_main, audit.leixing, audit.shijidaozhang)
|
||
sync_audit_and_jilu_status(
|
||
audit, 3,
|
||
bhliyou=reason,
|
||
fail_reason=reason,
|
||
tixian_auto_id=None,
|
||
)
|
||
logger.warning(
|
||
'超限关单并退款 yonghuid=%s shenhe_danhao=%s reason=%s',
|
||
audit.yonghuid, audit.shenhe_danhao, reason,
|
||
)
|
||
return True
|
||
|
||
|
||
def query_wechat_transfer_bill(out_bill_no, club_id=None):
|
||
"""
|
||
向微信查转账单状态
|
||
GET /v3/fund-app/mch-transfer/transfer-bills/out-bill-no/{out_bill_no}
|
||
返回 dict;网络/系统异常返回 None;微信无此单返回 {'_not_found': True}
|
||
"""
|
||
from jituan.services.wechat_pay import club_id_for_tixian_yonghuid
|
||
|
||
if not club_id:
|
||
try:
|
||
rec = TixianAutoRecord.query.filter(tixian_id=out_bill_no).first()
|
||
if rec:
|
||
club_id = club_id_for_tixian_yonghuid(rec.yonghuid)
|
||
except Exception:
|
||
pass
|
||
|
||
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, '', club_id=club_id)
|
||
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 jituan.services.szjilu_accounting import apply_szjilu_expense
|
||
from jituan.services.wechat_pay import club_id_for_tixian_yonghuid
|
||
payout_club = club_id_for_tixian_yonghuid(auto_record.yonghuid)
|
||
update_daily_payout(auto_record.shijidaozhang, payout_club)
|
||
apply_szjilu_expense(auto_record.shijidaozhang, payout_club)
|
||
|
||
|
||
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', 'UpdateTime']
|
||
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'):
|
||
if auto_record.zhuangtai == 0 and not _parse_package_info(auto_record.wechat_package):
|
||
_mark_transfer_attempt_failed(
|
||
auto_record, '微信未找到该打款单,请重新发起收款', close_audit=False,
|
||
)
|
||
return RECONCILE_ALLOW_NEW, {}
|
||
age = timezone.now() - auto_record.CreateTime
|
||
if auto_record.zhuangtai == 0 and age < timedelta(seconds=PENDING_QUERY_GRACE_SECONDS):
|
||
local = _maybe_wait_confirm_from_local(auto_record)
|
||
if local:
|
||
return local
|
||
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', 'UpdateTime'])
|
||
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:
|
||
local = _maybe_wait_confirm_from_local(auto_record)
|
||
if local:
|
||
return local
|
||
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', 'UpdateTime'])
|
||
return RECONCILE_WAIT_CONFIRM, _wait_confirm_payload(auto_record, package)
|
||
|
||
if state in WX_STATE_CANCELING:
|
||
return RECONCILE_PENDING, {
|
||
'msg': '收款撤销处理中,请稍后再试',
|
||
'tixian_id': auto_record.tixian_id,
|
||
}
|
||
|
||
if state in WX_STATE_FAIL:
|
||
fail_reason = (
|
||
wx_data.get('fail_reason') or wx_data.get('close_reason') or state
|
||
)[:500]
|
||
if auto_record.zhuangtai != 1:
|
||
_mark_transfer_attempt_failed(auto_record, fail_reason, close_audit=False)
|
||
# 审核单保持待收款(6),允许用户再次发起收款(新 out_bill_no,防双笔由 reconcile 保证)
|
||
return RECONCILE_ALLOW_NEW, {}
|
||
|
||
if auto_record.zhuangtai == 0:
|
||
local = _maybe_wait_confirm_from_local(auto_record)
|
||
if local:
|
||
return local
|
||
return RECONCILE_PENDING, {
|
||
'msg': f'收款处理中,请稍后再试',
|
||
'tixian_id': auto_record.tixian_id,
|
||
}
|
||
return RECONCILE_ALLOW_NEW, {}
|
||
|
||
|
||
def get_auto_record_for_collect(shenhe_danhao):
|
||
"""
|
||
取本笔提现申请应对接微信的那条打款记录:
|
||
优先 zhuangtai=0(进行中),否则取最新一条(可能本地误标失败但微信未终态)
|
||
"""
|
||
pending = (
|
||
TixianAutoRecord.query.filter(shenhe_danhao=shenhe_danhao, zhuangtai=0)
|
||
.order_by('-CreateTime')
|
||
.first()
|
||
)
|
||
if pending:
|
||
return pending
|
||
return (
|
||
TixianAutoRecord.query.filter(shenhe_danhao=shenhe_danhao)
|
||
.order_by('-CreateTime')
|
||
.first()
|
||
)
|
||
|
||
|
||
def _collapse_stale_pending_records(shenhe_danhao):
|
||
"""同一审核单只保留最新一条进行中打款,其余作废(防多次点击堆叠导致永远处理中)。"""
|
||
pending = list(
|
||
TixianAutoRecord.query.filter(shenhe_danhao=shenhe_danhao, zhuangtai=0)
|
||
.order_by('-CreateTime')
|
||
)
|
||
if len(pending) <= 1:
|
||
return pending[0] if pending else None
|
||
keep = pending[0]
|
||
for stale in pending[1:]:
|
||
_mark_transfer_attempt_failed(stale, '重复发起已作废', close_audit=False)
|
||
return keep
|
||
|
||
|
||
def reconcile_shenhe_wechat_bills(shenhe_danhao):
|
||
"""
|
||
收款前对账(仅读本地库,不阻塞查微信)。
|
||
|
||
早期可用版本也是:有 package 直接调起确认;没有 package 就重新发起转账。
|
||
多俱乐部改造后「每次收款先查微信」在 V3 配置/查单异常时会永远返回处理中,已移除。
|
||
"""
|
||
records = list(
|
||
TixianAutoRecord.query.filter(shenhe_danhao=shenhe_danhao).order_by('-CreateTime')
|
||
)
|
||
|
||
if any(r.zhuangtai == 1 for r in records):
|
||
try:
|
||
audit = TixianShenheJilu.query.get(shenhe_danhao=shenhe_danhao)
|
||
if audit.zhuangtai != 2:
|
||
sync_audit_and_jilu_status(audit, 2)
|
||
except TixianShenheJilu.DoesNotExist:
|
||
pass
|
||
return RECONCILE_COMPLETED, {'msg': '该提现已完成,请勿重复操作'}
|
||
|
||
pending = _collapse_stale_pending_records(shenhe_danhao)
|
||
if not pending:
|
||
return RECONCILE_ALLOW_NEW, {}
|
||
|
||
local_wait = _maybe_wait_confirm_from_local(pending)
|
||
if local_wait:
|
||
return local_wait
|
||
|
||
# 进行中但无 package = 上次调微信没成功,立刻作废,允许本次重新发起
|
||
_mark_transfer_attempt_failed(
|
||
pending, '上次发起未成功,请重新收款', close_audit=False,
|
||
)
|
||
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.query.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.query.get(
|
||
id=int(shenhe_jilu_id),
|
||
shenhe_danhao=shenhe_danhao,
|
||
yonghuid=yonghuid,
|
||
)
|
||
else:
|
||
audit = TixianShenheJilu.query.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.query.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 非成功:本地已有 package 则继续调起确认;否则标失败并返回可重试。
|
||
"""
|
||
try:
|
||
ar = TixianAutoRecord.query.get(tixian_id=tixian_id)
|
||
except TixianAutoRecord.DoesNotExist:
|
||
return RECONCILE_ALLOW_NEW, {'wx_err': err_msg or '微信发起转账失败'}
|
||
|
||
local = _maybe_wait_confirm_from_local(ar)
|
||
if local:
|
||
return local
|
||
|
||
reason = (err_msg or err_code or '微信发起转账失败')[:500]
|
||
_mark_transfer_attempt_failed(ar, reason, close_audit=False)
|
||
return RECONCILE_ALLOW_NEW, {'wx_err': reason}
|
||
|
||
|
||
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.UserUID
|
||
|
||
wx_ok, wx_msg = validate_auto_withdraw_prerequisites(user_main)
|
||
if not wx_ok:
|
||
raise ValueError(wx_msg)
|
||
|
||
ok, msg, extra = validate_withdraw_eligibility(user_main, leixing, jine)
|
||
if not ok:
|
||
raise ValueError(msg)
|
||
|
||
feilv = get_tixian_feilv(leixing, yonghuid=yonghuid)
|
||
shouxufei, shijidaozhang = calc_fee_amounts(jine, feilv)
|
||
if shijidaozhang <= 0:
|
||
raise ValueError('提现金额过低,扣除手续费后无实际到账')
|
||
|
||
ok, limit_msg = check_shijidaozhang_within_limit(shijidaozhang)
|
||
if not ok:
|
||
raise ValueError(limit_msg)
|
||
|
||
shenhe_danhao = generate_shenhe_danhao()
|
||
nicheng = extra.get('nicheng', '')
|
||
|
||
with transaction.atomic():
|
||
nicheng = deduct_balance(user_main, leixing, jine) or nicheng
|
||
club_id = club_id_for_tixian_yonghuid(yonghuid)
|
||
|
||
audit_kwargs = {
|
||
'shenhe_danhao': shenhe_danhao,
|
||
'club_id': club_id,
|
||
'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.query.create(**audit_kwargs)
|
||
|
||
tixian_record = Tixianjilu.query.create(
|
||
club_id=club_id,
|
||
yonghuid=yonghuid,
|
||
avatar=user_main.Avatar or '',
|
||
phone=user_main.Phone or '',
|
||
nicheng=nicheng,
|
||
leixing=leixing,
|
||
zhifu=user_main.PaymentQRCode or '',
|
||
skzhanghao=user_main.PaymentAccount 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
|