316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""成交指标 + 可控自动结算(俱乐部开关,默认全关)。
|
||
|
||
对齐设计文档:成交率与自动结算_设计方案.md
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import timedelta
|
||
from decimal import Decimal
|
||
|
||
from django.db import transaction
|
||
from django.db.models import F
|
||
from django.utils import timezone
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def get_or_create_deal_config(club_id: str):
|
||
from jituan.models import ClubOrderDealConfig
|
||
cid = (club_id or '').strip()
|
||
if not cid:
|
||
return None
|
||
row = ClubOrderDealConfig.query.filter(club_id=cid).first()
|
||
if not row:
|
||
row = ClubOrderDealConfig(club_id=cid)
|
||
row.save()
|
||
return row
|
||
|
||
|
||
def order_auto_settle_payload(order) -> dict:
|
||
"""
|
||
小程序列表/详情展示用。
|
||
仅 Status=8 且 AutoSettleEligible 且有 AutoExpireAt 时返回字段;否则空 dict。
|
||
前端有字段才展示,避免关开关/老单误报。
|
||
"""
|
||
from backend.utils import fmt_datetime
|
||
|
||
if not order:
|
||
return {}
|
||
try:
|
||
status = int(getattr(order, 'Status', 0) or 0)
|
||
except (TypeError, ValueError):
|
||
status = 0
|
||
if status != 8:
|
||
return {}
|
||
if not bool(getattr(order, 'AutoSettleEligible', False)):
|
||
return {}
|
||
expire_at = getattr(order, 'AutoExpireAt', None)
|
||
if not expire_at:
|
||
return {}
|
||
now = timezone.now()
|
||
# 与项目 USE_TZ 对齐,避免剩余秒数偏差
|
||
if timezone.is_aware(expire_at) and timezone.is_naive(now):
|
||
now = timezone.make_aware(now, timezone.get_current_timezone())
|
||
elif timezone.is_naive(expire_at) and timezone.is_aware(now):
|
||
expire_at = timezone.make_aware(expire_at, timezone.get_current_timezone())
|
||
remain = int((expire_at - now).total_seconds())
|
||
return {
|
||
'auto_settle_eligible': True,
|
||
'auto_expire_at': fmt_datetime(expire_at),
|
||
'auto_settle_remain_seconds': max(0, remain),
|
||
}
|
||
|
||
|
||
def config_to_dict(row) -> dict:
|
||
if not row:
|
||
return {
|
||
'club_id': '',
|
||
'auto_settle_enabled': False,
|
||
'auto_settle_enabled_at': '',
|
||
'hours_platform': 48,
|
||
'hours_merchant': 48,
|
||
'stats_enabled': False,
|
||
'stats_since': '',
|
||
}
|
||
return {
|
||
'club_id': row.club_id,
|
||
'auto_settle_enabled': bool(row.auto_settle_enabled),
|
||
'auto_settle_enabled_at': row.auto_settle_enabled_at.isoformat() if row.auto_settle_enabled_at else '',
|
||
'hours_platform': int(row.hours_platform or 48),
|
||
'hours_merchant': int(row.hours_merchant or 48),
|
||
'stats_enabled': bool(row.stats_enabled),
|
||
'stats_since': row.stats_since.isoformat() if row.stats_since else '',
|
||
}
|
||
|
||
|
||
def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict:
|
||
"""保存配置。关自动结算时清空本俱乐部所有 eligible。"""
|
||
row = get_or_create_deal_config(club_id)
|
||
if not row:
|
||
raise ValueError('俱乐部不能为空')
|
||
|
||
was_auto = bool(row.auto_settle_enabled)
|
||
was_stats = bool(row.stats_enabled)
|
||
now = timezone.now()
|
||
|
||
if 'hours_platform' in data and data['hours_platform'] is not None:
|
||
row.hours_platform = max(1, min(int(data['hours_platform']), 720))
|
||
if 'hours_merchant' in data and data['hours_merchant'] is not None:
|
||
row.hours_merchant = max(1, min(int(data['hours_merchant']), 720))
|
||
|
||
if 'auto_settle_enabled' in data and data['auto_settle_enabled'] is not None:
|
||
enabled = bool(data['auto_settle_enabled'])
|
||
row.auto_settle_enabled = enabled
|
||
if enabled and not was_auto:
|
||
row.auto_settle_enabled_at = now
|
||
if not enabled:
|
||
row.auto_settle_enabled_at = None
|
||
_clear_club_auto_settle_eligible(row.club_id)
|
||
|
||
if 'stats_enabled' in data and data['stats_enabled'] is not None:
|
||
enabled = bool(data['stats_enabled'])
|
||
row.stats_enabled = enabled
|
||
if enabled and not was_stats:
|
||
row.stats_since = now
|
||
if not enabled:
|
||
# 停止写入;保留 since 供只读理解历史起点;再次开启会刷新 since
|
||
pass
|
||
if enabled and was_stats and data.get('reset_stats_since'):
|
||
row.stats_since = now
|
||
|
||
# 若刚打开统计且 since 空
|
||
if row.stats_enabled and not row.stats_since:
|
||
row.stats_since = now
|
||
|
||
row.updated_by = (updated_by or '')[:32]
|
||
row.save()
|
||
return config_to_dict(row)
|
||
|
||
|
||
def _clear_club_auto_settle_eligible(club_id: str):
|
||
from orders.models import Order
|
||
Order.objects.filter(ClubID=club_id, AutoSettleEligible=True).update(
|
||
AutoSettleEligible=False,
|
||
AutoExpireAt=None,
|
||
PendingDispatch=False,
|
||
AutoTaskID='',
|
||
)
|
||
|
||
|
||
def apply_enter_status_8(instance):
|
||
"""
|
||
订单进入结算中(8):写 SettlementTime;若俱乐部自动结算已开且本次开启后进入则打 eligible。
|
||
由 signal 调用。instance 为即将 save 的 Order。
|
||
"""
|
||
now = timezone.now()
|
||
instance.SettlementTime = now
|
||
instance.PendingDispatch = False
|
||
instance.AutoTaskID = ''
|
||
|
||
club_id = (getattr(instance, 'ClubID', None) or '').strip()
|
||
cfg = get_or_create_deal_config(club_id) if club_id else None
|
||
if (
|
||
cfg
|
||
and cfg.auto_settle_enabled
|
||
and cfg.auto_settle_enabled_at
|
||
and now >= cfg.auto_settle_enabled_at
|
||
):
|
||
hours = int(cfg.hours_merchant if instance.Platform == 2 else cfg.hours_platform) or 48
|
||
instance.AutoSettleEligible = True
|
||
instance.AutoExpireAt = now + timedelta(hours=hours)
|
||
else:
|
||
instance.AutoSettleEligible = False
|
||
instance.AutoExpireAt = None
|
||
|
||
|
||
def apply_leave_status_8(instance):
|
||
instance.SettlementTime = None
|
||
instance.AutoSettleEligible = False
|
||
instance.AutoExpireAt = None
|
||
instance.PendingDispatch = False
|
||
instance.AutoTaskID = ''
|
||
|
||
|
||
@transaction.atomic
|
||
def mark_order_completed_unified(order, *, source='auto_settle', operator=''):
|
||
"""
|
||
与客服强制结单等价的核心结算(打手入账 + 商家成交 + 管事分红)。
|
||
自动结算路径额外要求 AutoSettleEligible。
|
||
"""
|
||
from orders.models import Order
|
||
from users.business_models import User
|
||
from orders.utils import settle_shangjia_order_guanshi_fenhong
|
||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||
|
||
locked = Order.objects.select_for_update().filter(OrderID=order.OrderID).first()
|
||
if not locked:
|
||
return False, '订单不存在'
|
||
if locked.Status != 8:
|
||
return False, f'状态不是结算中({locked.Status})'
|
||
if source == 'auto_settle':
|
||
if not locked.AutoSettleEligible:
|
||
return False, '非自动结算资格单'
|
||
if locked.AutoExpireAt and locked.AutoExpireAt > timezone.now():
|
||
return False, '未到自动结算时间'
|
||
|
||
dashou_id = locked.PlayerID
|
||
dashou_fencheng = locked.PlayerCommission
|
||
if not dashou_id:
|
||
return False, '无接单打手'
|
||
if not dashou_fencheng or dashou_fencheng <= 0:
|
||
return False, '打手分成无效'
|
||
|
||
try:
|
||
dashou_user = User.query.get(UserUID=dashou_id)
|
||
dashou_profile = dashou_user.DashouProfile
|
||
except Exception:
|
||
return False, '打手不存在'
|
||
|
||
# 组队分账优先;无有效组队则全额给队长(原逻辑)
|
||
# 若本应组队分账却失败/异常:禁止回退全额队长(避免队友白做或重复入账)
|
||
try:
|
||
from orders.services.team_recruit import settle_team_or_legacy, get_active_recruit, team_should_split
|
||
from orders.models import TeamSettleLedger, TeamRecruit
|
||
rec = get_active_recruit(locked.OrderID)
|
||
if rec and rec.status == TeamRecruit.STATUS_RECRUITING:
|
||
# 进8锁失败的兜底:结单前再强制锁一次
|
||
from orders.services.team_recruit import lock_recruit
|
||
lock_recruit(rec.id, actor_uid='system', reason='settle_force_lock')
|
||
team_done = settle_team_or_legacy(locked, set_idle_leader=True)
|
||
except Exception as e:
|
||
logger.error('组队分账异常 %s: %s', locked.OrderID, e)
|
||
from orders.models import TeamSettleLedger
|
||
if TeamSettleLedger.query.filter(order_id=locked.OrderID).exists():
|
||
return False, f'组队分账异常且已有入账记录: {e}'
|
||
try:
|
||
from orders.services.team_recruit import team_should_split, get_active_recruit
|
||
if get_active_recruit(locked.OrderID) or team_should_split(locked):
|
||
return False, f'组队分账失败: {e}'
|
||
except Exception:
|
||
pass
|
||
team_done = False
|
||
|
||
if not team_done:
|
||
try:
|
||
from orders.services.team_recruit import get_active_recruit
|
||
if get_active_recruit(locked.OrderID):
|
||
return False, '存在有效组队但分账未完成,已阻止全额打给队长'
|
||
except Exception:
|
||
pass
|
||
credit_dashou_order_settle(
|
||
order=locked,
|
||
dashou_profile=dashou_profile,
|
||
user_id=dashou_id,
|
||
amount=dashou_fencheng,
|
||
)
|
||
|
||
if locked.Platform == 2:
|
||
try:
|
||
ext = getattr(locked, 'shangjia_kuozhan', None)
|
||
mid = getattr(ext, 'MerchantID', None) if ext else None
|
||
if mid:
|
||
shangjia_user = User.query.get(UserUID=mid)
|
||
profile = shangjia_user.ShopProfile
|
||
type(profile).objects.filter(pk=profile.pk).update(chengjiao=F('chengjiao') + 1)
|
||
try:
|
||
from backend.utils import update_shangjia_daily
|
||
update_shangjia_daily(
|
||
yonghuid=mid,
|
||
amount=Decimal(str(locked.Amount or 0)),
|
||
action=2,
|
||
order_id=locked.OrderID,
|
||
)
|
||
except Exception as e:
|
||
logger.warning('商家日统计跳过 %s: %s', locked.OrderID, e)
|
||
except Exception as e:
|
||
logger.warning('商家成交累加跳过 %s: %s', locked.OrderID, e)
|
||
settle_shangjia_order_guanshi_fenhong(locked, dashou_id)
|
||
|
||
locked.Status = 3
|
||
locked.AutoSettleEligible = False
|
||
locked.AutoExpireAt = None
|
||
# pre_save 会清 SettlementTime;必须写入 update_fields 才能落库
|
||
update_fields = ['Status', 'AutoSettleEligible', 'AutoExpireAt', 'SettlementTime', 'UpdateTime']
|
||
if operator:
|
||
locked.AssignedCS = (operator or '')[:20]
|
||
update_fields.append('AssignedCS')
|
||
locked.save(update_fields=update_fields)
|
||
# 成交指标由 post_save signal 幂等记账,此处不重复调用
|
||
return True, 'ok'
|
||
|
||
|
||
def scan_due_auto_settle(limit: int = 100) -> dict:
|
||
"""只处理 eligible 到期单;无关自动结算的俱乐部空跑。"""
|
||
from jituan.models import ClubOrderDealConfig
|
||
from orders.models import Order
|
||
|
||
clubs = list(
|
||
ClubOrderDealConfig.query.filter(auto_settle_enabled=True).values_list('club_id', flat=True)
|
||
)
|
||
if not clubs:
|
||
return {'scanned': 0, 'settled': 0, 'failed': 0, 'clubs': 0}
|
||
|
||
now = timezone.now()
|
||
qs = (
|
||
Order.objects.filter(
|
||
ClubID__in=list(clubs),
|
||
Status=8,
|
||
AutoSettleEligible=True,
|
||
AutoExpireAt__isnull=False,
|
||
AutoExpireAt__lte=now,
|
||
)
|
||
.order_by('AutoExpireAt')[:limit]
|
||
)
|
||
settled = 0
|
||
failed = 0
|
||
for order in qs:
|
||
ok, msg = mark_order_completed_unified(order, source='auto_settle', operator='auto')
|
||
if ok:
|
||
settled += 1
|
||
logger.info('自动结算成功 %s', order.OrderID)
|
||
else:
|
||
failed += 1
|
||
logger.warning('自动结算跳过 %s: %s', order.OrderID, msg)
|
||
return {'scanned': settled + failed, 'settled': settled, 'failed': failed, 'clubs': len(clubs)}
|