diff --git a/orders/management/commands/batch_approve_xq_merchant_refunds.py b/orders/management/commands/batch_approve_xq_merchant_refunds.py new file mode 100644 index 0000000..affb600 --- /dev/null +++ b/orders/management/commands/batch_approve_xq_merchant_refunds.py @@ -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('存在失败单,请查日志后单独处理。')) diff --git a/orders/services/merchant_refund_approve.py b/orders/services/merchant_refund_approve.py new file mode 100644 index 0000000..d17006a --- /dev/null +++ b/orders/services/merchant_refund_approve.py @@ -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'