724 lines
28 KiB
Python
724 lines
28 KiB
Python
"""拒绝退款后的可配置比例结算(full 明细 × ratio,Status=6)。"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||
from typing import Any, Optional
|
||
|
||
from django.db import transaction
|
||
from django.db.models import F
|
||
from django.utils import timezone
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
MONEY = Decimal('0.01')
|
||
RATIO_Q = Decimal('0.0001')
|
||
|
||
PARTIAL_STATUS_NONE = 0
|
||
PARTIAL_STATUS_ARMED = 1
|
||
PARTIAL_STATUS_DONE = 2
|
||
|
||
STATUS_TEXT = {
|
||
PARTIAL_STATUS_NONE: '',
|
||
PARTIAL_STATUS_ARMED: '已锁定比例,待结算',
|
||
PARTIAL_STATUS_DONE: '已按比例结算',
|
||
}
|
||
|
||
|
||
class PartialSettleError(Exception):
|
||
"""比例结算业务错误。"""
|
||
|
||
def __init__(self, msg: str, code: int = 400):
|
||
super().__init__(msg)
|
||
self.msg = msg
|
||
self.code = code
|
||
|
||
|
||
def _d(v) -> Decimal:
|
||
try:
|
||
return Decimal(str(v if v is not None else 0)).quantize(MONEY, rounding=ROUND_HALF_UP)
|
||
except Exception:
|
||
return Decimal('0.00')
|
||
|
||
|
||
def parse_settle_ratio(value, *, allow_percent=True) -> Decimal:
|
||
"""解析 0.01~1.00;allow_percent 时 1~100 视为百分比。"""
|
||
if value is None or value == '':
|
||
raise PartialSettleError('请填写结算比例')
|
||
try:
|
||
val = Decimal(str(value).strip())
|
||
except (InvalidOperation, ValueError, TypeError) as e:
|
||
raise PartialSettleError('结算比例无效') from e
|
||
if allow_percent and val > 1:
|
||
val = val / Decimal('100')
|
||
val = val.quantize(RATIO_Q)
|
||
if val < Decimal('0.01') or val > Decimal('1.00'):
|
||
raise PartialSettleError('结算比例须在 1%~100%')
|
||
return val
|
||
|
||
|
||
def club_default_partial_ratio(club_id: str) -> Decimal:
|
||
from jituan.services.order_deal import get_or_create_deal_config
|
||
row = get_or_create_deal_config(club_id or '')
|
||
if not row:
|
||
return Decimal('0.5000')
|
||
try:
|
||
return parse_settle_ratio(getattr(row, 'default_partial_settle_ratio', None) or '0.5')
|
||
except PartialSettleError:
|
||
return Decimal('0.5000')
|
||
|
||
|
||
def preview_partial_amounts(order, ratio: Decimal) -> dict:
|
||
"""
|
||
按 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 (
|
||
calc_lock_shares, get_active_recruit, team_should_split, _d as team_d,
|
||
)
|
||
from orders.models import TeamRecruit, TeamMember
|
||
|
||
oid = getattr(order, 'OrderID', '') or ''
|
||
rec = None
|
||
if team_should_split(order):
|
||
rec = TeamRecruit.query.filter(
|
||
order_id=oid, status=TeamRecruit.STATUS_LOCKED,
|
||
).first()
|
||
if not rec:
|
||
rec = get_active_recruit(oid)
|
||
if rec:
|
||
members = list(
|
||
TeamMember.query.filter(recruit_id=rec.id, join_status=TeamMember.JOIN_ACTIVE)
|
||
)
|
||
if members:
|
||
c = full_player
|
||
if any(team_d(m.share_amount) <= 0 and m.role == TeamMember.ROLE_MEMBER for m in members) or \
|
||
sum((team_d(m.share_amount) for m in members), Decimal('0')) <= 0:
|
||
mate_uids = [m.user_uid for m in members if m.role == TeamMember.ROLE_MEMBER]
|
||
shares = calc_lock_shares(
|
||
c, rec.share_mode, rec.per_person_amount, rec.leader_uid, mate_uids,
|
||
)
|
||
else:
|
||
shares = {m.user_uid: team_d(m.share_amount) for m in members}
|
||
total = sum(shares.values(), Decimal('0.00'))
|
||
if total != c:
|
||
diff = team_d(c - total)
|
||
if rec.leader_uid in shares:
|
||
shares[rec.leader_uid] = team_d(shares[rec.leader_uid] + diff)
|
||
for m in members:
|
||
full_share = team_d(shares.get(m.user_uid, 0))
|
||
team_shares.append({
|
||
'user_uid': m.user_uid,
|
||
'role': m.role,
|
||
'full_share': float(full_share),
|
||
'partial_share': float(_d(full_share * ratio)),
|
||
})
|
||
except Exception as e:
|
||
logger.warning('比例结算预览组队份额失败 %s: %s', getattr(order, 'OrderID', ''), e)
|
||
|
||
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(order_amount),
|
||
}
|
||
|
||
|
||
def build_refund_settle_payload(order, *, viewer_uid: str = '') -> dict:
|
||
"""详情 API 附加:申请/拒绝/同意原因 + 比例结算状态与金额。"""
|
||
from orders.models import RefundRecord
|
||
from backend.utils import fmt_datetime
|
||
|
||
oid = getattr(order, 'OrderID', '') or ''
|
||
apply_reason = ''
|
||
reject_reason = ''
|
||
approve_reason = ''
|
||
apply_status = None
|
||
try:
|
||
rec = RefundRecord.query.filter(OrderID=oid).order_by('-CreateTime').first()
|
||
if rec:
|
||
apply_reason = (rec.Reason or '').strip()
|
||
reject_reason = (rec.RejectReason or '').strip()
|
||
apply_status = rec.ApplyStatus
|
||
if int(rec.ApplyStatus or 0) == 1:
|
||
approve_reason = (getattr(order, 'RefundReason', None) or '').strip()
|
||
except Exception:
|
||
pass
|
||
|
||
if not reject_reason and int(getattr(order, 'Status', 0) or 0) in (6, 8):
|
||
# 历史单:拒绝理由可能只写在订单 RefundReason
|
||
if int(getattr(order, 'PartialSettleStatus', 0) or 0) or int(getattr(order, 'Status', 0) or 0) == 6:
|
||
reject_reason = reject_reason or (getattr(order, 'RefundReason', None) or '').strip()
|
||
|
||
st = int(getattr(order, 'PartialSettleStatus', 0) or 0)
|
||
ratio = getattr(order, 'PartialSettleRatio', None)
|
||
ratio_d = None
|
||
if ratio is not None:
|
||
try:
|
||
ratio_d = parse_settle_ratio(ratio)
|
||
except PartialSettleError:
|
||
ratio_d = None
|
||
|
||
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
|
||
and int(getattr(order, 'Status', 0) or 0) == 8
|
||
and ratio_d is not None
|
||
)
|
||
|
||
my_partial_share = None
|
||
if viewer_uid and ratio_d is not None:
|
||
try:
|
||
from orders.services.team_recruit import get_team_role_for_user
|
||
ctx = get_team_role_for_user(oid, viewer_uid)
|
||
if ctx.get('my_share') is not None:
|
||
my_partial_share = float(_d(Decimal(str(ctx['my_share'])) * ratio_d))
|
||
elif (getattr(order, 'PlayerID', None) or '') == viewer_uid and not ctx.get('has_team'):
|
||
my_partial_share = float(pay_player)
|
||
except Exception:
|
||
pass
|
||
|
||
settled_at = getattr(order, 'PartialSettledAt', None)
|
||
# tuikuan_liyou:优先展示商家申请理由;无记录时回落订单字段(兼容历史)
|
||
show_apply = apply_reason or (
|
||
(getattr(order, 'RefundReason', None) or '').strip()
|
||
if apply_status in (0, None) and not reject_reason else ''
|
||
)
|
||
team_shares = []
|
||
if ratio_d is not None:
|
||
try:
|
||
team_shares = preview_partial_amounts(order, ratio_d).get('team_shares') or []
|
||
except Exception as e:
|
||
logger.warning('比例结算详情组队份额失败 %s: %s', oid, e)
|
||
return {
|
||
'tuikuan_liyou': show_apply,
|
||
'apply_reason': apply_reason,
|
||
'reject_reason': reject_reason,
|
||
'approve_reason': approve_reason,
|
||
'refund_apply_status': apply_status,
|
||
'settle_ratio': str(ratio_d) if ratio_d is not None else '',
|
||
'settle_percent': int((ratio_d * 100).quantize(Decimal('1'))) if ratio_d is not None else None,
|
||
'partial_settle_status': st,
|
||
'partial_settle_status_text': STATUS_TEXT.get(st, ''),
|
||
'full_player_commission': float(full_player),
|
||
'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,
|
||
'my_partial_share': my_partial_share,
|
||
'team_shares': team_shares,
|
||
'default_partial_settle_ratio': str(club_default_partial_ratio(getattr(order, 'ClubID', '') or '')),
|
||
}
|
||
|
||
|
||
def _upsert_refund_reject(order, *, processor_id: str, jujue_liyou: str):
|
||
from orders.models import RefundRecord
|
||
from orders.services.merchant_refund_approve import normalize_processor_id
|
||
|
||
dingdan_id = order.OrderID
|
||
phone = normalize_processor_id(processor_id)
|
||
try:
|
||
refund_record = RefundRecord.query.get(OrderID=dingdan_id)
|
||
refund_record.ApplyStatus = 2
|
||
refund_record.ProcessorID = phone
|
||
refund_record.RejectReason = jujue_liyou or ''
|
||
refund_record.save()
|
||
except RefundRecord.DoesNotExist:
|
||
RefundRecord.query.create(
|
||
OrderID=dingdan_id,
|
||
PlayerID=order.PlayerID or '',
|
||
ApplicantID='',
|
||
ProcessorID=phone,
|
||
Reason=jujue_liyou or '客服拒绝退款',
|
||
ApplyStatus=2,
|
||
Amount=order.Amount,
|
||
PlayerCommission=order.PlayerCommission or 0,
|
||
Description=order.Description or '',
|
||
Remark='客服拒绝退款',
|
||
Nickname=order.Nickname or '',
|
||
RejectReason=jujue_liyou or '',
|
||
)
|
||
|
||
|
||
def arm_partial_settle(
|
||
order,
|
||
*,
|
||
ratio: Decimal,
|
||
jujue_liyou: str,
|
||
operator: str,
|
||
) -> dict:
|
||
"""锁定比例,回 Status=8,清自动结算,待商家/客服强制比例结。"""
|
||
ratio = parse_settle_ratio(ratio)
|
||
if int(order.Status) != 4:
|
||
raise PartialSettleError('订单状态不是退款审核中')
|
||
if int(getattr(order, 'PartialSettleStatus', 0) or 0) == PARTIAL_STATUS_DONE:
|
||
raise PartialSettleError('订单已比例结算,无法重复操作')
|
||
|
||
preview = preview_partial_amounts(order, ratio)
|
||
order.PartialSettleRatio = ratio
|
||
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
|
||
order.PendingDispatch = False
|
||
order.AutoTaskID = ''
|
||
order.SettlementTime = timezone.now()
|
||
if jujue_liyou:
|
||
order.RefundReason = jujue_liyou
|
||
if operator:
|
||
order.AssignedCS = (operator or '')[:20]
|
||
order.save(update_fields=[
|
||
'PartialSettleRatio', 'PartialSettleStatus',
|
||
'PartialSettlePlayerAmount', 'PartialSettleManagerAmount',
|
||
'PartialSettleMerchantRefund',
|
||
'Status', 'AutoSettleEligible', 'AutoExpireAt', 'PendingDispatch', 'AutoTaskID',
|
||
'SettlementTime', 'RefundReason', 'AssignedCS', 'UpdateTime',
|
||
])
|
||
_upsert_refund_reject(order, processor_id=operator, jujue_liyou=jujue_liyou)
|
||
return preview
|
||
|
||
|
||
def _credit_team_or_solo_partial(order, *, ratio: Decimal, pay_player: Decimal) -> bool:
|
||
"""组队:full 份额 × ratio 入账;否则单人折后入账。返回是否组队。"""
|
||
from orders.services.team_recruit import (
|
||
calc_lock_shares, get_active_recruit, lock_recruit, team_should_split, _d as team_d, _log,
|
||
)
|
||
from orders.models import TeamRecruit, TeamMember, TeamSettleLedger
|
||
from users.business_models import User
|
||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||
|
||
oid = order.OrderID
|
||
rec = get_active_recruit(oid)
|
||
if rec and rec.status == TeamRecruit.STATUS_RECRUITING:
|
||
lock_recruit(rec.id, actor_uid='system', reason='partial_settle_force_lock')
|
||
|
||
if not team_should_split(order):
|
||
# 仍有有效组队则禁止全额打队长
|
||
if get_active_recruit(oid):
|
||
raise PartialSettleError('存在有效组队但未锁定,无法比例结算')
|
||
return False
|
||
|
||
rec = TeamRecruit.objects.select_for_update().filter(
|
||
order_id=oid, status=TeamRecruit.STATUS_LOCKED,
|
||
).first()
|
||
if not rec:
|
||
return False
|
||
|
||
members = list(
|
||
TeamMember.query.filter(recruit_id=rec.id, join_status=TeamMember.JOIN_ACTIVE)
|
||
)
|
||
if not members:
|
||
return False
|
||
|
||
c = team_d(order.PlayerCommission)
|
||
if any(team_d(m.share_amount) <= 0 and m.role == TeamMember.ROLE_MEMBER for m in members) or \
|
||
sum((team_d(m.share_amount) for m in members), Decimal('0')) <= 0:
|
||
mate_uids = [m.user_uid for m in members if m.role == TeamMember.ROLE_MEMBER]
|
||
shares = calc_lock_shares(c, rec.share_mode, rec.per_person_amount, rec.leader_uid, mate_uids)
|
||
for m in members:
|
||
m.share_amount = shares.get(m.user_uid, Decimal('0.00'))
|
||
m.save(update_fields=['share_amount', 'UpdateTime'])
|
||
|
||
total = sum((team_d(m.share_amount) for m in members), Decimal('0.00'))
|
||
if total != c:
|
||
diff = team_d(c - total)
|
||
for m in members:
|
||
if m.role == TeamMember.ROLE_LEADER:
|
||
m.share_amount = team_d(m.share_amount) + diff
|
||
m.save(update_fields=['share_amount', 'UpdateTime'])
|
||
break
|
||
|
||
# 折后份额合计对齐 pay_player(尾差给队长)
|
||
partial_map = {}
|
||
for m in members:
|
||
partial_map[m.user_uid] = _d(team_d(m.share_amount) * ratio)
|
||
sum_partial = sum(partial_map.values(), Decimal('0.00'))
|
||
if sum_partial != pay_player:
|
||
for m in members:
|
||
if m.role == TeamMember.ROLE_LEADER:
|
||
partial_map[m.user_uid] = _d(partial_map[m.user_uid] + (pay_player - sum_partial))
|
||
break
|
||
|
||
for m in members:
|
||
amt = partial_map.get(m.user_uid, Decimal('0.00'))
|
||
biz_id = f'{oid}:team:{m.user_uid}:partial_settle'
|
||
if TeamSettleLedger.query.filter(biz_id=biz_id).exists():
|
||
continue
|
||
# 禁止与全额结单幂等键混用:若已有全额入账则报错
|
||
full_biz = f'{oid}:team:{m.user_uid}'
|
||
if TeamSettleLedger.query.filter(biz_id=full_biz).exists():
|
||
raise PartialSettleError('订单已全额组队结算,无法比例结算')
|
||
try:
|
||
user = User.query.get(UserUID=m.user_uid)
|
||
profile = user.DashouProfile
|
||
except Exception:
|
||
logger.error('比例结算组队打手不存在 %s %s', oid, m.user_uid)
|
||
continue
|
||
credit_dashou_order_settle(
|
||
order=order,
|
||
dashou_profile=profile,
|
||
user_id=m.user_uid,
|
||
amount=amt,
|
||
set_idle_status=True,
|
||
increment_chengjiao=True,
|
||
settle_biz_id=biz_id,
|
||
)
|
||
TeamSettleLedger(
|
||
club_id=rec.club_id,
|
||
order_id=oid,
|
||
user_uid=m.user_uid,
|
||
amount=amt,
|
||
biz_id=biz_id,
|
||
).save()
|
||
|
||
now = timezone.now()
|
||
rec.status = TeamRecruit.STATUS_FINISHED
|
||
rec.closed_at = now
|
||
rec.save(update_fields=['status', 'closed_at', 'UpdateTime'])
|
||
_log(rec.club_id, oid, rec.id, 'system', 'partial_settle', f'ratio={ratio} total={pay_player}')
|
||
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,
|
||
*,
|
||
ratio: Optional[Decimal] = None,
|
||
operator: str = '',
|
||
source: str = '',
|
||
) -> dict:
|
||
"""
|
||
统一比例结算:full×ratio 入账打手/组队/管事,Status=6,写快照。
|
||
调用方须保证在合适的业务状态(4 立即结 / 8 且已锁定)。
|
||
"""
|
||
from orders.models import Order
|
||
from users.business_models import User
|
||
from users.models import UserShangjia
|
||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||
from orders.utils import settle_shangjia_order_guanshi_fenhong
|
||
from backend.utils import update_dashou_daily_by_action, update_shangjia_daily
|
||
|
||
locked = Order.objects.select_for_update().select_related('shangjia_kuozhan').get(pk=order.pk)
|
||
st = int(getattr(locked, 'PartialSettleStatus', 0) or 0)
|
||
if st == PARTIAL_STATUS_DONE:
|
||
raise PartialSettleError('订单已比例结算', code=400)
|
||
if int(locked.Status or 0) == 3:
|
||
raise PartialSettleError('订单已全额结单,无法比例结算')
|
||
if int(locked.Status or 0) == 5:
|
||
raise PartialSettleError('订单已退款,无法比例结算')
|
||
if int(locked.Status or 0) == 6:
|
||
raise PartialSettleError('订单已关闭(退款失败),无法再结算')
|
||
|
||
if ratio is not None:
|
||
ratio_d = parse_settle_ratio(ratio)
|
||
elif locked.PartialSettleRatio is not None:
|
||
ratio_d = parse_settle_ratio(locked.PartialSettleRatio)
|
||
else:
|
||
raise PartialSettleError('缺少结算比例')
|
||
|
||
# 允许:退款审核中立即结;或已锁定待结且 Status=8
|
||
status = int(locked.Status or 0)
|
||
if status == 4:
|
||
pass
|
||
elif status == 8 and st in (PARTIAL_STATUS_NONE, PARTIAL_STATUS_ARMED):
|
||
# NONE 兼容强制比例结传入 ratio
|
||
pass
|
||
else:
|
||
raise PartialSettleError(f'当前状态不允许比例结算(status={status}, partial={st})')
|
||
|
||
if not locked.PlayerID:
|
||
raise PartialSettleError('订单没有接单打手')
|
||
if locked.PlayerCommission is None:
|
||
raise PartialSettleError('订单缺少打手分成金额')
|
||
|
||
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:
|
||
try:
|
||
dashou_user = User.query.get(UserUID=dashou_id)
|
||
dashou_profile = dashou_user.DashouProfile
|
||
except (User.DoesNotExist, AttributeError) as e:
|
||
raise PartialSettleError('接单打手不存在或信息不完整') from e
|
||
credit_dashou_order_settle(
|
||
order=locked,
|
||
dashou_profile=dashou_profile,
|
||
user_id=dashou_id,
|
||
amount=pay_player,
|
||
set_idle_status=True,
|
||
increment_chengjiao=True,
|
||
settle_biz_id=f'{oid}:partial_settle',
|
||
)
|
||
|
||
merchant_refund_result = {'credited': False, 'amount': '0.00'}
|
||
if int(locked.Platform or 0) == 2:
|
||
try:
|
||
mid = locked.shangjia_kuozhan.MerchantID
|
||
if mid:
|
||
try:
|
||
sj_user = User.query.get(UserUID=mid)
|
||
UserShangjia.query.filter(id=sj_user.ShopProfile.id).update(
|
||
chengjiao=F('chengjiao') + 1,
|
||
)
|
||
except (User.DoesNotExist, AttributeError):
|
||
logger.warning('比例结算:商家 %s 不存在,跳过成交+1', mid)
|
||
# 日统计:成交口径记 Amount×ratio;退还另记 action=3
|
||
try:
|
||
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:
|
||
logger.warning('比例结算更新商家成交失败: %s', e)
|
||
|
||
settle_shangjia_order_guanshi_fenhong(
|
||
locked,
|
||
dashou_id,
|
||
amount=pay_manager,
|
||
biz_id=f'{oid}:guanshi:merchant:partial_settle',
|
||
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,
|
||
amount=pay_player,
|
||
action=2,
|
||
)
|
||
except Exception as e:
|
||
logger.error('比例结算打手日统计失败: %s', e)
|
||
|
||
now = timezone.now()
|
||
locked.PartialSettleRatio = ratio_d
|
||
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
|
||
locked.AutoSettleEligible = False
|
||
locked.AutoExpireAt = None
|
||
locked.PendingDispatch = False
|
||
locked.AutoTaskID = ''
|
||
if operator:
|
||
locked.AssignedCS = (operator or '')[:20]
|
||
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 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,
|
||
}
|
||
|
||
|
||
def reject_refund_partial(
|
||
order,
|
||
*,
|
||
jujue_liyou: str,
|
||
settle_ratio,
|
||
settle_timing: str,
|
||
operator: str,
|
||
) -> dict:
|
||
"""
|
||
客服/后台拒绝退款入口。
|
||
settle_timing: immediate | merchant
|
||
"""
|
||
liyou = (jujue_liyou or '').strip()
|
||
if not liyou:
|
||
raise PartialSettleError('请填写拒绝理由')
|
||
# 默认留给商家结算(客服强制立即结需显式传 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)
|
||
|
||
if timing == 'merchant':
|
||
preview = arm_partial_settle(
|
||
order, ratio=ratio, jujue_liyou=liyou, operator=operator,
|
||
)
|
||
return {'timing': 'merchant', 'status': 8, **preview}
|
||
|
||
# 立即比例结:先写拒绝理由到订单,再结算
|
||
if liyou:
|
||
order.RefundReason = liyou
|
||
order.save(update_fields=['RefundReason', 'UpdateTime'])
|
||
_upsert_refund_reject(order, processor_id=operator, jujue_liyou=liyou)
|
||
result = settle_merchant_order_partial(
|
||
order, ratio=ratio, operator=operator, source='reject_immediate',
|
||
)
|
||
return {'timing': 'immediate', 'status': 6, **result}
|