feat: 星阙商家退款审核批量同意脚本
新增 batch_approve_xq_merchant_refunds 管理命令,复用 kefusjtk 同意退款逻辑; 默认要求待审>=762单且 dry-run,--execute 才真正处理。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
180
orders/management/commands/batch_approve_xq_merchant_refunds.py
Normal file
180
orders/management/commands/batch_approve_xq_merchant_refunds.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
星阙(xq)俱乐部 · 商家发单 · 退款审核中订单 — 批量同意退款
|
||||
|
||||
与客服接口 POST /yonghu/kefusjtk 同一套业务逻辑(见 orders.services.merchant_refund_approve)。
|
||||
|
||||
用法(先排查,再执行):
|
||||
|
||||
# 1. 仅统计,默认要求待审单数 >= 762
|
||||
python manage.py batch_approve_xq_merchant_refunds
|
||||
|
||||
# 2. 指定最低单数、只看某商家
|
||||
python manage.py batch_approve_xq_merchant_refunds --min-count 762 --merchant-nicheng 星阙
|
||||
|
||||
# 3. 确认无误后真正执行(建议先备份库)
|
||||
python manage.py batch_approve_xq_merchant_refunds --execute --processor batch_20260710
|
||||
|
||||
# 4. 单数不足也强制跑(危险)
|
||||
python manage.py batch_approve_xq_merchant_refunds --execute --force
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from jituan.constants import CLUB_ID_DEFAULT
|
||||
from orders.models import Order, RefundRecord
|
||||
from orders.services.merchant_refund_approve import (
|
||||
MerchantRefundApproveError,
|
||||
approve_merchant_refund_order,
|
||||
)
|
||||
from users.models import UserShangjia
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '批量同意星阙(xq)商家发单退款审核(Status=4 → 5)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--club-id', default=CLUB_ID_DEFAULT,
|
||||
help='俱乐部 ID,默认 xq(星阙电竞)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--min-count', type=int, default=762,
|
||||
help='待审单数低于该值则中止(除非 --force),默认 762',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--merchant-id', default='',
|
||||
help='仅处理指定商家 UserUID(可选)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--merchant-nicheng', default='',
|
||||
help='仅处理商家昵称包含该关键字(可选,如 星阙)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--processor', default='batch_script',
|
||||
help='写入 RefundRecord.ProcessorID / Order.AssignedCS',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--tuikuan-liyou', default='批量同意退款',
|
||||
help='可选:写入订单退款理由',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--execute', action='store_true',
|
||||
help='真正执行;默认仅排查统计(dry-run)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force', action='store_true',
|
||||
help='待审单数 < min-count 时也继续(默认中止)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--limit', type=int, default=0,
|
||||
help='最多处理 N 单,0 表示不限制(排查模式忽略)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
club_id = (options['club_id'] or CLUB_ID_DEFAULT).strip()
|
||||
min_count = int(options['min_count'] or 0)
|
||||
merchant_id = (options['merchant_id'] or '').strip()
|
||||
merchant_nicheng = (options['merchant_nicheng'] or '').strip()
|
||||
processor = (options['processor'] or 'batch_script').strip()
|
||||
tuikuan_liyou = (options['tuikuan_liyou'] or '').strip()
|
||||
do_execute = bool(options['execute'])
|
||||
force = bool(options['force'])
|
||||
limit = int(options['limit'] or 0)
|
||||
|
||||
pending_refund_ids = RefundRecord.query.filter(
|
||||
ApplyStatus=0,
|
||||
).values_list('OrderID', flat=True)
|
||||
|
||||
qs = (
|
||||
Order.query.filter(
|
||||
ClubID=club_id,
|
||||
Platform=2,
|
||||
Status=4,
|
||||
OrderID__in=pending_refund_ids,
|
||||
)
|
||||
.select_related('shangjia_kuozhan')
|
||||
.order_by('OrderID')
|
||||
)
|
||||
|
||||
if merchant_id:
|
||||
qs = qs.filter(shangjia_kuozhan__MerchantID=merchant_id)
|
||||
|
||||
if merchant_nicheng:
|
||||
merchant_uids = list(
|
||||
UserShangjia.query.filter(
|
||||
nicheng__icontains=merchant_nicheng,
|
||||
).values_list('user__UserUID', flat=True)
|
||||
)
|
||||
if not merchant_uids:
|
||||
raise CommandError(f'未找到昵称包含「{merchant_nicheng}」的商家')
|
||||
qs = qs.filter(shangjia_kuozhan__MerchantID__in=merchant_uids)
|
||||
|
||||
orders = list(qs)
|
||||
total = len(orders)
|
||||
|
||||
self.stdout.write('')
|
||||
self.stdout.write('=== 星阙商家退款审核批量排查 ===')
|
||||
self.stdout.write(f'俱乐部 : {club_id}')
|
||||
self.stdout.write(f'筛选条件 : Platform=2(商家发单) Status=4(退款审核中) RefundRecord.ApplyStatus=0')
|
||||
self.stdout.write(f'商家 UID 过滤 : {merchant_id or "(不限)"}')
|
||||
self.stdout.write(f'商家昵称过滤 : {merchant_nicheng or "(不限)"}')
|
||||
self.stdout.write(f'待审订单数 : {total}')
|
||||
self.stdout.write(f'要求最低单数 : {min_count}({"已满足" if total >= min_count else "未满足"})')
|
||||
self.stdout.write(f'模式 : {"执行" if do_execute else "仅排查(dry-run)"}')
|
||||
self.stdout.write('')
|
||||
|
||||
if total == 0:
|
||||
self.stdout.write(self.style.WARNING('没有符合条件的待审退款单,退出。'))
|
||||
return
|
||||
|
||||
if total < min_count and not force:
|
||||
raise CommandError(
|
||||
f'待审单数 {total} < {min_count},与预期不符,已中止。'
|
||||
f'若确认仍要处理,请加 --force'
|
||||
)
|
||||
|
||||
sample = orders[:5]
|
||||
self.stdout.write('样例订单(前 5 条):')
|
||||
for o in sample:
|
||||
mid = getattr(getattr(o, 'shangjia_kuozhan', None), 'MerchantID', '')
|
||||
self.stdout.write(
|
||||
f' {o.OrderID} 金额={o.Amount} 商家={mid} 打手={o.PlayerID or "-"}',
|
||||
)
|
||||
if total > 5:
|
||||
self.stdout.write(f' ... 共 {total} 条')
|
||||
self.stdout.write('')
|
||||
|
||||
if not do_execute:
|
||||
self.stdout.write(self.style.WARNING(
|
||||
'当前为 dry-run,未改任何数据。确认后执行:\n'
|
||||
f' python manage.py batch_approve_xq_merchant_refunds --execute --processor {processor}'
|
||||
))
|
||||
return
|
||||
|
||||
if limit > 0:
|
||||
orders = orders[:limit]
|
||||
self.stdout.write(self.style.WARNING(f'--limit={limit},本次只处理前 {len(orders)} 单'))
|
||||
|
||||
ok_count = 0
|
||||
fail_count = 0
|
||||
for order in orders:
|
||||
try:
|
||||
approve_merchant_refund_order(
|
||||
order,
|
||||
processor_id=processor,
|
||||
tuikuan_liyou=tuikuan_liyou,
|
||||
require_status_4=True,
|
||||
)
|
||||
ok_count += 1
|
||||
if ok_count <= 10 or ok_count % 50 == 0:
|
||||
self.stdout.write(self.style.SUCCESS(f' OK {order.OrderID}'))
|
||||
except MerchantRefundApproveError as exc:
|
||||
fail_count += 1
|
||||
self.stdout.write(self.style.ERROR(f' FAIL {order.OrderID}: {exc}'))
|
||||
except Exception as exc:
|
||||
fail_count += 1
|
||||
self.stdout.write(self.style.ERROR(f' ERR {order.OrderID}: {exc}'))
|
||||
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.SUCCESS(f'完成:成功 {ok_count},失败 {fail_count},合计 {len(orders)}'))
|
||||
if fail_count:
|
||||
self.stdout.write(self.style.WARNING('存在失败单,请查日志后单独处理。'))
|
||||
129
orders/services/merchant_refund_approve.py
Normal file
129
orders/services/merchant_refund_approve.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""商家发单退款审核通过(与 KefuMerchantRefundView / 后台同意退款业务一致)。"""
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
|
||||
from backend.utils import update_dashou_daily_by_action, update_shangjia_daily
|
||||
from orders.models import Order, RefundRecord
|
||||
from users.business_models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MerchantRefundApproveError(Exception):
|
||||
"""单笔商家退款同意失败。"""
|
||||
|
||||
|
||||
def approve_merchant_refund_order(
|
||||
order,
|
||||
*,
|
||||
processor_id='batch_script',
|
||||
tuikuan_liyou='',
|
||||
require_status_4=True,
|
||||
):
|
||||
"""
|
||||
同意商家发单退款(Platform=2)。
|
||||
|
||||
与 POST /yonghu/kefusjtk 核心事务一致:
|
||||
- 订单 Status → 5(已退款)
|
||||
- 打手 tuikuanliang+1、zhuangtai=1
|
||||
- 商家 tuikuan+1、余额+订单金额(子客服额度单除外)
|
||||
- RefundRecord ApplyStatus → 1
|
||||
- update_shangjia_daily(action=3)、update_dashou_daily_by_action(action=3)
|
||||
|
||||
返回 (True, 'ok');失败抛 MerchantRefundApproveError。
|
||||
"""
|
||||
dingdan_id = order.OrderID
|
||||
|
||||
if order.Platform != 2:
|
||||
raise MerchantRefundApproveError(f'订单 {dingdan_id} 不是商家发单(Platform=2)')
|
||||
|
||||
if require_status_4 and order.Status != 4:
|
||||
raise MerchantRefundApproveError(
|
||||
f'订单 {dingdan_id} 状态={order.Status},非退款审核中(4)',
|
||||
)
|
||||
if not require_status_4 and order.Status not in (1, 7, 2, 8, 4):
|
||||
raise MerchantRefundApproveError(
|
||||
f'订单 {dingdan_id} 状态={order.Status},不允许退款',
|
||||
)
|
||||
|
||||
jine = order.Amount
|
||||
if jine is None:
|
||||
raise MerchantRefundApproveError(f'订单 {dingdan_id} 缺少订单金额')
|
||||
|
||||
try:
|
||||
shangjia_id = order.shangjia_kuozhan.MerchantID
|
||||
except ObjectDoesNotExist as exc:
|
||||
raise MerchantRefundApproveError(f'订单 {dingdan_id} 缺少商家扩展信息') from exc
|
||||
|
||||
jiedan_dashou_id = order.PlayerID
|
||||
|
||||
with transaction.atomic():
|
||||
locked = Order.objects.select_for_update().select_related('shangjia_kuozhan').get(
|
||||
pk=order.pk,
|
||||
)
|
||||
if require_status_4 and locked.Status != 4:
|
||||
raise MerchantRefundApproveError(
|
||||
f'订单 {dingdan_id} 并发冲突:当前状态={locked.Status}',
|
||||
)
|
||||
if locked.Platform != 2:
|
||||
raise MerchantRefundApproveError(f'订单 {dingdan_id} 不是商家发单')
|
||||
|
||||
locked.Status = 5
|
||||
if tuikuan_liyou:
|
||||
locked.RefundReason = tuikuan_liyou
|
||||
if processor_id:
|
||||
locked.AssignedCS = processor_id
|
||||
locked.save(update_fields=['Status', 'RefundReason', 'AssignedCS', 'UpdateTime'])
|
||||
|
||||
if jiedan_dashou_id:
|
||||
try:
|
||||
dashou_user = User.objects.select_for_update().get(UserUID=jiedan_dashou_id)
|
||||
dashou_profile = dashou_user.DashouProfile
|
||||
dashou_profile.tuikuanliang += 1
|
||||
dashou_profile.zhuangtai = 1
|
||||
dashou_profile.save(update_fields=['tuikuanliang', 'zhuangtai'])
|
||||
except (User.DoesNotExist, ObjectDoesNotExist):
|
||||
logger.warning('商家退款:打手 %s 不存在,跳过', jiedan_dashou_id)
|
||||
|
||||
from merchant_ops.services.wallet import is_staff_quota_order
|
||||
|
||||
try:
|
||||
shangjia_user = User.objects.select_for_update().get(UserUID=shangjia_id)
|
||||
shangjia_profile = shangjia_user.ShopProfile
|
||||
shangjia_profile.tuikuan += 1
|
||||
if not is_staff_quota_order(dingdan_id):
|
||||
shangjia_profile.yue += jine
|
||||
shangjia_profile.save(update_fields=['tuikuan', 'yue'])
|
||||
except (User.DoesNotExist, ObjectDoesNotExist):
|
||||
logger.warning('商家退款:商家 %s 不存在,跳过', shangjia_id)
|
||||
|
||||
try:
|
||||
refund_record = RefundRecord.objects.select_for_update().get(OrderID=dingdan_id)
|
||||
refund_record.ApplyStatus = 1
|
||||
refund_record.ProcessorID = processor_id
|
||||
refund_record.save(update_fields=['ApplyStatus', 'ProcessorID', 'UpdateTime'])
|
||||
except RefundRecord.DoesNotExist:
|
||||
logger.warning('商家退款:订单 %s 无 RefundRecord,跳过更新', dingdan_id)
|
||||
|
||||
update_shangjia_daily(
|
||||
yonghuid=shangjia_id,
|
||||
amount=Decimal(str(jine)),
|
||||
action=3,
|
||||
order_id=dingdan_id,
|
||||
)
|
||||
|
||||
if jiedan_dashou_id and order.PlayerCommission is not None:
|
||||
try:
|
||||
update_dashou_daily_by_action(
|
||||
yonghuid=jiedan_dashou_id,
|
||||
amount=order.PlayerCommission,
|
||||
action=3,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error('打手每日统计更新失败 order=%s err=%s', dingdan_id, exc)
|
||||
|
||||
logger.info('同意商家退款成功 order=%s processor=%s', dingdan_id, processor_id)
|
||||
return True, 'ok'
|
||||
Reference in New Issue
Block a user