feat: 比例结算退回商家 (1-ratio)×订单金额并落幂等账本
This commit is contained in:
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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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='')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user