fix: 冻结条件可评估+分期规范化,定时扫描更及时
后台条件真正按罚款率/单量等评估;选项式配置所需字段规范化;Celery 改为每5分钟;小程序说明文案接口补齐。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -89,7 +89,7 @@ app.conf.beat_schedule = {
|
|||||||
# 5. 资金冻结到期解冻(默认无冻结单时几乎空跑)
|
# 5. 资金冻结到期解冻(默认无冻结单时几乎空跑)
|
||||||
'scan_fund_freeze_due': {
|
'scan_fund_freeze_due': {
|
||||||
'task': 'jituan.tasks.scan_fund_freeze_due',
|
'task': 'jituan.tasks.scan_fund_freeze_due',
|
||||||
'schedule': crontab(minute='*/15'),
|
'schedule': crontab(minute='*/5'),
|
||||||
'options': {
|
'options': {
|
||||||
'queue': 'periodic_tasks',
|
'queue': 'periodic_tasks',
|
||||||
'priority': 4,
|
'priority': 4,
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ def _config_snapshot(cfg: ClubFundFreezeConfig) -> Dict[str, Any]:
|
|||||||
'installment_json': cfg.installment_json,
|
'installment_json': cfg.installment_json,
|
||||||
'condition_json': cfg.condition_json,
|
'condition_json': cfg.condition_json,
|
||||||
'freeze_min_days': cfg.freeze_min_days,
|
'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],
|
order_id=(order_id or '')[:64],
|
||||||
czjilu_id=(czjilu_id or '')[:64],
|
czjilu_id=(czjilu_id or '')[:64],
|
||||||
biz_id=biz_id[: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),
|
config_snapshot=_config_snapshot(cfg),
|
||||||
current_step=1,
|
current_step=1,
|
||||||
next_eligible_at=next_at,
|
next_eligible_at=next_at,
|
||||||
@@ -381,12 +387,14 @@ def apply_unfreeze_step(
|
|||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
执行一笔冻结单的当前分期步(骨架:先支持到期/手动;条件解冻 P4 再接指标)。
|
执行一笔冻结单的当前分期步。
|
||||||
force=True 时忽略 next_eligible_at(后台手动)。
|
force=True:后台手动,跳过时间/条件门槛。
|
||||||
|
条件未达标时不推进步数、不改 next_eligible_at,供定时任务下次重试。
|
||||||
"""
|
"""
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan
|
from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan
|
||||||
from jituan.models import FundUnfreezeEvent
|
from jituan.models import FundUnfreezeEvent
|
||||||
|
from jituan.services.fund_freeze_conditions import evaluate_unfreeze_conditions
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
ledger = (
|
ledger = (
|
||||||
@@ -400,29 +408,37 @@ def apply_unfreeze_step(
|
|||||||
return {'ok': False, 'msg': '已完结或已作废'}
|
return {'ok': False, 'msg': '已完结或已作废'}
|
||||||
|
|
||||||
now = timezone.now()
|
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 {}
|
snap = ledger.config_snapshot or {}
|
||||||
trigger_mode = (snap.get('unfreeze_trigger') or 'time_only')
|
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:
|
if trigger_mode == 'manual_only' and not force:
|
||||||
return {'ok': False, 'msg': '仅允许手动解冻'}
|
return {'ok': False, 'msg': '仅允许手动解冻'}
|
||||||
# condition_*:自动任务暂不评估指标(P4);手动 force 可解
|
|
||||||
if trigger_mode == 'condition_only' and not force:
|
|
||||||
return {'ok': False, 'msg': '条件解冻尚未启用自动评估'}
|
|
||||||
|
|
||||||
steps = _plan_steps(snap)
|
steps = _plan_steps(snap)
|
||||||
step_idx = max(0, int(ledger.current_step or 1) - 1)
|
step_idx = max(0, int(ledger.current_step or 1) - 1)
|
||||||
if step_idx >= len(steps):
|
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:
|
else:
|
||||||
step = steps[step_idx]
|
step = steps[step_idx]
|
||||||
|
need_step_cond = bool(step.get('need_condition'))
|
||||||
|
|
||||||
amount = _step_unfreeze_amount(ledger, step)
|
cond_ok = True
|
||||||
if amount <= 0:
|
cond_snap: Dict[str, Any] = {}
|
||||||
ledger.status = FundFreezeLedger.STATUS_DONE
|
need_eval = (
|
||||||
ledger.save(update_fields=['status', 'UpdateTime'])
|
not force
|
||||||
return {'ok': True, 'amount': Decimal('0.00'), 'done': True}
|
and (
|
||||||
|
need_step_cond
|
||||||
|
or trigger_mode in (
|
||||||
|
'condition_only', 'time_and_condition', 'time_or_condition',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
profile_map = {
|
profile_map = {
|
||||||
ROLE_DASHOU: UserDashou,
|
ROLE_DASHOU: UserDashou,
|
||||||
@@ -442,6 +458,63 @@ def apply_unfreeze_step(
|
|||||||
if not profile:
|
if not profile:
|
||||||
return {'ok': False, 'msg': '角色扩展表不存在'}
|
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)
|
cur_frozen = _money(getattr(profile, 'dongjie_yue', 0) or 0)
|
||||||
if cur_frozen < amount:
|
if cur_frozen < amount:
|
||||||
@@ -472,19 +545,22 @@ def apply_unfreeze_step(
|
|||||||
else:
|
else:
|
||||||
ledger.status = FundFreezeLedger.STATUS_PARTIAL
|
ledger.status = FundFreezeLedger.STATUS_PARTIAL
|
||||||
ledger.current_step = next_step
|
ledger.current_step = next_step
|
||||||
# 下一步到期时间
|
|
||||||
if next_step - 1 < len(steps):
|
if next_step - 1 < len(steps):
|
||||||
nxt = steps[next_step - 1]
|
nxt = steps[next_step - 1]
|
||||||
days = int(nxt.get('after_days') or 0)
|
days = int(nxt.get('after_days') or 0)
|
||||||
# after_days 相对冻结日
|
ledger.next_eligible_at = frozen_at + timedelta(days=days)
|
||||||
ledger.next_eligible_at = ledger.frozen_at + timedelta(days=days)
|
|
||||||
else:
|
else:
|
||||||
|
# 无更多配置步:剩余立即进入可扫状态,下一步按 remain 吃完
|
||||||
ledger.next_eligible_at = now
|
ledger.next_eligible_at = now
|
||||||
|
|
||||||
ledger.save(update_fields=[
|
ledger.save(update_fields=[
|
||||||
'amount_unfrozen', 'status', 'current_step', 'next_eligible_at', 'UpdateTime',
|
'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(
|
FundUnfreezeEvent.query.create(
|
||||||
ledger_id=ledger.id,
|
ledger_id=ledger.id,
|
||||||
club_id=ledger.club_id,
|
club_id=ledger.club_id,
|
||||||
@@ -492,10 +568,10 @@ def apply_unfreeze_step(
|
|||||||
role=ledger.role,
|
role=ledger.role,
|
||||||
step_seq=int(step.get('seq') or ledger.current_step),
|
step_seq=int(step.get('seq') or ledger.current_step),
|
||||||
amount=amount,
|
amount=amount,
|
||||||
trigger=trigger,
|
trigger=event_trigger,
|
||||||
reason=(reason or f'{trigger}解冻')[:255],
|
reason=(reason or f'{event_trigger}解冻')[:255],
|
||||||
operator_id=(operator_id or '')[:32],
|
operator_id=(operator_id or '')[:32],
|
||||||
condition_snapshot={},
|
condition_snapshot=cond_snap or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -504,29 +580,65 @@ def apply_unfreeze_step(
|
|||||||
'remain': max(remain, Decimal('0.00')),
|
'remain': max(remain, Decimal('0.00')),
|
||||||
'done': remain <= 0,
|
'done': remain <= 0,
|
||||||
'ledger_id': ledger.id,
|
'ledger_id': ledger.id,
|
||||||
|
'condition': cond_snap,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def scan_due_freeze_ledgers(limit: int = 200) -> Dict[str, Any]:
|
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()
|
now = timezone.now()
|
||||||
qs = (
|
base = FundFreezeLedger.query.filter(
|
||||||
FundFreezeLedger.query.filter(
|
status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL],
|
||||||
status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL],
|
|
||||||
next_eligible_at__lte=now,
|
|
||||||
)
|
|
||||||
.order_by('next_eligible_at', 'id')[:limit]
|
|
||||||
)
|
)
|
||||||
ok_n = fail_n = 0
|
due_ids = list(
|
||||||
for row in qs:
|
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:
|
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'):
|
if r.get('ok'):
|
||||||
ok_n += 1
|
ok_n += 1
|
||||||
else:
|
else:
|
||||||
fail_n += 1
|
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:
|
except Exception as e:
|
||||||
fail_n += 1
|
fail_n += 1
|
||||||
logger.exception('unfreeze error id=%s: %s', row.id, e)
|
logger.exception('unfreeze error id=%s: %s', lid, e)
|
||||||
return {'ok': ok_n, 'fail': fail_n, 'scanned': ok_n + fail_n}
|
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,
|
'freeze_min_days': 0,
|
||||||
'remark': '',
|
'remark': '',
|
||||||
|
'display_reason': '',
|
||||||
'exists': False,
|
'exists': False,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -90,6 +91,7 @@ def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) -
|
|||||||
'condition_json': row.condition_json or {},
|
'condition_json': row.condition_json or {},
|
||||||
'freeze_min_days': int(row.freeze_min_days or 0),
|
'freeze_min_days': int(row.freeze_min_days or 0),
|
||||||
'remark': row.remark or '',
|
'remark': row.remark or '',
|
||||||
|
'display_reason': row.remark or '',
|
||||||
'updated_by': row.updated_by or '',
|
'updated_by': row.updated_by or '',
|
||||||
'exists': True,
|
'exists': True,
|
||||||
}
|
}
|
||||||
@@ -158,8 +160,40 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict]
|
|||||||
installment_json = json.loads(installment_json)
|
installment_json = json.loads(installment_json)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None, 'installment_json 不是合法 JSON'
|
return None, 'installment_json 不是合法 JSON'
|
||||||
if schedule_mode == 'installment' and not (installment_json.get('steps') or []):
|
if schedule_mode == 'installment':
|
||||||
installment_json = DEFAULT_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 {}
|
condition_json = data.get('condition_json') or {}
|
||||||
if isinstance(condition_json, str):
|
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:
|
if freeze_min_days < 0:
|
||||||
return None, '最短冻结天数不能为负'
|
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()
|
row = ClubFundFreezeConfig.query.filter(club_id=club_id, role=role).first()
|
||||||
if not row:
|
if not row:
|
||||||
row = ClubFundFreezeConfig(
|
row = ClubFundFreezeConfig(
|
||||||
@@ -190,7 +227,7 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict]
|
|||||||
row.installment_json = installment_json
|
row.installment_json = installment_json
|
||||||
row.condition_json = condition_json
|
row.condition_json = condition_json
|
||||||
row.freeze_min_days = freeze_min_days
|
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.updated_by = (operator or '')[:32]
|
||||||
row.save()
|
row.save()
|
||||||
invalidate_freeze_config_cache(club_id, role)
|
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.response import Response
|
||||||
from rest_framework.views import APIView
|
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.club_user import get_user_club_id
|
||||||
from jituan.services.fund_freeze import (
|
from jituan.services.fund_freeze import (
|
||||||
ROLE_DASHOU,
|
ROLE_DASHOU,
|
||||||
@@ -109,6 +109,19 @@ class FundFreezeMineView(APIView):
|
|||||||
]
|
]
|
||||||
total_dongjie = sum((Decimal(r['dongjie_yue']) for r in roles), Decimal('0.00'))
|
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'))
|
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({
|
return Response({
|
||||||
'code': 200,
|
'code': 200,
|
||||||
'msg': '获取成功',
|
'msg': '获取成功',
|
||||||
@@ -119,6 +132,10 @@ class FundFreezeMineView(APIView):
|
|||||||
'total_dongjie': _money(total_dongjie),
|
'total_dongjie': _money(total_dongjie),
|
||||||
'total_withdrawable': _money(total_withdrawable),
|
'total_withdrawable': _money(total_withdrawable),
|
||||||
'has_freeze': total_dongjie > 0,
|
'has_freeze': total_dongjie > 0,
|
||||||
|
'display_reason': display_reason or (
|
||||||
|
'部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;'
|
||||||
|
'按俱乐部规则到期或达标后自动转入可提现余额,冻结中金额不可提现。'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -173,7 +190,11 @@ class FundFreezeMyLedgerView(APIView):
|
|||||||
'source_type': r.source_type,
|
'source_type': r.source_type,
|
||||||
'source_label': SOURCE_LABEL.get(r.source_type, r.source_type or '入账'),
|
'source_label': SOURCE_LABEL.get(r.source_type, r.source_type or '入账'),
|
||||||
'order_id': r.order_id 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,
|
'current_step': r.current_step,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -246,7 +267,11 @@ class FundFreezeMyLedgerDetailView(APIView):
|
|||||||
'source_type': ledger.source_type,
|
'source_type': ledger.source_type,
|
||||||
'source_label': SOURCE_LABEL.get(ledger.source_type, ledger.source_type or '入账'),
|
'source_label': SOURCE_LABEL.get(ledger.source_type, ledger.source_type or '入账'),
|
||||||
'order_id': ledger.order_id 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,
|
'current_step': ledger.current_step,
|
||||||
'events': event_list,
|
'events': event_list,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1112,10 +1112,30 @@ class TixianAssetView(APIView):
|
|||||||
|
|
||||||
from jituan.services.club_user import get_user_club_id
|
from jituan.services.club_user import get_user_club_id
|
||||||
from jituan.services.withdraw_config import build_tixian_asset_rates
|
from jituan.services.withdraw_config import build_tixian_asset_rates
|
||||||
|
from jituan.models import ClubFundFreezeConfig
|
||||||
|
|
||||||
club_id = get_user_club_id(user)
|
club_id = get_user_club_id(user)
|
||||||
data.update(build_tixian_asset_rates(club_id))
|
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({
|
return Response({
|
||||||
'code': 200,
|
'code': 200,
|
||||||
'msg': '获取成功',
|
'msg': '获取成功',
|
||||||
|
|||||||
Reference in New Issue
Block a user