P0防双笔:查不清绝不新建第二笔;新增提现抽成利润统计接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-16 19:49:38 +08:00
parent 52e777c3a0
commit fc93f1c8fc
4 changed files with 374 additions and 65 deletions

View File

@@ -0,0 +1,328 @@
"""
提现抽成利润统计(真实抽成 = 申请扣款额 实际到账额 = shouxufei
口径说明(务必与分账一致):
- 仅统计「提现成功」记录Tixianjilu.zhuangtai = 2
- 利润 = COALESCE(shouxufei, max(shenqing_jine - jine, 0))
- 手动/自动一并计入dakuan_mode 可筛)
- 日期维度默认按 update_time成功落账日可按 create_time申请日
- 不含会员/押金充值/商家充值等,那些不是抽成利润
"""
import logging
from datetime import datetime, time
from decimal import Decimal
from django.db.models import (
Count, DecimalField, F, Q, Sum, Value,
)
from django.db.models.functions import Coalesce, Greatest, TruncDate, TruncMonth
from django.utils import timezone
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from yonghu.models import Tixianjilu
from .view import verify_kefu_permission
logger = logging.getLogger(__name__)
_LEIXING_LABEL = {
1: '打手佣金',
2: '管事分红',
3: '组长分红',
4: '审核官分佣',
5: '打手押金',
6: '商家余额',
}
_FEE_EXPR = Coalesce(
F('shouxufei'),
Greatest(
Coalesce(F('shenqing_jine'), Value(Decimal('0.00')))
- Coalesce(F('jine'), Value(Decimal('0.00'))),
Value(Decimal('0.00')),
),
Value(Decimal('0.00')),
output_field=DecimalField(max_digits=12, decimal_places=2),
)
def _parse_date(s):
if not s:
return None
try:
return datetime.strptime(str(s).strip()[:10], '%Y-%m-%d').date()
except (TypeError, ValueError):
return None
def _day_bounds(d):
"""本地日历日 → aware datetime 区间 [start, end)"""
start = datetime.combine(d, time.min)
end = datetime.combine(d, time.max)
if timezone.is_naive(start):
tz = timezone.get_current_timezone()
start = timezone.make_aware(start, tz)
end = timezone.make_aware(end, tz)
return start, end
class TixianChouchengProfitView(APIView):
"""
POST /houtai/txchouchengtj
提现抽成利润统计(分账专用口径)
参数:
phone: 客服手机号
date_from / date_to: YYYY-MM-DD闭区间按成功日或申请日
date_field: update_time(默认,成功日) | create_time(申请日)
leixing: 1~6 可选;不传=全部身份
dakuan_mode: 1手动 2自动 可选;不传=全部
group_by: day | month | leixing | none默认 day
page / page_size: 明细分页(可选,仅 group_by=none 时返回 list
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
kefu, permissions = verify_kefu_permission(request, phone)
if kefu is None:
return permissions
if 'caiwu' not in permissions and not any(
p in permissions for p in ('5500a', '5500b', '5500c')
):
return Response({'code': 403, 'msg': '无提现抽成利润查看权限'})
date_from = _parse_date(request.data.get('date_from') or request.data.get('start_date'))
date_to = _parse_date(request.data.get('date_to') or request.data.get('end_date'))
if not date_from or not date_to:
today = timezone.localdate()
date_from = date_from or today
date_to = date_to or today
if date_from > date_to:
return Response({'code': 400, 'msg': 'date_from 不能晚于 date_to'})
date_field = (request.data.get('date_field') or 'update_time').strip()
if date_field not in ('update_time', 'create_time'):
return Response({'code': 400, 'msg': 'date_field 仅支持 update_time / create_time'})
group_by = (request.data.get('group_by') or 'day').strip()
if group_by not in ('day', 'month', 'leixing', 'none'):
return Response({'code': 400, 'msg': 'group_by 仅支持 day/month/leixing/none'})
leixing_val = None
raw_leixing = request.data.get('leixing')
if raw_leixing is not None and str(raw_leixing).strip() != '':
try:
leixing_val = int(raw_leixing)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': 'leixing 无效'})
if leixing_val not in (1, 2, 3, 4, 5, 6):
return Response({'code': 400, 'msg': 'leixing 仅支持 1~6'})
dakuan_mode_val = None
raw_mode = request.data.get('dakuan_mode')
if raw_mode is not None and str(raw_mode).strip() != '':
try:
dakuan_mode_val = int(raw_mode)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': 'dakuan_mode 无效'})
if dakuan_mode_val not in (1, 2):
return Response({'code': 400, 'msg': 'dakuan_mode 仅支持 1/2'})
start_dt, _ = _day_bounds(date_from)
_, end_dt = _day_bounds(date_to)
# 仅提现成功;手动+自动
q = Q(zhuangtai=2)
if date_field == 'create_time':
q &= Q(create_time__gte=start_dt, create_time__lte=end_dt)
else:
q &= Q(update_time__gte=start_dt, update_time__lte=end_dt)
if leixing_val is not None:
q &= Q(leixing=leixing_val)
if dakuan_mode_val is not None:
q &= Q(dakuan_mode=dakuan_mode_val)
qs = Tixianjilu.objects.filter(q)
annotated = qs.annotate(fee=_FEE_EXPR)
total_agg = annotated.aggregate(
fee_sum=Sum('fee'),
apply_sum=Sum(Coalesce(F('shenqing_jine'), F('jine'), Value(Decimal('0.00')))),
payout_sum=Sum(Coalesce(F('jine'), Value(Decimal('0.00')))),
cnt=Count('id'),
)
fee_total = total_agg.get('fee_sum') or Decimal('0.00')
apply_total = total_agg.get('apply_sum') or Decimal('0.00')
payout_total = total_agg.get('payout_sum') or Decimal('0.00')
# 交叉校验:申请 到账 应约等于抽成(允许历史脏数据偏差)
cross_check = (apply_total - payout_total).quantize(Decimal('0.01'))
summary = {
'profit_total': str(fee_total.quantize(Decimal('0.01'))),
'apply_total': str(apply_total.quantize(Decimal('0.01'))),
'payout_total': str(payout_total.quantize(Decimal('0.01'))),
'apply_minus_payout': str(cross_check),
'success_count': int(total_agg.get('cnt') or 0),
'date_from': date_from.isoformat(),
'date_to': date_to.isoformat(),
'date_field': date_field,
'leixing': leixing_val,
'leixing_label': _LEIXING_LABEL.get(leixing_val, '全部身份'),
'dakuan_mode': dakuan_mode_val,
'formula': '利润=申请扣款额(shenqing_jine)−实际到账(jine)=手续费(shouxufei);仅统计提现成功(zhuangtai=2)',
}
rows = []
if group_by == 'day':
trunc = TruncDate(date_field)
grouped = (
annotated.annotate(period=trunc)
.values('period')
.annotate(
profit=Sum('fee'),
apply_amount=Sum(Coalesce(F('shenqing_jine'), F('jine'), Value(Decimal('0.00')))),
payout_amount=Sum(Coalesce(F('jine'), Value(Decimal('0.00')))),
count=Count('id'),
)
.order_by('period')
)
for g in grouped:
period = g['period']
rows.append({
'period': period.isoformat() if period else '',
'profit': str((g['profit'] or Decimal('0')).quantize(Decimal('0.01'))),
'apply_amount': str((g['apply_amount'] or Decimal('0')).quantize(Decimal('0.01'))),
'payout_amount': str((g['payout_amount'] or Decimal('0')).quantize(Decimal('0.01'))),
'count': g['count'] or 0,
})
elif group_by == 'month':
trunc = TruncMonth(date_field)
grouped = (
annotated.annotate(period=trunc)
.values('period')
.annotate(
profit=Sum('fee'),
apply_amount=Sum(Coalesce(F('shenqing_jine'), F('jine'), Value(Decimal('0.00')))),
payout_amount=Sum(Coalesce(F('jine'), Value(Decimal('0.00')))),
count=Count('id'),
)
.order_by('period')
)
for g in grouped:
period = g['period']
rows.append({
'period': period.strftime('%Y-%m') if period else '',
'profit': str((g['profit'] or Decimal('0')).quantize(Decimal('0.01'))),
'apply_amount': str((g['apply_amount'] or Decimal('0')).quantize(Decimal('0.01'))),
'payout_amount': str((g['payout_amount'] or Decimal('0')).quantize(Decimal('0.01'))),
'count': g['count'] or 0,
})
elif group_by == 'leixing':
grouped = (
annotated.values('leixing')
.annotate(
profit=Sum('fee'),
apply_amount=Sum(Coalesce(F('shenqing_jine'), F('jine'), Value(Decimal('0.00')))),
payout_amount=Sum(Coalesce(F('jine'), Value(Decimal('0.00')))),
count=Count('id'),
)
.order_by('leixing')
)
for g in grouped:
lx = g['leixing']
rows.append({
'leixing': lx,
'leixing_label': _LEIXING_LABEL.get(lx, f'类型{lx}'),
'profit': str((g['profit'] or Decimal('0')).quantize(Decimal('0.01'))),
'apply_amount': str((g['apply_amount'] or Decimal('0')).quantize(Decimal('0.01'))),
'payout_amount': str((g['payout_amount'] or Decimal('0')).quantize(Decimal('0.01'))),
'count': g['count'] or 0,
})
else:
# 明细
try:
page = max(1, int(request.data.get('page') or 1))
page_size = min(200, max(1, int(request.data.get('page_size') or 50)))
except (TypeError, ValueError):
page, page_size = 1, 50
total = annotated.count()
items = list(
annotated.order_by(f'-{date_field}', '-id')
[(page - 1) * page_size: page * page_size]
.values(
'id', 'yonghuid', 'leixing', 'dakuan_mode',
'shenqing_jine', 'jine', 'shouxufei', 'fee',
'create_time', 'update_time', 'shenhe_danhao',
)
)
for it in items:
fee = it.get('fee') or Decimal('0')
rows.append({
'id': it['id'],
'yonghuid': it['yonghuid'],
'leixing': it['leixing'],
'leixing_label': _LEIXING_LABEL.get(it['leixing'], ''),
'dakuan_mode': it['dakuan_mode'],
'dakuan_mode_label': '自动' if it['dakuan_mode'] == 2 else '手动',
'shenqing_jine': str(it['shenqing_jine'] or '0.00'),
'shijidaozhang': str(it['jine'] or '0.00'),
'shouxufei': str(it['shouxufei'] if it['shouxufei'] is not None else fee),
'profit': str(Decimal(fee).quantize(Decimal('0.01'))),
'shenhe_danhao': it.get('shenhe_danhao') or '',
'create_time': it['create_time'].strftime('%Y-%m-%d %H:%M:%S') if it.get('create_time') else '',
'update_time': it['update_time'].strftime('%Y-%m-%d %H:%M:%S') if it.get('update_time') else '',
})
return Response({
'code': 0,
'data': {
'summary': summary,
'group_by': group_by,
'list': rows,
'total': total,
'page': page,
'page_size': page_size,
},
})
# 按身份拆分汇总(便于分账核对,始终附带)
by_leixing = []
for g in (
annotated.values('leixing')
.annotate(profit=Sum('fee'), count=Count('id'))
.order_by('leixing')
):
by_leixing.append({
'leixing': g['leixing'],
'leixing_label': _LEIXING_LABEL.get(g['leixing'], ''),
'profit': str((g['profit'] or Decimal('0')).quantize(Decimal('0.01'))),
'count': g['count'] or 0,
})
by_mode = []
for g in (
annotated.values('dakuan_mode')
.annotate(profit=Sum('fee'), count=Count('id'))
.order_by('dakuan_mode')
):
by_mode.append({
'dakuan_mode': g['dakuan_mode'],
'dakuan_mode_label': '自动' if g['dakuan_mode'] == 2 else '手动',
'profit': str((g['profit'] or Decimal('0')).quantize(Decimal('0.01'))),
'count': g['count'] or 0,
})
return Response({
'code': 0,
'data': {
'summary': summary,
'group_by': group_by,
'rows': rows,
'by_leixing': by_leixing,
'by_dakuan_mode': by_mode,
},
})

View File

@@ -18,6 +18,7 @@ from .view import GetClubConfigView, GetCrossOrderListView, PartnerGetOrderDataV
CaiwuView, CwhybkhqView, HybkjtsjView, SzxxView, CwddhqlxView, CwhqjtddsjView, CwqtczhqView, KhpzhqView, ChzsgcView, \
KhgglView, ShgxgsjView, ZxkfghdsView, KptxwztjbView
from .dongjie_views import DongjieUserConfigView
from .tixian_profit_views import TixianChouchengProfitView
from .huashu_views import HuashuListAPIView, HuashuModifyAPIView
from .xieyi_views import (
XieyiListAPIView, XieyiModifyAPIView, XieyiImageUploadAPIView,
@@ -120,6 +121,7 @@ urlpatterns = [
path('bkxg', BkxgView.as_view(), name='板块增删改查'), # 板块增删改
path('caiwu', CaiwuView.as_view(), name='财务页面基础数据'),
path('txchouchengtj', TixianChouchengProfitView.as_view(), name='提现抽成利润统计'),
path('cwhybkhq', CwhybkhqView.as_view(), name='财务会员模块'),
path('hybkjtsj', HybkjtsjView.as_view(), name='财务会员统计'),

View File

@@ -78,7 +78,7 @@ RECONCILE_WAIT_CONFIRM = 'wait_confirm'
RECONCILE_ALLOW_NEW = 'allow_new'
RECONCILE_PENDING = 'pending_query'
# 查单失败且本地 zhuangtai=0 超过该分钟数,才允许新建打款记录
# 历史兼容:宽限分钟数常量仍保留(对账已改为「有 package 查无单也不新建」)
PENDING_QUERY_GRACE_MINUTES = 10
@@ -1280,17 +1280,11 @@ def mark_transfer_success(auto_record, *, wechat_transfer_no=None, with_accounti
def _sync_record_from_wx_query(auto_record, wx_data):
"""根据微信查单结果修正本地打款记录,返回 (action, payload)"""
if wx_data.get('_not_found'):
# 从未拿到 package = 未真正进入待确认,查无单应立刻放行换号/重发,
# 不要卡 10 分钟宽限期(否则首户日额度失败后重试永远进不了多商户轮换)。
# 微信明确无此单。若本地曾有 package可能查单串户/短暂异常 → 禁止新建第二笔
has_package = bool(_parse_package_info(auto_record.wechat_package))
age = timezone.now() - auto_record.create_time
if (
auto_record.zhuangtai == 0
and has_package
and age < timedelta(minutes=PENDING_QUERY_GRACE_MINUTES)
):
if has_package:
return RECONCILE_PENDING, {
'msg': '有收款处理中,请稍后再试',
'msg': '本地已有收款凭证但微信暂查无单,请稍后再试,切勿重复点击',
'tixian_id': auto_record.tixian_id,
}
if auto_record.zhuangtai == 0:
@@ -1358,12 +1352,11 @@ def _sync_record_from_wx_query(auto_record, wx_data):
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, {}
# 未知状态:宁可卡住,绝不放行新建第二笔
return RECONCILE_PENDING, {
'msg': f'收款处理中(微信状态:{state or "未知"}),请稍后再试',
'tixian_id': auto_record.tixian_id,
}
def get_auto_record_for_collect(shenhe_danhao):
@@ -1394,10 +1387,11 @@ def _mark_auto_record_failed_local(rec, reason):
def reconcile_shenhe_wechat_bills(shenhe_danhao):
"""
P0 防双笔:同一 shenhe_danhao 下所有打款记录逐条问微信
P0 防双笔(宁可提不出也不开第二笔):
- SUCCESS → completed
- 真正待确认(有 package) → wait_confirm(禁止换户)
- 其余(无 package / 限额失败 / IP / 查单失败)→ 标失败ALLOW_NEW 以便换商户
- WAIT_USER_CONFIRM有 package→ wait_confirm
- ACCEPTED / PROCESSING / CANCELING / 查单失败(None) → PENDING禁止新建
- 仅微信明确 FAIL / 查无单(_not_found) → 可 ALLOW_NEW
"""
records = list(
TixianAutoRecord.objects.filter(shenhe_danhao=shenhe_danhao).order_by('-create_time')
@@ -1410,7 +1404,6 @@ def reconcile_shenhe_wechat_bills(shenhe_danhao):
if not records:
return RECONCILE_ALLOW_NEW, {}
pending_with_package = None
for rec in records:
if rec.zhuangtai == 1:
ok, msg = ensure_shenhe_collect_completed(shenhe_danhao)
@@ -1418,64 +1411,48 @@ def reconcile_shenhe_wechat_bills(shenhe_danhao):
local_pkg = _parse_package_info(rec.wechat_package)
# 本地已有收款包:必须先对齐数字,禁止为换号新建第二笔待确认
if rec.zhuangtai == 0 and local_pkg:
# 进行中记录:必须先对齐微信,查不清绝不新建
if rec.zhuangtai == 0:
wx_data = query_wechat_transfer_bill(rec.tixian_id)
if wx_data is None:
logger.error(
'【防双笔】查单失败禁止新建 shenhe=%s bill=%s mch=%s has_pkg=%s',
shenhe_danhao, rec.tixian_id, rec.mch_id, bool(local_pkg),
)
return RECONCILE_PENDING, {
'msg': '有收款处理中,系统正在核对微信状态,请稍后再试',
'msg': '有收款处理中,系统正在核对微信状态,请稍后再试,切勿重复点击',
'tixian_id': rec.tixian_id,
}
action, payload = _sync_record_from_wx_query(rec, wx_data)
if action in (RECONCILE_COMPLETED, RECONCILE_WAIT_CONFIRM, RECONCILE_PENDING):
return action, payload
# ALLOW_NEW本条已明确失败查无单或 FAIL继续看其它记录
continue
# 无 package商户侧基本未进入待确认。限额/IP/卡单都不能挡住换号。
if rec.zhuangtai == 0 and not local_pkg:
wx_data = query_wechat_transfer_bill(rec.tixian_id)
if wx_data is None or wx_data.get('_not_found'):
_mark_auto_record_failed_local(
rec, '未进入待确认(查无单或查单失败),已换商户重试',
)
continue
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
# PENDING / ALLOW_NEW若仍无 package标失败继续换号
if action == RECONCILE_PENDING:
pkg = (
(payload or {}).get('package_info')
if isinstance(payload, dict) else None
) or wx_data.get('package_info')
if pkg:
if pending_with_package is None:
pending_with_package = (action, payload)
continue
_mark_auto_record_failed_local(
rec,
f'微信不可用状态:{_wx_transfer_state(wx_data) or "未知"},已换商户重试',
)
continue
# 已失败记录:仍问微信。若仍待确认必须回旧 package禁止新建第二笔防双收
# 本地已失败:微信若仍有效/处理中,禁止新建第二笔
if rec.zhuangtai == 2:
wx_data = query_wechat_transfer_bill(rec.tixian_id)
if wx_data is None or wx_data.get('_not_found'):
if wx_data is None:
logger.error(
'【防双笔】失败单查单不明禁止新建 shenhe=%s bill=%s mch=%s',
shenhe_danhao, rec.tixian_id, rec.mch_id,
)
return RECONCILE_PENDING, {
'msg': '有历史收款单状态核对中,请稍后再试,切勿重复点击',
'tixian_id': rec.tixian_id,
}
if wx_data.get('_not_found'):
continue
action, payload = _sync_record_from_wx_query(rec, wx_data)
if action in (RECONCILE_COMPLETED, RECONCILE_WAIT_CONFIRM):
if action in (RECONCILE_COMPLETED, RECONCILE_WAIT_CONFIRM, RECONCILE_PENDING):
logger.warning(
'本地失败单微信仍有效 shenhe=%s bill=%s mch=%s action=%s(禁止新建第二笔)',
shenhe_danhao, rec.tixian_id, rec.mch_id, action,
)
return action, payload
# ALLOW_NEW微信确认终态失败可继续
continue
if pending_with_package:
return pending_with_package
return RECONCILE_ALLOW_NEW, {}

View File

@@ -421,17 +421,19 @@ def process_audit_collect(request):
# 选户:绑定用户仅 1 户;未绑定按 priority。
# 仅「商户整户日/月额度」+ 查单确认未建单 才静默换号;其它失败立刻回前端。
# 跨次仅跳过「整户额度」失败过的商户,个人限额/IP/余额失败不跨次换号。
if not is_user_bound and len(mch_cfgs) > 1:
failed_mchs = set(
TixianAutoRecord.objects.filter(
shenhe_danhao=shenhe_danhao, zhuangtai=2,
).exclude(mch_id='').values_list('mch_id', flat=True)
)
prefer_cfgs = [c for c in mch_cfgs if c.get('MCHID') not in failed_mchs]
failed_quota_mchs = set()
for fr, mid in TixianAutoRecord.objects.filter(
shenhe_danhao=shenhe_danhao, zhuangtai=2,
).exclude(mch_id='').values_list('fail_reason', 'mch_id'):
if mid and _is_wx_mch_total_quota_exhausted('', fr or ''):
failed_quota_mchs.add(mid)
prefer_cfgs = [c for c in mch_cfgs if c.get('MCHID') not in failed_quota_mchs]
if prefer_cfgs and len(prefer_cfgs) < len(mch_cfgs):
logger.warning(
'收款优先未失败商户 shenhe=%s skip=%s try=%s',
shenhe_danhao, sorted(failed_mchs),
'收款优先未触整户额度商户 shenhe=%s skip=%s try=%s',
shenhe_danhao, sorted(failed_quota_mchs),
[c.get('MCHID') for c in prefer_cfgs],
)
mch_cfgs = prefer_cfgs