diff --git a/orders/migrations/0021_partial_merchant_refund.py b/orders/migrations/0021_partial_merchant_refund.py new file mode 100644 index 0000000..c8c8f7a --- /dev/null +++ b/orders/migrations/0021_partial_merchant_refund.py @@ -0,0 +1,44 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0020_partial_settle'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='PartialSettleMerchantRefund', + field=models.DecimalField( + blank=True, + db_column='partial_settle_merchant_refund', + decimal_places=2, + max_digits=10, + null=True, + verbose_name='比例结算退商家金额', + ), + ), + migrations.CreateModel( + name='PartialMerchantRefundLedger', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('order_id', models.CharField(db_index=True, max_length=32, unique=True)), + ('merchant_uid', models.CharField(db_index=True, max_length=32)), + ('amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('order_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('settle_ratio', models.DecimalField(decimal_places=4, default=0, max_digits=5)), + ('reason', models.CharField(blank=True, default='', max_length=200)), + ('source', models.CharField(blank=True, default='', max_length=32)), + ('operator', models.CharField(blank=True, default='', max_length=32)), + ('biz_id', models.CharField(max_length=64, unique=True)), + ('staff_quota', models.BooleanField(default=False, verbose_name='是否退回店员额度')), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': '比例结算退商家账本', + 'db_table': 'partial_merchant_refund_ledger', + }, + ), + ] diff --git a/orders/models.py b/orders/models.py index 63c9d18..bb12c56 100644 --- a/orders/models.py +++ b/orders/models.py @@ -188,6 +188,11 @@ class Order(QModel): max_length=32, blank=True, default='', db_column='partial_settled_by', verbose_name='比例结算操作人', ) + # 退回商家:Amount × (1-ratio),入可提现余额(或店员额度单释放额度) + PartialSettleMerchantRefund = models.DecimalField( + max_digits=10, decimal_places=2, null=True, blank=True, + db_column='partial_settle_merchant_refund', verbose_name='比例结算退商家金额', + ) # ---- 时间戳 ---- CreateTime = models.DateTimeField( auto_now_add=True, @@ -1353,6 +1358,27 @@ class TeamSettleLedger(QModel): db_table = 'team_settle_ledger' +class PartialMerchantRefundLedger(QModel): + """比例结算退商家余额幂等账本(防多退/少退)。""" + + id = models.BigAutoField(primary_key=True) + order_id = models.CharField(max_length=32, unique=True, db_index=True) + merchant_uid = models.CharField(max_length=32, db_index=True) + amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) + order_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) + settle_ratio = models.DecimalField(max_digits=5, decimal_places=4, default=0) + reason = models.CharField(max_length=200, blank=True, default='') + source = models.CharField(max_length=32, blank=True, default='') + operator = models.CharField(max_length=32, blank=True, default='') + biz_id = models.CharField(max_length=64, unique=True) + staff_quota = models.BooleanField(default=False, verbose_name='是否退回店员额度') + CreateTime = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'partial_merchant_refund_ledger' + verbose_name = '比例结算退商家账本' + + class TeamActionLog(QModel): id = models.BigAutoField(primary_key=True) club_id = models.CharField(max_length=16, db_index=True, blank=True, default='') diff --git a/orders/services/partial_settle.py b/orders/services/partial_settle.py index cb5d082..cb26a29 100644 --- a/orders/services/partial_settle.py +++ b/orders/services/partial_settle.py @@ -69,12 +69,18 @@ def club_default_partial_ratio(club_id: str) -> Decimal: def preview_partial_amounts(order, ratio: Decimal) -> dict: - """按 full×ratio 预览(不入账)。组队每人份额 = full 份额 × ratio。""" + """ + 按 full×ratio 预览(不入账)。 + - 打手/管事:分成 × ratio + - 退商家:订单金额 Amount × (1-ratio) 回可提现余额 + """ ratio = parse_settle_ratio(ratio) + order_amount = _d(getattr(order, 'Amount', None)) full_player = _d(getattr(order, 'PlayerCommission', None)) full_manager = _d(getattr(order, 'ManagerCommission', None)) pay_player = _d(full_player * ratio) pay_manager = _d(full_manager * ratio) + merchant_refund = _d(order_amount * (Decimal('1') - ratio)) team_shares = [] try: from orders.services.team_recruit import ( @@ -123,12 +129,14 @@ def preview_partial_amounts(order, ratio: Decimal) -> dict: return { 'settle_ratio': str(ratio), 'settle_percent': int((ratio * 100).quantize(Decimal('1'))), + 'refund_percent': int(((Decimal('1') - ratio) * 100).quantize(Decimal('1'))), 'full_player_commission': float(full_player), 'full_manager_commission': float(full_manager), 'partial_player_amount': float(pay_player), 'partial_manager_amount': float(pay_manager), + 'merchant_refund_amount': float(merchant_refund), 'team_shares': team_shares, - 'order_amount': float(_d(getattr(order, 'Amount', None))), + 'order_amount': float(order_amount), } @@ -169,15 +177,19 @@ def build_refund_settle_payload(order, *, viewer_uid: str = '') -> dict: full_player = _d(getattr(order, 'PlayerCommission', None)) full_manager = _d(getattr(order, 'ManagerCommission', None)) + order_amount = _d(getattr(order, 'Amount', None)) if st == PARTIAL_STATUS_DONE: pay_player = _d(getattr(order, 'PartialSettlePlayerAmount', None)) pay_manager = _d(getattr(order, 'PartialSettleManagerAmount', None)) + merchant_refund = _d(getattr(order, 'PartialSettleMerchantRefund', None)) elif ratio_d is not None: pay_player = _d(full_player * ratio_d) pay_manager = _d(full_manager * ratio_d) + merchant_refund = _d(order_amount * (Decimal('1') - ratio_d)) else: pay_player = Decimal('0.00') pay_manager = Decimal('0.00') + merchant_refund = Decimal('0.00') can_partial = ( st == PARTIAL_STATUS_ARMED @@ -217,6 +229,8 @@ def build_refund_settle_payload(order, *, viewer_uid: str = '') -> dict: 'full_manager_commission': float(full_manager), 'partial_player_amount': float(pay_player) if (st or ratio_d) else None, 'partial_manager_amount': float(pay_manager) if (st or ratio_d) else None, + 'merchant_refund_amount': float(merchant_refund) if (st or ratio_d) else None, + 'refund_percent': int(((Decimal('1') - ratio_d) * 100).quantize(Decimal('1'))) if ratio_d is not None else None, 'partial_settled_at': fmt_datetime(settled_at) if settled_at else '', 'partial_settled_by': getattr(order, 'PartialSettledBy', '') or '', 'can_partial_settle': can_partial, @@ -273,6 +287,7 @@ def arm_partial_settle( order.PartialSettleStatus = PARTIAL_STATUS_ARMED order.PartialSettlePlayerAmount = _d(preview['partial_player_amount']) order.PartialSettleManagerAmount = _d(preview['partial_manager_amount']) + order.PartialSettleMerchantRefund = _d(preview['merchant_refund_amount']) order.Status = 8 order.AutoSettleEligible = False order.AutoExpireAt = None @@ -286,6 +301,7 @@ def arm_partial_settle( order.save(update_fields=[ 'PartialSettleRatio', 'PartialSettleStatus', 'PartialSettlePlayerAmount', 'PartialSettleManagerAmount', + 'PartialSettleMerchantRefund', 'Status', 'AutoSettleEligible', 'AutoExpireAt', 'PendingDispatch', 'AutoTaskID', 'SettlementTime', 'RefundReason', 'AssignedCS', 'UpdateTime', ]) @@ -394,6 +410,105 @@ def _credit_team_or_solo_partial(order, *, ratio: Decimal, pay_player: Decimal) return True +def _credit_merchant_partial_refund( + order, + *, + amount: Decimal, + ratio: Decimal, + reason: str, + source: str, + operator: str, +) -> dict: + """ + 退商家 Amount×(1-ratio) 到可提现余额;店员额度单则释放额度。 + 幂等:PartialMerchantRefundLedger.order_id / biz_id 唯一。 + """ + from orders.models import PartialMerchantRefundLedger + from users.business_models import User + from users.models import UserShangjia + from merchant_ops.services.wallet import is_staff_quota_order, release_dispatch_quota + from backend.utils import update_shangjia_daily + + oid = str(getattr(order, 'OrderID', '') or '') + amount = _d(amount) + if amount <= 0: + return {'credited': False, 'amount': '0.00', 'idempotent': True} + + biz_id = f'{oid}:merchant:partial_refund' + if PartialMerchantRefundLedger.query.filter(biz_id=biz_id).exists(): + logger.warning('比例结算退商家幂等跳过 order=%s', oid) + return {'credited': False, 'amount': str(amount), 'idempotent': True} + + try: + mid = order.shangjia_kuozhan.MerchantID + except Exception as e: + raise PartialSettleError('订单缺少商家信息,无法退还余额') from e + if not mid: + raise PartialSettleError('订单缺少商家ID,无法退还余额') + + staff_quota = bool(is_staff_quota_order(oid)) + reason_text = (reason or f'拒绝退款比例结算退还 ratio={ratio}').strip()[:200] + + # 先落账本再入账,唯一约束防并发双退 + try: + ledger = PartialMerchantRefundLedger( + order_id=oid, + merchant_uid=str(mid)[:32], + amount=amount, + order_amount=_d(order.Amount), + settle_ratio=ratio, + reason=reason_text, + source=(source or '')[:32], + operator=(operator or '')[:32], + biz_id=biz_id, + staff_quota=staff_quota, + ) + ledger.save() + except Exception as e: + if PartialMerchantRefundLedger.query.filter(biz_id=biz_id).exists(): + return {'credited': False, 'amount': str(amount), 'idempotent': True} + raise PartialSettleError(f'退商家账本写入失败: {e}') from e + + if staff_quota: + try: + release_dispatch_quota( + oid, amount, operator or mid, + remark=f'比例结算退还额度 {reason_text}'[:80], + ) + except Exception as e: + logger.error('比例结算释放店员额度失败 order=%s: %s', oid, e, exc_info=True) + raise PartialSettleError(f'退还店员额度失败: {e}') from e + else: + try: + sj_user = User.objects.select_for_update().get(UserUID=mid) + profile = sj_user.ShopProfile + UserShangjia.objects.filter(id=profile.id).update(yue=F('yue') + amount) + except (User.DoesNotExist, AttributeError) as e: + raise PartialSettleError('商家不存在,无法退还余额') from e + + try: + update_shangjia_daily( + yonghuid=mid, + amount=amount, + action=3, # 退款 + order_id=oid, + ) + except Exception as e: + logger.error('比例结算退商家日统计失败: %s', e) + + logger.info( + '比例结算退商家成功 order=%s merchant=%s amount=%s staff_quota=%s', + oid, mid, amount, staff_quota, + ) + return { + 'credited': True, + 'amount': str(amount), + 'idempotent': False, + 'staff_quota': staff_quota, + 'merchant_uid': mid, + } + + @transaction.atomic def settle_merchant_order_partial( order, @@ -449,8 +564,10 @@ def settle_merchant_order_partial( preview = preview_partial_amounts(locked, ratio_d) pay_player = _d(preview['partial_player_amount']) pay_manager = _d(preview['partial_manager_amount']) + merchant_refund = _d(preview['merchant_refund_amount']) dashou_id = locked.PlayerID oid = locked.OrderID + reject_reason = (getattr(locked, 'RefundReason', None) or '').strip() team_done = _credit_team_or_solo_partial(locked, ratio=ratio_d, pay_player=pay_player) if not team_done: @@ -469,7 +586,7 @@ def settle_merchant_order_partial( settle_biz_id=f'{oid}:partial_settle', ) - # 商家成交 +1(拒绝路径不退商家款) + merchant_refund_result = {'credited': False, 'amount': '0.00'} if int(locked.Platform or 0) == 2: try: mid = locked.shangjia_kuozhan.MerchantID @@ -481,13 +598,16 @@ def settle_merchant_order_partial( ) except (User.DoesNotExist, AttributeError): logger.warning('比例结算:商家 %s 不存在,跳过成交+1', mid) + # 日统计:成交口径记 Amount×ratio;退还另记 action=3 try: - update_shangjia_daily( - yonghuid=mid, - amount=_d(locked.Amount), - action=2, - order_id=oid, - ) + kept = _d(_d(locked.Amount) * ratio_d) + if kept > 0: + update_shangjia_daily( + yonghuid=mid, + amount=kept, + action=2, + order_id=oid, + ) except Exception as e: logger.error('比例结算商家日统计失败: %s', e) except Exception as e: @@ -501,6 +621,17 @@ def settle_merchant_order_partial( shuoming='商家订单比例结算分红', ) + # 剩余 (1-ratio)×Amount 退回商家(幂等) + if merchant_refund > 0: + merchant_refund_result = _credit_merchant_partial_refund( + locked, + amount=merchant_refund, + ratio=ratio_d, + reason=reject_reason or f'比例结算退还{preview.get("refund_percent")}%', + source=source or '', + operator=operator or '', + ) + try: update_dashou_daily_by_action( yonghuid=dashou_id, @@ -515,6 +646,7 @@ def settle_merchant_order_partial( locked.PartialSettleStatus = PARTIAL_STATUS_DONE locked.PartialSettlePlayerAmount = pay_player locked.PartialSettleManagerAmount = pay_manager + locked.PartialSettleMerchantRefund = merchant_refund locked.PartialSettledAt = now locked.PartialSettledBy = (operator or source or '')[:32] locked.Status = 6 @@ -527,20 +659,22 @@ def settle_merchant_order_partial( locked.save(update_fields=[ 'PartialSettleRatio', 'PartialSettleStatus', 'PartialSettlePlayerAmount', 'PartialSettleManagerAmount', + 'PartialSettleMerchantRefund', 'PartialSettledAt', 'PartialSettledBy', 'Status', 'AutoSettleEligible', 'AutoExpireAt', 'PendingDispatch', 'AutoTaskID', 'AssignedCS', 'UpdateTime', ]) logger.info( - '比例结算完成 order=%s ratio=%s player=%s manager=%s source=%s team=%s', - oid, ratio_d, pay_player, pay_manager, source, team_done, + '比例结算完成 order=%s ratio=%s player=%s manager=%s refund=%s source=%s team=%s', + oid, ratio_d, pay_player, pay_manager, merchant_refund, source, team_done, ) return { **preview, 'partial_settle_status': PARTIAL_STATUS_DONE, 'team_settled': team_done, 'source': source, + 'merchant_refund': merchant_refund_result, } @@ -559,7 +693,8 @@ def reject_refund_partial( liyou = (jujue_liyou or '').strip() if not liyou: raise PartialSettleError('请填写拒绝理由') - timing = (settle_timing or 'immediate').strip().lower() + # 默认留给商家结算(客服强制立即结需显式传 immediate) + timing = (settle_timing or 'merchant').strip().lower() if timing not in ('immediate', 'merchant'): raise PartialSettleError('settle_timing 须为 immediate 或 merchant') ratio = parse_settle_ratio(settle_ratio) diff --git a/orders/views/admin.py b/orders/views/admin.py index c2ed7ef..50c994c 100644 --- a/orders/views/admin.py +++ b/orders/views/admin.py @@ -774,7 +774,7 @@ class AdJuJueTuiKuan(APIView): ) settle_ratio = request.data.get('settle_ratio', request.data.get('ratio')) - settle_timing = (request.data.get('settle_timing') or 'immediate').strip() + settle_timing = (request.data.get('settle_timing') or 'merchant').strip() with transaction.atomic(): locked = Order.query.select_for_update().get(OrderID=dingdan_id) diff --git a/users/views/kefu_orders.py b/users/views/kefu_orders.py index c64bc8f..ad857bc 100644 --- a/users/views/kefu_orders.py +++ b/users/views/kefu_orders.py @@ -993,7 +993,7 @@ class KefuRejectRefundView(APIView): dingdan_id = request.data.get('dingdan_id', '').strip() jujue_liyou = request.data.get('jujue_liyou', '').strip() settle_ratio = request.data.get('settle_ratio', request.data.get('ratio')) - settle_timing = (request.data.get('settle_timing') or 'immediate').strip() + settle_timing = (request.data.get('settle_timing') or 'merchant').strip() if not phone or not dingdan_id: return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED)