feat: 比例结算退回商家 (1-ratio)×订单金额并落幂等账本

This commit is contained in:
XingQue committed 2026-08-09 12:33:56 +08:00
1 parent 02bbf0faf3
commit 6e885dd41d
5 files changed
+219 -14

No files matched your search

@@ -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',
},
),
]
+26
View File
@@ -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='')
+147 -12
View File
@@ -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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)