fix: 冻结条件可评估+分期规范化,定时扫描更及时
后台条件真正按罚款率/单量等评估;选项式配置所需字段规范化;Celery 改为每5分钟;小程序说明文案接口补齐。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -89,7 +89,7 @@ app.conf.beat_schedule = {
|
||||
# 5. 资金冻结到期解冻(默认无冻结单时几乎空跑)
|
||||
'scan_fund_freeze_due': {
|
||||
'task': 'jituan.tasks.scan_fund_freeze_due',
|
||||
'schedule': crontab(minute='*/15'),
|
||||
'schedule': crontab(minute='*/5'),
|
||||
'options': {
|
||||
'queue': 'periodic_tasks',
|
||||
'priority': 4,
|
||||
|
||||
@@ -118,6 +118,8 @@ def _config_snapshot(cfg: ClubFundFreezeConfig) -> Dict[str, Any]:
|
||||
'installment_json': cfg.installment_json,
|
||||
'condition_json': cfg.condition_json,
|
||||
'freeze_min_days': cfg.freeze_min_days,
|
||||
'display_reason': (cfg.remark or '')[:255],
|
||||
'remark': (cfg.remark or '')[:255],
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +235,11 @@ def credit_role_balance(
|
||||
order_id=(order_id or '')[:64],
|
||||
czjilu_id=(czjilu_id or '')[:64],
|
||||
biz_id=biz_id[:64],
|
||||
freeze_reason=(freeze_reason or f'{source_type}冻结')[:255],
|
||||
freeze_reason=(
|
||||
freeze_reason
|
||||
or (getattr(cfg, 'remark', None) if cfg else None)
|
||||
or f'{source_type}冻结'
|
||||
)[:255],
|
||||
config_snapshot=_config_snapshot(cfg),
|
||||
current_step=1,
|
||||
next_eligible_at=next_at,
|
||||
@@ -381,12 +387,14 @@ def apply_unfreeze_step(
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
执行一笔冻结单的当前分期步(骨架:先支持到期/手动;条件解冻 P4 再接指标)。
|
||||
force=True 时忽略 next_eligible_at(后台手动)。
|
||||
执行一笔冻结单的当前分期步。
|
||||
force=True:后台手动,跳过时间/条件门槛。
|
||||
条件未达标时不推进步数、不改 next_eligible_at,供定时任务下次重试。
|
||||
"""
|
||||
from django.db import transaction
|
||||
from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan
|
||||
from jituan.models import FundUnfreezeEvent
|
||||
from jituan.services.fund_freeze_conditions import evaluate_unfreeze_conditions
|
||||
|
||||
with transaction.atomic():
|
||||
ledger = (
|
||||
@@ -400,29 +408,37 @@ def apply_unfreeze_step(
|
||||
return {'ok': False, 'msg': '已完结或已作废'}
|
||||
|
||||
now = timezone.now()
|
||||
if not force and ledger.next_eligible_at and ledger.next_eligible_at > now:
|
||||
return {'ok': False, 'msg': '未到解冻时间', 'next_eligible_at': ledger.next_eligible_at}
|
||||
|
||||
snap = ledger.config_snapshot or {}
|
||||
trigger_mode = (snap.get('unfreeze_trigger') or 'time_only')
|
||||
min_days = max(0, int(snap.get('freeze_min_days') or 0))
|
||||
frozen_at = ledger.frozen_at or now
|
||||
min_days_ok = force or (now >= frozen_at + timedelta(days=min_days))
|
||||
time_ok = force or (
|
||||
not ledger.next_eligible_at or ledger.next_eligible_at <= now
|
||||
)
|
||||
|
||||
if trigger_mode == 'manual_only' and not force:
|
||||
return {'ok': False, 'msg': '仅允许手动解冻'}
|
||||
# condition_*:自动任务暂不评估指标(P4);手动 force 可解
|
||||
if trigger_mode == 'condition_only' and not force:
|
||||
return {'ok': False, 'msg': '条件解冻尚未启用自动评估'}
|
||||
|
||||
steps = _plan_steps(snap)
|
||||
step_idx = max(0, int(ledger.current_step or 1) - 1)
|
||||
if step_idx >= len(steps):
|
||||
step = {'seq': ledger.current_step, 'type': 'remain', 'value': 1}
|
||||
step = {'seq': ledger.current_step, 'type': 'remain', 'value': 1, 'need_condition': False}
|
||||
else:
|
||||
step = steps[step_idx]
|
||||
need_step_cond = bool(step.get('need_condition'))
|
||||
|
||||
amount = _step_unfreeze_amount(ledger, step)
|
||||
if amount <= 0:
|
||||
ledger.status = FundFreezeLedger.STATUS_DONE
|
||||
ledger.save(update_fields=['status', 'UpdateTime'])
|
||||
return {'ok': True, 'amount': Decimal('0.00'), 'done': True}
|
||||
cond_ok = True
|
||||
cond_snap: Dict[str, Any] = {}
|
||||
need_eval = (
|
||||
not force
|
||||
and (
|
||||
need_step_cond
|
||||
or trigger_mode in (
|
||||
'condition_only', 'time_and_condition', 'time_or_condition',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
profile_map = {
|
||||
ROLE_DASHOU: UserDashou,
|
||||
@@ -442,6 +458,63 @@ def apply_unfreeze_step(
|
||||
if not profile:
|
||||
return {'ok': False, 'msg': '角色扩展表不存在'}
|
||||
|
||||
if need_eval:
|
||||
cond_ok, cond_snap = evaluate_unfreeze_conditions(
|
||||
ledger.club_id,
|
||||
ledger.user_id,
|
||||
ledger.role,
|
||||
snap.get('condition_json') or {},
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
if not force:
|
||||
if trigger_mode == 'time_only':
|
||||
if not time_ok:
|
||||
return {'ok': False, 'msg': '未到解冻时间', 'next_eligible_at': ledger.next_eligible_at}
|
||||
elif trigger_mode == 'condition_only':
|
||||
if not min_days_ok:
|
||||
return {'ok': False, 'msg': f'未满最短冻结天数({min_days}天)'}
|
||||
if not cond_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
'msg': '条件未达标',
|
||||
'condition': cond_snap,
|
||||
'retry': True,
|
||||
}
|
||||
elif trigger_mode == 'time_and_condition':
|
||||
if not time_ok:
|
||||
return {'ok': False, 'msg': '未到解冻时间', 'next_eligible_at': ledger.next_eligible_at}
|
||||
if not cond_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
'msg': '已到期但条件未达标,等待达标后自动解',
|
||||
'condition': cond_snap,
|
||||
'retry': True,
|
||||
}
|
||||
elif trigger_mode == 'time_or_condition':
|
||||
if not (time_ok or cond_ok):
|
||||
return {
|
||||
'ok': False,
|
||||
'msg': '未到期且条件未达标',
|
||||
'condition': cond_snap,
|
||||
'retry': True,
|
||||
}
|
||||
|
||||
# 分期单步要求达标时,即使全局是 time_only / time_or 已到期,也仍需条件
|
||||
if need_step_cond and not cond_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
'msg': '本分期步要求条件达标',
|
||||
'condition': cond_snap,
|
||||
'retry': True,
|
||||
}
|
||||
|
||||
amount = _step_unfreeze_amount(ledger, step)
|
||||
if amount <= 0:
|
||||
ledger.status = FundFreezeLedger.STATUS_DONE
|
||||
ledger.save(update_fields=['status', 'UpdateTime'])
|
||||
return {'ok': True, 'amount': Decimal('0.00'), 'done': True}
|
||||
|
||||
# 冻结池不足则按实际可解
|
||||
cur_frozen = _money(getattr(profile, 'dongjie_yue', 0) or 0)
|
||||
if cur_frozen < amount:
|
||||
@@ -472,19 +545,22 @@ def apply_unfreeze_step(
|
||||
else:
|
||||
ledger.status = FundFreezeLedger.STATUS_PARTIAL
|
||||
ledger.current_step = next_step
|
||||
# 下一步到期时间
|
||||
if next_step - 1 < len(steps):
|
||||
nxt = steps[next_step - 1]
|
||||
days = int(nxt.get('after_days') or 0)
|
||||
# after_days 相对冻结日
|
||||
ledger.next_eligible_at = ledger.frozen_at + timedelta(days=days)
|
||||
ledger.next_eligible_at = frozen_at + timedelta(days=days)
|
||||
else:
|
||||
# 无更多配置步:剩余立即进入可扫状态,下一步按 remain 吃完
|
||||
ledger.next_eligible_at = now
|
||||
|
||||
ledger.save(update_fields=[
|
||||
'amount_unfrozen', 'status', 'current_step', 'next_eligible_at', 'UpdateTime',
|
||||
])
|
||||
|
||||
event_trigger = trigger
|
||||
if not force and trigger_mode in ('condition_only', 'time_or_condition') and cond_ok and not time_ok:
|
||||
event_trigger = 'condition'
|
||||
|
||||
FundUnfreezeEvent.query.create(
|
||||
ledger_id=ledger.id,
|
||||
club_id=ledger.club_id,
|
||||
@@ -492,10 +568,10 @@ def apply_unfreeze_step(
|
||||
role=ledger.role,
|
||||
step_seq=int(step.get('seq') or ledger.current_step),
|
||||
amount=amount,
|
||||
trigger=trigger,
|
||||
reason=(reason or f'{trigger}解冻')[:255],
|
||||
trigger=event_trigger,
|
||||
reason=(reason or f'{event_trigger}解冻')[:255],
|
||||
operator_id=(operator_id or '')[:32],
|
||||
condition_snapshot={},
|
||||
condition_snapshot=cond_snap or {},
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -504,29 +580,65 @@ def apply_unfreeze_step(
|
||||
'remain': max(remain, Decimal('0.00')),
|
||||
'done': remain <= 0,
|
||||
'ledger_id': ledger.id,
|
||||
'condition': cond_snap,
|
||||
}
|
||||
|
||||
|
||||
def scan_due_freeze_ledgers(limit: int = 200) -> Dict[str, Any]:
|
||||
"""定时任务入口:扫到期冻结单并尝试解冻一步。"""
|
||||
"""
|
||||
定时任务入口。
|
||||
1) 到期(next_eligible_at<=now)的冻结中/部分解冻单
|
||||
2) 条件触发类:condition_only / time_or_condition —— 即使未到期也扫描,避免「条件先达标却要干等到期」
|
||||
临界:条件未达标只记 fail+retry,不改账本;下次 beat 再试。
|
||||
"""
|
||||
from django.db.models import Q
|
||||
|
||||
now = timezone.now()
|
||||
qs = (
|
||||
FundFreezeLedger.query.filter(
|
||||
status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL],
|
||||
next_eligible_at__lte=now,
|
||||
)
|
||||
.order_by('next_eligible_at', 'id')[:limit]
|
||||
base = FundFreezeLedger.query.filter(
|
||||
status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL],
|
||||
)
|
||||
ok_n = fail_n = 0
|
||||
for row in qs:
|
||||
due_ids = list(
|
||||
base.filter(next_eligible_at__lte=now)
|
||||
.order_by('next_eligible_at', 'id')
|
||||
.values_list('id', flat=True)[:limit]
|
||||
)
|
||||
# JSON 字段路径:MySQL/Postgres Django JSONField 支持 key 查询
|
||||
cond_ids = []
|
||||
remain = max(0, limit - len(due_ids))
|
||||
if remain > 0:
|
||||
try:
|
||||
r = apply_unfreeze_step(row.id, trigger='schedule')
|
||||
cond_ids = list(
|
||||
base.filter(
|
||||
Q(config_snapshot__unfreeze_trigger='condition_only')
|
||||
| Q(config_snapshot__unfreeze_trigger='time_or_condition')
|
||||
)
|
||||
.exclude(id__in=due_ids)
|
||||
.order_by('id')
|
||||
.values_list('id', flat=True)[:remain]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('scan condition branch skipped: %s', e)
|
||||
|
||||
ids = due_ids + cond_ids
|
||||
ok_n = fail_n = retry_n = 0
|
||||
for lid in ids:
|
||||
try:
|
||||
r = apply_unfreeze_step(lid, trigger='schedule')
|
||||
if r.get('ok'):
|
||||
ok_n += 1
|
||||
else:
|
||||
fail_n += 1
|
||||
logger.info('unfreeze skip id=%s msg=%s', row.id, r.get('msg'))
|
||||
if r.get('retry'):
|
||||
retry_n += 1
|
||||
logger.info('unfreeze skip id=%s msg=%s', lid, r.get('msg'))
|
||||
except Exception as e:
|
||||
fail_n += 1
|
||||
logger.exception('unfreeze error id=%s: %s', row.id, e)
|
||||
return {'ok': ok_n, 'fail': fail_n, 'scanned': ok_n + fail_n}
|
||||
logger.exception('unfreeze error id=%s: %s', lid, e)
|
||||
return {
|
||||
'ok': ok_n,
|
||||
'fail': fail_n,
|
||||
'retry': retry_n,
|
||||
'scanned': len(ids),
|
||||
'due': len(due_ids),
|
||||
'cond_scan': len(cond_ids),
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) -
|
||||
},
|
||||
'freeze_min_days': 0,
|
||||
'remark': '',
|
||||
'display_reason': '',
|
||||
'exists': False,
|
||||
}
|
||||
return {
|
||||
@@ -90,6 +91,7 @@ def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) -
|
||||
'condition_json': row.condition_json or {},
|
||||
'freeze_min_days': int(row.freeze_min_days or 0),
|
||||
'remark': row.remark or '',
|
||||
'display_reason': row.remark or '',
|
||||
'updated_by': row.updated_by or '',
|
||||
'exists': True,
|
||||
}
|
||||
@@ -158,8 +160,40 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict]
|
||||
installment_json = json.loads(installment_json)
|
||||
except Exception:
|
||||
return None, 'installment_json 不是合法 JSON'
|
||||
if schedule_mode == 'installment' and not (installment_json.get('steps') or []):
|
||||
installment_json = DEFAULT_INSTALLMENT
|
||||
if schedule_mode == 'installment':
|
||||
steps = installment_json.get('steps') if isinstance(installment_json, dict) else None
|
||||
if not steps:
|
||||
installment_json = dict(DEFAULT_INSTALLMENT)
|
||||
steps = installment_json['steps']
|
||||
# 规范化:自由添加的分期重新编号,校验类型
|
||||
normalized = []
|
||||
for i, s in enumerate(steps):
|
||||
if not isinstance(s, dict):
|
||||
return None, f'第{i + 1}期格式错误'
|
||||
typ = (s.get('type') or 'ratio').strip().lower()
|
||||
if typ not in ('ratio', 'fixed', 'remain'):
|
||||
return None, f'第{i + 1}期类型无效(ratio/fixed/remain)'
|
||||
try:
|
||||
after_days = max(0, int(s.get('after_days') or 0))
|
||||
except (TypeError, ValueError):
|
||||
return None, f'第{i + 1}期天数无效'
|
||||
try:
|
||||
value = float(s.get('value') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None, f'第{i + 1}期数值无效'
|
||||
if typ == 'ratio' and (value < 0 or value > 1):
|
||||
return None, f'第{i + 1}期比例须在 0~1'
|
||||
normalized.append({
|
||||
'seq': i + 1,
|
||||
'after_days': after_days,
|
||||
'type': typ,
|
||||
'value': value if typ != 'remain' else 1,
|
||||
'need_condition': bool(s.get('need_condition')),
|
||||
})
|
||||
if not normalized:
|
||||
return None, '请至少添加一期分期'
|
||||
# 最后一期建议 remain,若全是 ratio 且合计偏离太大仅告警不拦截——尾差由 remain 或末步吃完
|
||||
installment_json = {'steps': normalized}
|
||||
|
||||
condition_json = data.get('condition_json') or {}
|
||||
if isinstance(condition_json, str):
|
||||
@@ -172,6 +206,9 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict]
|
||||
if freeze_min_days < 0:
|
||||
return None, '最短冻结天数不能为负'
|
||||
|
||||
# 用户可见冻结说明:优先 display_reason,兼容 remark
|
||||
display_reason = (data.get('display_reason') or data.get('remark') or '')[:255]
|
||||
|
||||
row = ClubFundFreezeConfig.query.filter(club_id=club_id, role=role).first()
|
||||
if not row:
|
||||
row = ClubFundFreezeConfig(
|
||||
@@ -190,7 +227,7 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict]
|
||||
row.installment_json = installment_json
|
||||
row.condition_json = condition_json
|
||||
row.freeze_min_days = freeze_min_days
|
||||
row.remark = (data.get('remark') or '')[:255]
|
||||
row.remark = display_reason
|
||||
row.updated_by = (operator or '')[:32]
|
||||
row.save()
|
||||
invalidate_freeze_config_cache(club_id, role)
|
||||
|
||||
250
jituan/services/fund_freeze_conditions.py
Normal file
250
jituan/services/fund_freeze_conditions.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""资金冻结:解冻条件评估(实时算窗口指标,并回写角色 risk_* 快照)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _dec(v, default='0') -> Decimal:
|
||||
try:
|
||||
return Decimal(str(v if v is not None else default)).quantize(
|
||||
Decimal('0.000001'), rounding=ROUND_HALF_UP
|
||||
)
|
||||
except Exception:
|
||||
return Decimal(default)
|
||||
|
||||
|
||||
def _int(v, default=0) -> int:
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _flag(cfg: dict, enable_key: str, legacy_active: bool) -> bool:
|
||||
"""新配置用 enable_*;旧配置无 enable 时按 legacy_active 推断。"""
|
||||
if enable_key in cfg:
|
||||
return bool(cfg.get(enable_key))
|
||||
return legacy_active
|
||||
|
||||
|
||||
def compute_risk_metrics(
|
||||
club_id: str,
|
||||
user_id: str,
|
||||
role: str,
|
||||
condition_json: Optional[dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""按窗口统计完成单量 / 罚款率 / 投诉率 / 有效邀请。失败时返回安全默认。"""
|
||||
cfg = condition_json or {}
|
||||
fine_days = max(1, _int(cfg.get('fine_rate_window_days'), 30))
|
||||
complaint_days = max(1, _int(cfg.get('complaint_rate_window_days'), 30))
|
||||
order_days = max(fine_days, complaint_days, _int(cfg.get('order_window_days'), 30))
|
||||
now = timezone.now()
|
||||
since_order = now - timedelta(days=order_days)
|
||||
since_fine = now - timedelta(days=fine_days)
|
||||
since_complaint = now - timedelta(days=complaint_days)
|
||||
|
||||
completed = 0
|
||||
fine_count = 0
|
||||
complaint_count = 0
|
||||
valid_invites = 0
|
||||
|
||||
try:
|
||||
from orders.models import Order, Penalty
|
||||
|
||||
if role == 'dashou':
|
||||
completed = Order.query.filter(
|
||||
ClubID=club_id,
|
||||
PlayerID=user_id,
|
||||
Status=3,
|
||||
CreateTime__gte=since_order,
|
||||
).count()
|
||||
fine_count = Penalty.query.filter(
|
||||
PenalizedUserID=user_id,
|
||||
Identity=1,
|
||||
Status__in=[1, 2, 3, 5],
|
||||
CreateTime__gte=since_fine,
|
||||
).count()
|
||||
# 投诉:窗口内该打手完成单关联的工单数
|
||||
try:
|
||||
from gongdan.models import Gongdan
|
||||
order_ids = list(
|
||||
Order.query.filter(
|
||||
ClubID=club_id,
|
||||
PlayerID=user_id,
|
||||
Status=3,
|
||||
CreateTime__gte=since_complaint,
|
||||
).values_list('OrderID', flat=True)[:800]
|
||||
)
|
||||
if order_ids:
|
||||
complaint_count = Gongdan.query.filter(
|
||||
order_id__in=order_ids,
|
||||
).count()
|
||||
except Exception as e:
|
||||
logger.warning('complaint metrics skip: %s', e)
|
||||
valid_invites = 0
|
||||
|
||||
elif role == 'guanshi':
|
||||
from users.models import User, UserGuanshi, UserDashou
|
||||
u = User.query.filter(UserUID=user_id).first()
|
||||
gs = UserGuanshi.query.filter(user=u).first() if u else None
|
||||
if gs is not None:
|
||||
valid_invites = int(getattr(gs, 'yaogingshuliang', 0) or 0)
|
||||
# 管事「完成单」近似:窗口内其邀请打手的成交单量
|
||||
dashou_uids = list(
|
||||
UserDashou.query.filter(yaoqingren=user_id).values_list(
|
||||
'user__UserUID', flat=True
|
||||
)[:500]
|
||||
)
|
||||
if dashou_uids:
|
||||
completed = Order.query.filter(
|
||||
ClubID=club_id,
|
||||
PlayerID__in=dashou_uids,
|
||||
Status=3,
|
||||
CreateTime__gte=since_order,
|
||||
).count()
|
||||
fine_count = Penalty.query.filter(
|
||||
PenalizedUserID=user_id,
|
||||
Identity=2,
|
||||
Status__in=[1, 2, 3, 5],
|
||||
CreateTime__gte=since_fine,
|
||||
).count()
|
||||
try:
|
||||
from gongdan.models import Gongdan
|
||||
complaint_count = Gongdan.query.filter(
|
||||
guanshi_id=user_id,
|
||||
CreateTime__gte=since_complaint,
|
||||
).count()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif role == 'zuzhang':
|
||||
from users.models import User, UserGuanshi, UserZuzhang
|
||||
u = User.query.filter(UserUID=user_id).first()
|
||||
zz = UserZuzhang.query.filter(user=u).first() if u else None
|
||||
if zz is not None:
|
||||
valid_invites = UserGuanshi.query.filter(yaoqingren=user_id).count()
|
||||
fine_count = Penalty.query.filter(
|
||||
PenalizedUserID=user_id,
|
||||
Identity=4,
|
||||
Status__in=[1, 2, 3, 5],
|
||||
CreateTime__gte=since_fine,
|
||||
).count()
|
||||
completed = valid_invites # 组长无直接结单,用邀请管事数作「完成」近似时可被 min_orders 覆盖
|
||||
|
||||
else: # shenheguan
|
||||
fine_count = Penalty.query.filter(
|
||||
PenalizedUserID=user_id,
|
||||
Status__in=[1, 2, 3, 5],
|
||||
CreateTime__gte=since_fine,
|
||||
).count()
|
||||
except Exception as e:
|
||||
logger.exception('compute_risk_metrics failed user=%s role=%s: %s', user_id, role, e)
|
||||
|
||||
denom = max(completed, 1)
|
||||
fine_rate = _dec(fine_count) / _dec(denom)
|
||||
complaint_rate = _dec(complaint_count) / _dec(denom)
|
||||
|
||||
return {
|
||||
'completed_orders': completed,
|
||||
'fine_count': fine_count,
|
||||
'complaint_count': complaint_count,
|
||||
'fine_rate': str(fine_rate),
|
||||
'complaint_rate': str(complaint_rate),
|
||||
'valid_invites': valid_invites,
|
||||
'order_window_days': order_days,
|
||||
'fine_rate_window_days': fine_days,
|
||||
'complaint_rate_window_days': complaint_days,
|
||||
}
|
||||
|
||||
|
||||
def write_risk_snapshot(profile, metrics: dict) -> None:
|
||||
if profile is None:
|
||||
return
|
||||
try:
|
||||
profile.__class__.query.filter(pk=profile.pk).update(
|
||||
risk_fine_rate=_dec(metrics.get('fine_rate')),
|
||||
risk_complaint_rate=_dec(metrics.get('complaint_rate')),
|
||||
risk_completed_orders=_int(metrics.get('completed_orders')),
|
||||
risk_valid_invites=_int(metrics.get('valid_invites')),
|
||||
risk_stat_at=timezone.now(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('write_risk_snapshot failed: %s', e)
|
||||
|
||||
|
||||
def evaluate_unfreeze_conditions(
|
||||
club_id: str,
|
||||
user_id: str,
|
||||
role: str,
|
||||
condition_json: Optional[dict],
|
||||
profile=None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
返回 (是否达标, 指标快照)。
|
||||
未勾选的条件不参与;全部未勾选视为「无条件限制」→ 达标。
|
||||
"""
|
||||
cfg = condition_json or {}
|
||||
metrics = compute_risk_metrics(club_id, user_id, role, cfg)
|
||||
write_risk_snapshot(profile, metrics)
|
||||
|
||||
checks = []
|
||||
reasons = []
|
||||
|
||||
# 最低完成单量
|
||||
min_orders = _int(cfg.get('min_completed_orders'), 0)
|
||||
if _flag(cfg, 'enable_min_completed_orders', min_orders > 0):
|
||||
ok = metrics['completed_orders'] >= min_orders
|
||||
checks.append(ok)
|
||||
if not ok:
|
||||
reasons.append(
|
||||
f"完成单量 {metrics['completed_orders']}/{min_orders}"
|
||||
)
|
||||
|
||||
# 最高罚款率
|
||||
max_fine = _dec(cfg.get('max_fine_rate'), '1')
|
||||
if _flag(cfg, 'enable_max_fine_rate', 0 <= max_fine < 1):
|
||||
ok = _dec(metrics['fine_rate']) <= max_fine
|
||||
checks.append(ok)
|
||||
if not ok:
|
||||
reasons.append(
|
||||
f"罚款率 {metrics['fine_rate']} > {max_fine}"
|
||||
)
|
||||
|
||||
# 最高投诉率
|
||||
max_comp = _dec(cfg.get('max_complaint_rate'), '1')
|
||||
if _flag(cfg, 'enable_max_complaint_rate', 0 <= max_comp < 1):
|
||||
ok = _dec(metrics['complaint_rate']) <= max_comp
|
||||
checks.append(ok)
|
||||
if not ok:
|
||||
reasons.append(
|
||||
f"投诉率 {metrics['complaint_rate']} > {max_comp}"
|
||||
)
|
||||
|
||||
# 最低有效邀请
|
||||
min_inv = _int(cfg.get('min_valid_invites'), 0)
|
||||
if _flag(cfg, 'enable_min_valid_invites', min_inv > 0):
|
||||
ok = metrics['valid_invites'] >= min_inv
|
||||
checks.append(ok)
|
||||
if not ok:
|
||||
reasons.append(
|
||||
f"有效邀请 {metrics['valid_invites']}/{min_inv}"
|
||||
)
|
||||
|
||||
if not checks:
|
||||
passed = True
|
||||
else:
|
||||
passed = all(checks)
|
||||
|
||||
snap = {
|
||||
**metrics,
|
||||
'passed': passed,
|
||||
'fail_reasons': reasons,
|
||||
}
|
||||
return passed, snap
|
||||
@@ -13,7 +13,7 @@ from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from jituan.models import FundFreezeLedger, FundUnfreezeEvent
|
||||
from jituan.models import ClubFundFreezeConfig, FundFreezeLedger, FundUnfreezeEvent
|
||||
from jituan.services.club_user import get_user_club_id
|
||||
from jituan.services.fund_freeze import (
|
||||
ROLE_DASHOU,
|
||||
@@ -109,6 +109,19 @@ class FundFreezeMineView(APIView):
|
||||
]
|
||||
total_dongjie = sum((Decimal(r['dongjie_yue']) for r in roles), Decimal('0.00'))
|
||||
total_withdrawable = sum((Decimal(r['withdrawable']) for r in roles), Decimal('0.00'))
|
||||
|
||||
# 用户可见说明:优先打手规则,否则取任意已配置角色
|
||||
display_reason = ''
|
||||
if club_id:
|
||||
cfgs = list(ClubFundFreezeConfig.query.filter(club_id=club_id, enabled=True))
|
||||
prefer = next((c for c in cfgs if c.role == ROLE_DASHOU), None) or (cfgs[0] if cfgs else None)
|
||||
if prefer and prefer.remark:
|
||||
display_reason = prefer.remark
|
||||
if not display_reason:
|
||||
any_cfg = ClubFundFreezeConfig.query.filter(club_id=club_id).exclude(remark='').first()
|
||||
if any_cfg:
|
||||
display_reason = any_cfg.remark or ''
|
||||
|
||||
return Response({
|
||||
'code': 200,
|
||||
'msg': '获取成功',
|
||||
@@ -119,6 +132,10 @@ class FundFreezeMineView(APIView):
|
||||
'total_dongjie': _money(total_dongjie),
|
||||
'total_withdrawable': _money(total_withdrawable),
|
||||
'has_freeze': total_dongjie > 0,
|
||||
'display_reason': display_reason or (
|
||||
'部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;'
|
||||
'按俱乐部规则到期或达标后自动转入可提现余额,冻结中金额不可提现。'
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -173,7 +190,11 @@ class FundFreezeMyLedgerView(APIView):
|
||||
'source_type': r.source_type,
|
||||
'source_label': SOURCE_LABEL.get(r.source_type, r.source_type or '入账'),
|
||||
'order_id': r.order_id or '',
|
||||
'freeze_reason': r.freeze_reason or '',
|
||||
'freeze_reason': r.freeze_reason or (
|
||||
(r.config_snapshot or {}).get('display_reason')
|
||||
or (r.config_snapshot or {}).get('remark')
|
||||
or ''
|
||||
),
|
||||
'current_step': r.current_step,
|
||||
})
|
||||
|
||||
@@ -246,7 +267,11 @@ class FundFreezeMyLedgerDetailView(APIView):
|
||||
'source_type': ledger.source_type,
|
||||
'source_label': SOURCE_LABEL.get(ledger.source_type, ledger.source_type or '入账'),
|
||||
'order_id': ledger.order_id or '',
|
||||
'freeze_reason': ledger.freeze_reason or '',
|
||||
'freeze_reason': ledger.freeze_reason or (
|
||||
(ledger.config_snapshot or {}).get('display_reason')
|
||||
or (ledger.config_snapshot or {}).get('remark')
|
||||
or ''
|
||||
),
|
||||
'current_step': ledger.current_step,
|
||||
'events': event_list,
|
||||
},
|
||||
|
||||
@@ -1112,10 +1112,30 @@ class TixianAssetView(APIView):
|
||||
|
||||
from jituan.services.club_user import get_user_club_id
|
||||
from jituan.services.withdraw_config import build_tixian_asset_rates
|
||||
from jituan.models import ClubFundFreezeConfig
|
||||
|
||||
club_id = get_user_club_id(user)
|
||||
data.update(build_tixian_asset_rates(club_id))
|
||||
|
||||
display_reason = ''
|
||||
try:
|
||||
if club_id:
|
||||
prefer = ClubFundFreezeConfig.query.filter(
|
||||
club_id=club_id, role='dashou', enabled=True,
|
||||
).first()
|
||||
if not prefer:
|
||||
prefer = ClubFundFreezeConfig.query.filter(
|
||||
club_id=club_id, enabled=True,
|
||||
).exclude(remark='').first()
|
||||
if prefer:
|
||||
display_reason = prefer.remark or ''
|
||||
except Exception:
|
||||
pass
|
||||
data['freeze_display_reason'] = display_reason or (
|
||||
'部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;'
|
||||
'按规则解冻后自动转入可提现,冻结中不可提现。'
|
||||
)
|
||||
|
||||
return Response({
|
||||
'code': 200,
|
||||
'msg': '获取成功',
|
||||
|
||||
Reference in New Issue
Block a user