diff --git a/jituan/migrations/0027_default_partial_settle_ratio.py b/jituan/migrations/0027_default_partial_settle_ratio.py new file mode 100644 index 0000000..b9b742e --- /dev/null +++ b/jituan/migrations/0027_default_partial_settle_ratio.py @@ -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='拒绝退款默认结算比例', + ), + ), + ] diff --git a/jituan/models.py b/jituan/models.py index f6cc034..5b11d8a 100644 --- a/jituan/models.py +++ b/jituan/models.py @@ -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) diff --git a/jituan/services/order_deal.py b/jituan/services/order_deal.py index af35aa5..0158e30 100644 --- a/jituan/services/order_deal.py +++ b/jituan/services/order_deal.py @@ -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: diff --git a/orders/migrations/0020_partial_settle.py b/orders/migrations/0020_partial_settle.py new file mode 100644 index 0000000..3ff3e29 --- /dev/null +++ b/orders/migrations/0020_partial_settle.py @@ -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='比例结算操作人', + ), + ), + ] diff --git a/orders/models.py b/orders/models.py index 10d55e2..63c9d18 100644 --- a/orders/models.py +++ b/orders/models.py @@ -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, diff --git a/orders/services/partial_settle.py b/orders/services/partial_settle.py new file mode 100644 index 0000000..cb5d082 --- /dev/null +++ b/orders/services/partial_settle.py @@ -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} diff --git a/orders/utils.py b/orders/utils.py index ed2c7e2..a83ab72 100644 --- a/orders/utils.py +++ b/orders/utils.py @@ -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}不存在") diff --git a/orders/views/admin.py b/orders/views/admin.py index 6fb0820..c2ed7ef 100644 --- a/orders/views/admin.py +++ b/orders/views/admin.py @@ -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: diff --git a/orders/views/dashou.py b/orders/views/dashou.py index 6768758..0a62212 100644 --- a/orders/views/dashou.py +++ b/orders/views/dashou.py @@ -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)) diff --git a/orders/views/merchant.py b/orders/views/merchant.py index 5fc9e2f..cb1cb11 100644 --- a/orders/views/merchant.py +++ b/orders/views/merchant.py @@ -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: diff --git a/users/urls.py b/users/urls.py index 411549b..af22bce 100644 --- a/users/urls.py +++ b/users/urls.py @@ -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='客服取消指定'), diff --git a/users/views/__init__.py b/users/views/__init__.py index 6300406..b0cbce5 100644 --- a/users/views/__init__.py +++ b/users/views/__init__.py @@ -128,6 +128,7 @@ from .kefu_orders import ( KefuGetOrderListView, KefuGetShangjiaOrderListView, KefuMerchantRefundView, + KefuPartialSettleView, KefuPlatformRefundView, KefuRecoverOrderView, KefuRejectRefundView, diff --git a/users/views/kefu_orders.py b/users/views/kefu_orders.py index 6f2d998..f1f9749 100644 --- a/users/views/kefu_orders.py +++ b/users/views/kefu_orders.py @@ -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