feat: 拒绝退款可配置比例结算(full×ratio、组队/管事、Status6)
This commit is contained in:
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='拒绝退款默认结算比例',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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='比例结算操作人',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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='客服取消指定'),
|
||||
|
||||
@@ -128,6 +128,7 @@ from .kefu_orders import (
|
||||
KefuGetOrderListView,
|
||||
KefuGetShangjiaOrderListView,
|
||||
KefuMerchantRefundView,
|
||||
KefuPartialSettleView,
|
||||
KefuPlatformRefundView,
|
||||
KefuRecoverOrderView,
|
||||
KefuRejectRefundView,
|
||||
|
||||
+119
-105
@@ -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
|
||||
|
||||
Reference in new issue
Block a user