feat: 拒绝退款可配置比例结算(full×ratio、组队/管事、Status6)

This commit is contained in:
XingQue committed 2026-08-09 11:58:29 +08:00
1 parent e37ecce54c
commit 26848972ab
13 files changed
+999 -209

No files matched your search

@@ -0,0 +1,23 @@
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0026_dispatch_block_and_merchant_whitelist'),
]
operations = [
migrations.AddField(
model_name='cluborderdealconfig',
name='default_partial_settle_ratio',
field=models.DecimalField(
decimal_places=4,
default=Decimal('0.5000'),
max_digits=5,
verbose_name='拒绝退款默认结算比例',
),
),
]
+5
View File
@@ -700,6 +700,11 @@ class ClubOrderDealConfig(QModel):
dispatch_block_hours = models.PositiveIntegerField(default=24, verbose_name='禁派单超时小时')
stats_enabled = models.BooleanField(default=False, verbose_name='成交指标统计开关')
stats_since = models.DateTimeField(null=True, blank=True, verbose_name='指标统计起点')
# 拒绝退款时客服审核默认比例(可改本次值);0.01~1.00,默认 0.5
default_partial_settle_ratio = models.DecimalField(
max_digits=5, decimal_places=4, default=Decimal('0.5000'),
verbose_name='拒绝退款默认结算比例',
)
updated_by = models.CharField(max_length=32, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
+38
View File
@@ -88,6 +88,9 @@ def order_auto_settle_payload(order) -> dict:
def config_to_dict(row) -> dict:
from decimal import Decimal
default_ratio = '0.5000'
if not row:
return {
'club_id': '',
@@ -99,7 +102,14 @@ def config_to_dict(row) -> dict:
'dispatch_block_hours': 24,
'stats_enabled': False,
'stats_since': '',
'default_partial_settle_ratio': default_ratio,
'default_partial_settle_percent': 50,
}
ratio = getattr(row, 'default_partial_settle_ratio', None)
try:
ratio_d = Decimal(str(ratio if ratio is not None else '0.5')).quantize(Decimal('0.0001'))
except Exception:
ratio_d = Decimal('0.5000')
return {
'club_id': row.club_id,
'auto_settle_enabled': bool(row.auto_settle_enabled),
@@ -110,6 +120,8 @@ def config_to_dict(row) -> dict:
'dispatch_block_hours': int(getattr(row, 'dispatch_block_hours', None) or 24),
'stats_enabled': bool(row.stats_enabled),
'stats_since': row.stats_since.isoformat() if row.stats_since else '',
'default_partial_settle_ratio': str(ratio_d),
'default_partial_settle_percent': int((ratio_d * 100).quantize(Decimal('1'))),
}
@@ -337,6 +349,21 @@ def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict:
if enabled and was_stats and data.get('reset_stats_since'):
row.stats_since = now
if 'default_partial_settle_ratio' in data and data['default_partial_settle_ratio'] is not None:
from decimal import Decimal, InvalidOperation
raw = data['default_partial_settle_ratio']
# 兼容前端传百分比 10~100
try:
val = Decimal(str(raw).strip())
except (InvalidOperation, ValueError, TypeError) as e:
raise ValueError('默认比例结算比例无效') from e
if val > 1:
val = val / Decimal('100')
val = val.quantize(Decimal('0.0001'))
if val < Decimal('0.01') or val > Decimal('1'):
raise ValueError('默认比例结算比例须在 1%~100%')
row.default_partial_settle_ratio = val
# 若刚打开统计且 since 空
if row.stats_enabled and not row.stats_since:
row.stats_since = _default_stats_since()
@@ -378,6 +405,12 @@ def apply_enter_status_8(instance):
instance.PendingDispatch = False
instance.AutoTaskID = ''
# 拒绝退款锁定比例结算:禁止 Celery 按全额自动结
if int(getattr(instance, 'PartialSettleStatus', 0) or 0) in (1, 2):
instance.AutoSettleEligible = False
instance.AutoExpireAt = None
return
club_id = (getattr(instance, 'ClubID', None) or '').strip()
cfg = get_or_create_deal_config(club_id) if club_id else None
enabled_at = _db_datetime(getattr(cfg, 'auto_settle_enabled_at', None)) if cfg else None
@@ -422,6 +455,11 @@ def mark_order_completed_unified(order, *, source='auto_settle', operator=''):
return False, '订单不存在'
if locked.Status != 8:
return False, f'状态不是结算中({locked.Status})'
# 已锁定比例结算的单禁止走全额 Status=3 路径(含自动结算/强制全额结)
if int(getattr(locked, 'PartialSettleStatus', 0) or 0) == 1:
return False, '订单已锁定比例结算,请走比例结算'
if int(getattr(locked, 'PartialSettleStatus', 0) or 0) == 2:
return False, '订单已比例结算'
if source == 'auto_settle':
# 多重闸:资格位 + 到期 + 开关 + 开启时间 + 商家白名单
if not locked.AutoSettleEligible:
+78
View File
@@ -0,0 +1,78 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0019_penalty_spec'),
]
operations = [
migrations.AddField(
model_name='order',
name='PartialSettleManagerAmount',
field=models.DecimalField(
blank=True,
db_column='partial_settle_manager_amount',
decimal_places=2,
max_digits=10,
null=True,
verbose_name='比例结算管事实付',
),
),
migrations.AddField(
model_name='order',
name='PartialSettlePlayerAmount',
field=models.DecimalField(
blank=True,
db_column='partial_settle_player_amount',
decimal_places=2,
max_digits=10,
null=True,
verbose_name='比例结算打手实付',
),
),
migrations.AddField(
model_name='order',
name='PartialSettleRatio',
field=models.DecimalField(
blank=True,
db_column='partial_settle_ratio',
decimal_places=4,
max_digits=5,
null=True,
verbose_name='比例结算比例',
),
),
migrations.AddField(
model_name='order',
name='PartialSettleStatus',
field=models.PositiveSmallIntegerField(
db_column='partial_settle_status',
db_index=True,
default=0,
verbose_name='比例结算状态',
),
),
migrations.AddField(
model_name='order',
name='PartialSettledAt',
field=models.DateTimeField(
blank=True,
db_column='partial_settled_at',
null=True,
verbose_name='比例结算时间',
),
),
migrations.AddField(
model_name='order',
name='PartialSettledBy',
field=models.CharField(
blank=True,
db_column='partial_settled_by',
default='',
max_length=32,
verbose_name='比例结算操作人',
),
),
]
+26
View File
@@ -162,6 +162,32 @@ class Order(QModel):
default=False, db_index=True,
db_column='auto_settle_eligible', verbose_name='允许自动结算',
)
# ---- 拒绝退款后的比例结算快照(原 Amount/分成字段不改写)----
# PartialSettleStatus: 0无 / 1已锁定待结 / 2已结清
PartialSettleRatio = models.DecimalField(
max_digits=5, decimal_places=4, null=True, blank=True,
db_column='partial_settle_ratio', verbose_name='比例结算比例',
)
PartialSettleStatus = models.PositiveSmallIntegerField(
default=0, db_index=True,
db_column='partial_settle_status', verbose_name='比例结算状态',
)
PartialSettlePlayerAmount = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True,
db_column='partial_settle_player_amount', verbose_name='比例结算打手实付',
)
PartialSettleManagerAmount = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True,
db_column='partial_settle_manager_amount', verbose_name='比例结算管事实付',
)
PartialSettledAt = models.DateTimeField(
null=True, blank=True,
db_column='partial_settled_at', verbose_name='比例结算时间',
)
PartialSettledBy = models.CharField(
max_length=32, blank=True, default='',
db_column='partial_settled_by', verbose_name='比例结算操作人',
)
# ---- 时间戳 ----
CreateTime = models.DateTimeField(
auto_now_add=True,
+581
View File
@@ -0,0 +1,581 @@
"""拒绝退款后的可配置比例结算(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 预览(不入账)。组队每人份额 = full 份额 × ratio。"""
ratio = parse_settle_ratio(ratio)
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)
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'))),
'full_player_commission': float(full_player),
'full_manager_commission': float(full_manager),
'partial_player_amount': float(pay_player),
'partial_manager_amount': float(pay_manager),
'team_shares': team_shares,
'order_amount': float(_d(getattr(order, 'Amount', None))),
}
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))
if st == PARTIAL_STATUS_DONE:
pay_player = _d(getattr(order, 'PartialSettlePlayerAmount', None))
pay_manager = _d(getattr(order, 'PartialSettleManagerAmount', None))
elif ratio_d is not None:
pay_player = _d(full_player * ratio_d)
pay_manager = _d(full_manager * ratio_d)
else:
pay_player = Decimal('0.00')
pay_manager = 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 ''
)
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,
'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,
'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.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',
'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
@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'])
dashou_id = locked.PlayerID
oid = locked.OrderID
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',
)
# 商家成交 +1(拒绝路径不退商家款)
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)
try:
update_shangjia_daily(
yonghuid=mid,
amount=_d(locked.Amount),
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='商家订单比例结算分红',
)
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.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',
'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,
)
return {
**preview,
'partial_settle_status': PARTIAL_STATUS_DONE,
'team_settled': team_done,
'source': source,
}
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('请填写拒绝理由')
timing = (settle_timing or 'immediate').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}
+23 -7
View File
@@ -149,15 +149,28 @@ def calc_shangjia_order_fencheng(jine, club_id=None):
return dashou_fencheng, guanshi_fencheng
def settle_shangjia_order_guanshi_fenhong(order, dashou_id):
def settle_shangjia_order_guanshi_fenhong(
order,
dashou_id,
*,
amount=None,
biz_id=None,
shuoming=None,
):
"""
商家订单结单时结算管事分红(幂等,失败不抛异常阻断主流程)。
amount / biz_id:比例结算时传入折后额与带 partial 语义的幂等键;
未传则按订单 ManagerCommission 全额、biz_id=`{order}:guanshi:merchant`。
"""
if getattr(order, 'Platform', None) != 2:
return
guanshi_fencheng = order.ManagerCommission or Decimal('0')
if amount is None:
guanshi_fencheng = order.ManagerCommission or Decimal('0')
else:
guanshi_fencheng = Decimal(str(amount or 0))
if guanshi_fencheng <= 0:
return
@@ -179,18 +192,21 @@ def settle_shangjia_order_guanshi_fenhong(order, dashou_id):
logger.info(f"商家订单管事分红跳过: 打手{dashou_id}无邀请管事")
return
order_id = str(getattr(order, 'OrderID', '') or '')
credit_biz_id = (biz_id or f'{order_id}:guanshi:merchant').strip()
note = (shuoming or '商家订单分红').strip() or '商家订单分红'
try:
with transaction.atomic():
guanshi = UserGuanshi.objects.select_for_update().get(user__UserUID=guanshi_id)
from jituan.services.fund_freeze import credit_guanshi_fenhong
order_id = str(getattr(order, 'OrderID', '') or '')
result = credit_guanshi_fenhong(
user_id=str(guanshi_id),
amount=guanshi_fencheng,
biz_id=f'{order_id}:guanshi:merchant',
biz_id=credit_biz_id,
profile=guanshi,
order_id=order_id,
freeze_reason=f'商家订单分红 {order_id}',
freeze_reason=f'{note} {order_id}',
)
if not result.get('idempotent'):
UserGuanshi.objects.filter(id=guanshi.id).update(
@@ -202,7 +218,7 @@ def settle_shangjia_order_guanshi_fenhong(order, dashou_id):
dingdan_id=order.OrderID,
guanshi=guanshi_id,
dashouid=dashou_id,
shuoming='商家订单分红',
shuoming=note[:100],
fenhong=guanshi_fencheng,
avatar=user_main.Avatar if user_main else None,
nicheng=dashou.nicheng or '未知打手',
@@ -211,7 +227,7 @@ def settle_shangjia_order_guanshi_fenhong(order, dashou_id):
)
logger.info(
f"商家订单管事分红成功: 订单{order.OrderID}, 管事{guanshi_id}, "
f"打手{dashou_id}, 金额{guanshi_fencheng}元"
f"打手{dashou_id}, 金额{guanshi_fencheng}元 biz={credit_biz_id}"
)
except UserGuanshi.DoesNotExist:
logger.warning(f"商家订单管事分红跳过: 管事{guanshi_id}不存在")
+20 -95
View File
@@ -767,111 +767,36 @@ class AdJuJueTuiKuan(APIView):
status=status.HTTP_404_NOT_FOUND
)
# 7. 获取订单的重要字段
jiedan_dashou_id = dingdan_obj.PlayerID # 接单打手ID
dashou_fencheng = dingdan_obj.PlayerCommission # 打手分成金额
fadan_pingtai = dingdan_obj.Platform # 发单平台
# 8. 验证打手分成金额是否存在
if dashou_fencheng is None:
if dingdan_obj.Status != 4:
return Response(
{'code': 400, 'message': '订单缺少打手分成金额', 'data': None},
{'code': 400, 'message': '订单状态不是退款审核中', 'data': None},
status=status.HTTP_400_BAD_REQUEST
)
# 9. 使用事务保证数据一致性
settle_ratio = request.data.get('settle_ratio', request.data.get('ratio'))
settle_timing = (request.data.get('settle_timing') or 'immediate').strip()
with transaction.atomic():
# 9.1 更新订单状态为6(退款失败)
dingdan_obj.Status = 6
dingdan_obj.save()
# 9.2 处理打手结算
if jiedan_dashou_id:
try:
# 查询打手用户主表
dashou_user = User.query.select_related('DashouProfile').get(
UserUID=jiedan_dashou_id,
#user_type='PlayerID'
)
# 获取打手扩展表
dashou_profile = dashou_user.DashouProfile
from jituan.services.fund_freeze import credit_dashou_order_settle
credit_dashou_order_settle(
order=dingdan_obj,
dashou_profile=dashou_profile,
user_id=jiedan_dashou_id,
amount=dashou_fencheng,
)
logger.info(f"拒绝退款成功,打手{jiedan_dashou_id}结算分成:{dashou_fencheng}")
except User.DoesNotExist:
# 打手用户不存在,记录日志但不影响操作
logger.warning(f"拒绝退款:打手{jiedan_dashou_id}不存在,跳过打手结算")
except AttributeError:
# 打手扩展表不存在,记录日志但不影响操作
logger.warning(f"拒绝退款:打手{jiedan_dashou_id}扩展信息不存在,跳过打手结算")
# 9.3 如果是商家发单,还需要更新商家扩展表
if fadan_pingtai == 2:
try:
# 获取商家订单扩展表
shangjia_kuozhan = dingdan_obj.shangjia_kuozhan
shangjia_id = shangjia_kuozhan.MerchantID
if shangjia_id:
# 查询商家用户主表
shangjia_user = User.query.select_related('ShopProfile').get(
UserUID=shangjia_id,
#user_type='shop'
)
# 获取商家扩展表
shangjia_profile = shangjia_user.ShopProfile
# ✅ 确认字段:chengjiao 商家成交订单总量
shangjia_profile.chengjiao += 1
shangjia_profile.save()
logger.info(f"拒绝退款成功,商家{shangjia_id}成交订单+1")
except MerchantOrderExt.DoesNotExist:
# 商家订单扩展表不存在,跳过商家更新
logger.warning(f"拒绝退款:订单{dingdan_id}商家扩展表不存在,跳过商家更新")
except User.DoesNotExist:
# 商家用户不存在,跳过商家更新
logger.warning(f"拒绝退款:商家{shangjia_id}不存在,跳过商家更新")
except AttributeError:
# 商家扩展表不存在,跳过商家更新
logger.warning(f"拒绝退款:商家{shangjia_id}扩展信息不存在,跳过商家更新")
# 9.4 更新退款记录表
locked = Order.query.select_for_update().get(OrderID=dingdan_id)
from orders.services.partial_settle import PartialSettleError, reject_refund_partial
try:
# 查询退款记录表
tuikuan_jilu = RefundRecord.query.get(OrderID=dingdan_id)
result = reject_refund_partial(
locked,
jujue_liyou=jujue_liyou,
settle_ratio=settle_ratio,
settle_timing=settle_timing,
operator=qianzhanghao,
)
except PartialSettleError as pe:
return Response(
{'code': pe.code, 'message': pe.msg, 'data': None},
status=status.HTTP_400_BAD_REQUEST,
)
# ✅ 确认字段:sqzhuangtai 申请状态,改为2(驳回)
tuikuan_jilu.ApplyStatus = 2
# ✅ 确认字段:bhliyou 驳回理由
tuikuan_jilu.RejectReason = jujue_liyou
# ✅ 确认字段:chuliid 处理人ID(使用管理员账号,即手机号)
tuikuan_jilu.ProcessorID = qianzhanghao
tuikuan_jilu.save()
logger.info(f"拒绝退款成功,更新退款记录表,订单:{dingdan_id}")
except RefundRecord.DoesNotExist:
# 退款记录表不存在,记录日志但不影响主流程
logger.warning(f"拒绝退款:订单{dingdan_id}退款记录不存在,跳过更新")
# 10. 返回成功响应
return Response({
'code': 0,
'message': '拒绝退款成功',
'data': None
'data': result,
})
except Exception as e:
+8 -1
View File
@@ -1495,7 +1495,7 @@ class DashouDingdanXiangqingView(APIView):
'nicheng': dingdan.Nickname or '',
'beizhu': dingdan.Remark or '',
**datetime_aliases(dingdan.CreateTime),
'tuikuan_liyou':dingdan.RefundReason or '',
'tuikuan_liyou': dingdan.RefundReason or '',
'dashou_liuyan': dingdan.PlayerRemark or '',
'fuwudashou_id': dingdan.PlayerID or '',
'fadanpingtai': dingdan.Platform or ''
@@ -1510,6 +1510,13 @@ class DashouDingdanXiangqingView(APIView):
)
except Exception:
dingdan_data['tuikuan_tupian'] = []
try:
from orders.services.partial_settle import build_refund_settle_payload
dingdan_data.update(build_refund_settle_payload(dingdan, viewer_uid=yonghuid))
if dingdan_data.get('apply_reason'):
dingdan_data['tuikuan_liyou'] = dingdan_data['apply_reason']
except Exception:
pass
try:
from jituan.services.order_deal import order_auto_settle_payload
dingdan_data.update(order_auto_settle_payload(dingdan))
+75
View File
@@ -782,6 +782,8 @@ class ShangjiaDingdanXiangqingView(APIView):
'sqzhuangtai': shangjia_kuozhan.PenaltyApplyStatus or 0,
'bhliyou': shangjia_kuozhan.RejectReason or '',
'chufa_jieguo': '', # 已弃用
'dashou_fencheng': float(order.PlayerCommission or 0),
'guanshi_fencheng': float(order.ManagerCommission or 0),
# 新增:罚款数据
'fadan': fadan_dict,
@@ -813,6 +815,16 @@ class ShangjiaDingdanXiangqingView(APIView):
except Exception as e:
logger.warning('自动结算展示字段附加失败: %s', e)
try:
from orders.services.partial_settle import build_refund_settle_payload
response_data.update(build_refund_settle_payload(order))
# 申请理由优先;拒绝理由单独字段(勿与罚单 bhliyou 混用)
if response_data.get('apply_reason'):
response_data['tuikuan_liyou'] = response_data['apply_reason']
response_data['tkliyou'] = response_data['apply_reason']
except Exception as e:
logger.warning('退款/比例结算展示字段附加失败: %s', e)
try:
from merchant_ops.services.order_enrich import enrich_order_detail_data
enrich_order_detail_data(response_data, dingdan_id)
@@ -919,6 +931,7 @@ class ShangjiaJiesuanView(APIView):
jine = order.Amount or Decimal('0')
dashou_fencheng = order.PlayerCommission or Decimal('0')
jiedan_dashou_id = order.PlayerID
partial_st = int(getattr(order, 'PartialSettleStatus', 0) or 0)
if not jiedan_dashou_id:
return Response({
@@ -927,6 +940,68 @@ class ShangjiaJiesuanView(APIView):
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
if partial_st == 2:
return Response({
'code': 6,
'msg': '订单已按比例结算,无法再次结单',
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
# ---- 已锁定比例:强制走比例结算(Status→6),禁止全额 Status=3 ----
if partial_st == 1:
from orders.services.partial_settle import (
PartialSettleError, settle_merchant_order_partial,
)
try:
partial_result = settle_merchant_order_partial(
order,
operator=yonghuid,
source='merchant_partial',
)
except PartialSettleError as pe:
return Response({
'code': 8,
'msg': pe.msg,
'data': None
}, status=status.HTTP_400_BAD_REQUEST)
order.refresh_from_db()
if pingfen > 0:
pingfen_obj, created = PlayerRating.query.get_or_create(
PlayerID=jiedan_dashou_id,
defaults={'TotalScore': pingfen, 'RatingCount': 1}
)
if not created:
PlayerRating.query.filter(id=pingfen_obj.id).update(
TotalScore=F('TotalScore') + pingfen,
RatingCount=F('RatingCount') + 1,
)
MerchantOrderExt.query.filter(id=shangjia_kuozhan.id).update(
MerchantReview=liuyan,
)
record_order_action_from_request(
request,
ACTION_SETTLE,
dingdan_id,
merchant_id=yonghuid,
amount=jine,
leixing_id=leixing_id_from_order(order),
)
return Response({
'code': 0,
'msg': '按比例结算成功',
'data': {
'dingdan_id': dingdan_id,
'new_zhuangtai': 6,
'jine': float(jine),
'dashou_fencheng': float(partial_result.get('partial_player_amount') or 0),
'jiedan_dashou_id': jiedan_dashou_id,
'pingfen': pingfen,
'liuyan': liuyan,
'partial_settle': partial_result,
}
}, status=status.HTTP_200_OK)
# ========== 本地订单正常结算给打手 ==========
# 查询打手信息
try:
+2 -1
View File
@@ -10,7 +10,7 @@ from .views import WechatMiniProgramLoginView, UserInfoUpdateView, DashouXinxiAP
YonghuTixianShenheXiugaiView, ChufaJiluHuoquView, DashouShensuView, ShangjiaChufaJiluHuoquView, \
WeixinOfficialCallbackView,KefuLoginView, KefuStatsView, KefuGetOrderTypesView, KefuGetOrderListView, \
KefuGetShangjiaOrderListView, KefuGetOrderDetailView, KefuChangeDashouView, KefuRecoverOrderView, \
KefuPlatformRefundView, KefuRejectRefundView, KefuMerchantRefundView, KefuRejectSettlementView, \
KefuPlatformRefundView, KefuRejectRefundView, KefuMerchantRefundView, KefuPartialSettleView, KefuRejectSettlementView, \
KefuTransferHallView, KefuCancelDesignationView, KefuUpdateOrderGrabRequirementView, \
KefuBatchProductGrabRequirementView, KefuGetDashouListView, KefuGetDashouDetailView, \
KefuUpdateDashouView, KefuPunishmentListView, KefuPunishmentActionView, KefuPunishmentDetailView, \
@@ -107,6 +107,7 @@ urlpatterns = [
path('kefupttk', KefuPlatformRefundView.as_view(), name='客服给平台订单退款'),
path('kefujjtk', KefuRejectRefundView.as_view(), name='客服拒绝商家退款'),
path('kefusjtk', KefuMerchantRefundView.as_view(), name='客服同意商家退款'),
path('kefubljs', KefuPartialSettleView.as_view(), name='客服强制比例结算'),
path('kefujjjs', KefuRejectSettlementView.as_view(), name='客服拒绝结算'),
path('kefuzydt', KefuTransferHallView.as_view(), name='客服订单返回大厅'),
path('kefuqxzd', KefuCancelDesignationView.as_view(), name='客服取消指定'),
+1
View File
@@ -128,6 +128,7 @@ from .kefu_orders import (
KefuGetOrderListView,
KefuGetShangjiaOrderListView,
KefuMerchantRefundView,
KefuPartialSettleView,
KefuPlatformRefundView,
KefuRecoverOrderView,
KefuRejectRefundView,
+119 -105
View File
@@ -980,24 +980,24 @@ class KefuRejectRefundView(APIView):
参数:{
"phone": "客服手机号",
"dingdan_id": "订单ID",
"jujue_liyou": "拒绝理由(可选)"
"jujue_liyou": "拒绝理由(必填)",
"settle_ratio": "结算比例 0.01~1 或 1~100 百分比(必填)",
"settle_timing": "immediate|merchant(必填)"
}
认证:JWT
返回:code=0 成功
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
# 1. 获取参数
phone = request.data.get('phone', '').strip()
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()
if not phone or not dingdan_id:
return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED)
# 2. 客服身份验证
current_user = request.user
if getattr(current_user, 'Phone', '') != phone:
return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED)
@@ -1016,7 +1016,6 @@ class KefuRejectRefundView(APIView):
if kefu.zhuangtai != 1:
return Response({'code': 401, 'msg': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED)
# 3. 查询订单并加锁
try:
with transaction.atomic():
order = Order.objects.select_for_update().get(OrderID=dingdan_id)
@@ -1026,112 +1025,33 @@ class KefuRejectRefundView(APIView):
if _ms:
return _ms
# 4. 验证订单状态为退款审核中(4)
if order.Status != 4:
return Response({'code': 400, 'msg': '订单状态不是退款审核中'}, status=status.HTTP_400_BAD_REQUEST)
# 获取必要字段
jiedan_dashou_id = order.PlayerID
dashou_fencheng = order.PlayerCommission
fadan_pingtai = order.Platform
# 5. 验证打手分成金额是否存在
if dashou_fencheng is None:
return Response({'code': 400, 'msg': '订单缺少打手分成金额'}, status=status.HTTP_400_BAD_REQUEST)
# 6. 更新订单状态为退款失败(6)
order.Status = 6
order.AssignedCS = current_user.Phone # 记录处理客服手机号
if jujue_liyou:
order.RefundReason = jujue_liyou
order.save()
# 7. 给打手结算(返还分成)
if jiedan_dashou_id:
try:
dashou_user = User.query.get(UserUID=jiedan_dashou_id)
dashou_profile = dashou_user.DashouProfile
from jituan.services.fund_freeze import credit_dashou_order_settle
credit_dashou_order_settle(
order=order,
dashou_profile=dashou_profile,
user_id=jiedan_dashou_id,
amount=dashou_fencheng,
)
logger.info(f"拒绝退款:打手 {jiedan_dashou_id} 获得分成 {dashou_fencheng}")
except (User.DoesNotExist, AttributeError):
logger.warning(f"拒绝退款:打手 {jiedan_dashou_id} 不存在或扩展表缺失,跳过结算")
# 8. 如果是商家发单,更新商家成交订单数量
if fadan_pingtai == 2:
try:
# 获取商家订单扩展表
shangjia_kuozhan = order.shangjia_kuozhan
shangjia_id = shangjia_kuozhan.MerchantID
if shangjia_id:
shangjia_user = User.query.get(UserUID=shangjia_id)
shangjia_profile = shangjia_user.ShopProfile
shangjia_profile.chengjiao += 1
shangjia_profile.save()
logger.info(f"拒绝退款:商家 {shangjia_id} 成交订单+1")
# ✅ 新方法(行为驱动,更安全)
update_shangjia_daily(
yonghuid=shangjia_id,
amount=Decimal(str(order.Amount)),
action=2, # 2 = 结算
order_id=dingdan_id,
)
except (ObjectDoesNotExist, User.DoesNotExist, AttributeError):
logger.warning(f"拒绝退款:商家信息不存在,跳过商家更新")
# 9. 更新退款记录表
from orders.services.partial_settle import PartialSettleError, reject_refund_partial
try:
refund_record = RefundRecord.query.get(OrderID=dingdan_id)
refund_record.ApplyStatus = 2 # 2表示拒绝
refund_record.ProcessorID = phone
refund_record.RejectReason = jujue_liyou # 记录驳回理由(假设模型有此字段)
refund_record.save()
logger.info(f"拒绝退款:更新退款记录表,订单 {dingdan_id}")
except RefundRecord.DoesNotExist:
# 如果退款记录不存在,可以创建一条记录(可选)
RefundRecord.query.create(
OrderID=dingdan_id,
PlayerID=jiedan_dashou_id or '',
ApplicantID='',
ProcessorID=phone,
Reason=jujue_liyou or '客服拒绝退款',
ApplyStatus=2,
Amount=order.Amount,
PlayerCommission=dashou_fencheng or 0,
Description=order.Description or '',
Remark='客服拒绝退款',
Nickname=order.Nickname or '',
RejectReason=jujue_liyou or '',
result = reject_refund_partial(
order,
jujue_liyou=jujue_liyou,
settle_ratio=settle_ratio,
settle_timing=settle_timing,
operator=phone,
)
except Exception as e:
logger.error(f"拒绝退款更新退款记录异常: {str(e)}", exc_info=True)
except PartialSettleError as pe:
return Response({'code': pe.code, 'msg': pe.msg}, status=status.HTTP_400_BAD_REQUEST)
# 🔥 新增:调用打手每日统计(成交)
try:
update_dashou_daily_by_action(
yonghuid=order.PlayerID,
amount=dashou_fencheng,
action=2 # 2 表示成交(结算)
)
logger.info(f"打手 {order.PlayerID} 每日统计更新成功")
except Exception as e:
logger.error(f"打手每日统计更新失败: {e}")
# 不影响主流程
# 10. 更新客服统计数据(管理员无客服扩展表,跳过)
if kefu is not None:
kefu.jinrichuli += 1
kefu.jinyuechuli += 1
kefu.zongchuli += 1
kefu.save()
return Response({'code': 0, 'msg': '拒绝退款成功'})
timing_msg = '已锁定比例,待商家结算' if result.get('timing') == 'merchant' else '已按比例结算'
return Response({
'code': 0,
'msg': f'拒绝退款成功,{timing_msg}',
'data': result,
})
except Order.DoesNotExist:
return Response({'code': 404, 'msg': '订单不存在'}, status=status.HTTP_404_NOT_FOUND)
@@ -1140,6 +1060,72 @@ class KefuRejectRefundView(APIView):
return Response({'code': 500, 'msg': '服务器内部错误'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class KefuPartialSettleView(APIView):
"""
客服强制按比例结算(已锁定比例的 Status=8 单)
POST /yonghu/kefubljs
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
phone = request.data.get('phone', '').strip()
dingdan_id = request.data.get('dingdan_id', '').strip()
settle_ratio = request.data.get('settle_ratio', request.data.get('ratio'))
if not phone or not dingdan_id:
return Response({'code': 401, 'msg': '参数缺失'})
current_user = request.user
if getattr(current_user, 'Phone', '') != phone:
return Response({'code': 401, 'msg': '认证失败'})
if not is_kefu_backend_account(current_user):
return Response({'code': 401, 'msg': '认证失败'})
is_admin = is_system_super_admin(current_user) or current_user.UserType == 'admin'
if not is_admin:
try:
kefu = current_user.KefuProfile
except ObjectDoesNotExist:
return Response({'code': 401, 'msg': '认证失败'})
if kefu.zhuangtai != 1:
return Response({'code': 401, 'msg': '客服状态异常'})
try:
with transaction.atomic():
order = Order.objects.select_for_update().get(OrderID=dingdan_id)
from jituan.services.club_user_access import forbid_if_order_mutate_out_of_scope
_ms = forbid_if_order_mutate_out_of_scope(request, order)
if _ms:
return _ms
from orders.services.partial_settle import (
PartialSettleError, settle_merchant_order_partial, PARTIAL_STATUS_ARMED,
)
st = int(getattr(order, 'PartialSettleStatus', 0) or 0)
if st != PARTIAL_STATUS_ARMED and settle_ratio is None:
return Response({'code': 400, 'msg': '订单未锁定比例结算,无法强制比例结'})
if order.Status not in (4, 8):
return Response({'code': 400, 'msg': f'当前状态不允许比例结算: {order.Status}'})
try:
result = settle_merchant_order_partial(
order,
ratio=settle_ratio,
operator=phone,
source='kefu_force_partial',
)
except PartialSettleError as pe:
return Response({'code': pe.code, 'msg': pe.msg})
return Response({'code': 0, 'msg': '比例结算成功', 'data': result})
except Order.DoesNotExist:
return Response({'code': 404, 'msg': '订单不存在'})
except Exception as e:
logger.error('客服强制比例结算异常: %s', e, exc_info=True)
return Response({'code': 500, 'msg': '服务器内部错误'})
class KefuMerchantRefundView(APIView):
"""
客服商家订单退款接口(发单平台=2)
@@ -1694,13 +1680,14 @@ class KefuGetOrderDetailView(APIView):
logger.exception(f"[kefuhqddxq] 打手图片查询异常 dingdan_id={dingdan_id}")
response_data['dashou_images'] = []
# 7. 退款理由 + 证据图
# 7. 退款理由 + 证据图 + 比例结算明细
try:
tuikuan_record = RefundRecord.query.filter(OrderID=dingdan_id).order_by('-CreateTime').first()
response_data['tuikuan_liyou'] = tuikuan_record.Reason if tuikuan_record and tuikuan_record.Reason else ''
from orders.services.partial_settle import build_refund_settle_payload
response_data.update(build_refund_settle_payload(dingdan_obj))
except Exception as e:
logger.exception(f"[kefuhqddxq] 退款理由查询异常 dingdan_id={dingdan_id}")
response_data['tuikuan_liyou'] = ''
logger.exception(f"[kefuhqddxq] 退款/比例结算字段异常 dingdan_id={dingdan_id}")
response_data.setdefault('tuikuan_liyou', '')
response_data.setdefault('reject_reason', '')
try:
from orders.models import RefundEvidenceImage
response_data['tuikuan_tupian'] = list(
@@ -1712,6 +1699,11 @@ class KefuGetOrderDetailView(APIView):
except Exception as e:
logger.exception(f"[kefuhqddxq] 退款图片查询异常 dingdan_id={dingdan_id}")
response_data['tuikuan_tupian'] = []
# 管事分成 full 供客服预览
try:
response_data['guanshi_fencheng'] = float(dingdan_obj.ManagerCommission or 0)
except Exception:
response_data['guanshi_fencheng'] = 0.0
# 8. 指定打手响应
ZHIDING_HF_TEXT = {1: '想接', 2: '不想接', 3: '等会接'}
@@ -1822,6 +1814,28 @@ class KefuForceCompleteView(APIView):
if order.Status != 8:
return Response({'code': 400, 'msg': f'订单状态不是结算中,当前状态: {order.Status}'})
# 已锁定比例:强制结单改为比例结算(Status=6),禁止全额 Status=3
if int(getattr(order, 'PartialSettleStatus', 0) or 0) == 1:
from orders.services.partial_settle import (
PartialSettleError, settle_merchant_order_partial,
)
try:
partial_result = settle_merchant_order_partial(
order, operator=phone, source='kefu_force_partial',
)
except PartialSettleError as pe:
return Response({'code': pe.code, 'msg': pe.msg})
if kefu is not None:
kefu.jinrichuli += 1
kefu.jinyuechuli += 1
kefu.zongchuli += 1
kefu.save()
return Response({
'code': 0,
'msg': '强制比例结算成功',
'data': partial_result,
})
# 4. 校验必要字段
dashou_id = order.PlayerID
dashou_fencheng = order.PlayerCommission