feat: 资金冻结 P1 打手结单收口网关 + 到期解冻定时任务骨架
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
"""资金冻结入账网关(P0)。
|
||||
"""资金冻结入账网关与解冻骨架。
|
||||
|
||||
默认:无配置或 enabled=False → 全额进可提现,与现网一致,不写流水。
|
||||
开启后:拆「可提现 + 冻结池」并写 FundFreezeLedger。
|
||||
|
||||
注意:P0 仅提供网关;结算调用点在 P1 收口,避免一次改太多接口。
|
||||
P1:打手订单结算入口已统一走 credit_dashou_order_settle(未开冻结时行为与改造前一致)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -273,3 +273,260 @@ def split_preview(club_id: Optional[str], role: str, amount, source_type: str =
|
||||
return credit, Decimal('0.00')
|
||||
frozen = calc_freeze_amount(credit, cfg)
|
||||
return _money(credit - frozen), frozen
|
||||
|
||||
|
||||
def order_club_id(order) -> str:
|
||||
return _club(getattr(order, 'ClubID', None) or getattr(order, 'club_id', None))
|
||||
|
||||
|
||||
def credit_dashou_order_settle(
|
||||
*,
|
||||
order,
|
||||
dashou_profile,
|
||||
user_id: str,
|
||||
amount,
|
||||
set_idle_status: bool = False,
|
||||
increment_chengjiao: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
打手订单结算专用入账(P1)。
|
||||
|
||||
- 可提现走 credit_role_balance(未开冻结=全额进 yue,与改造前一致)
|
||||
- zonge / 今日今月收益仍按「全额」累加(统计口径不变)
|
||||
- 成交单量 +1、可选置空闲状态
|
||||
调用方须已在 transaction.atomic() 内;本函数用 F() 更新,勿再对同一 profile save 覆盖余额字段。
|
||||
"""
|
||||
from users.models import UserDashou
|
||||
|
||||
credit = _money(amount)
|
||||
order_id = str(getattr(order, 'OrderID', None) or getattr(order, 'order_id', '') or '')
|
||||
cid = order_club_id(order)
|
||||
|
||||
if credit <= 0:
|
||||
updates = {}
|
||||
if increment_chengjiao:
|
||||
updates['chengjiaozongliang'] = F('chengjiaozongliang') + 1
|
||||
if set_idle_status:
|
||||
updates['zhuangtai'] = 1
|
||||
if updates:
|
||||
UserDashou.query.filter(pk=dashou_profile.pk).update(**updates)
|
||||
return {
|
||||
'available': Decimal('0.00'),
|
||||
'frozen': Decimal('0.00'),
|
||||
'ledger_id': None,
|
||||
'skipped_freeze': True,
|
||||
}
|
||||
|
||||
result = credit_role_balance(
|
||||
club_id=cid,
|
||||
user_id=str(user_id),
|
||||
role=ROLE_DASHOU,
|
||||
amount=credit,
|
||||
source_type=SOURCE_ORDER_SETTLE,
|
||||
biz_id=order_id,
|
||||
profile=dashou_profile,
|
||||
order_id=order_id,
|
||||
freeze_reason=f'订单结算 {order_id}',
|
||||
also_credit_zonge=False,
|
||||
)
|
||||
if result.get('idempotent'):
|
||||
logger.warning('打手订单结算幂等跳过 order=%s user=%s', order_id, user_id)
|
||||
return result
|
||||
|
||||
updates = {
|
||||
'zonge': F('zonge') + credit,
|
||||
'jinrishouyi': F('jinrishouyi') + credit,
|
||||
'jinyueshouyi': F('jinyueshouyi') + credit,
|
||||
}
|
||||
if increment_chengjiao:
|
||||
updates['chengjiaozongliang'] = F('chengjiaozongliang') + 1
|
||||
if set_idle_status:
|
||||
updates['zhuangtai'] = 1
|
||||
UserDashou.query.filter(pk=dashou_profile.pk).update(**updates)
|
||||
return result
|
||||
|
||||
|
||||
def _step_unfreeze_amount(ledger: FundFreezeLedger, step: dict) -> Decimal:
|
||||
remain = _money(ledger.amount_total - ledger.amount_unfrozen)
|
||||
if remain <= 0:
|
||||
return Decimal('0.00')
|
||||
typ = (step.get('type') or 'remain').strip().lower()
|
||||
if typ == 'remain':
|
||||
return remain
|
||||
if typ == 'fixed':
|
||||
return min(remain, _money(step.get('value') or 0))
|
||||
# ratio
|
||||
ratio = Decimal(str(step.get('value') or 0))
|
||||
if ratio <= 0:
|
||||
return Decimal('0.00')
|
||||
return min(remain, _money(ledger.amount_total * ratio))
|
||||
|
||||
|
||||
def _plan_steps(snapshot: dict) -> list:
|
||||
schedule = (snapshot or {}).get('schedule_mode') or 'all_at_once'
|
||||
if schedule == 'installment':
|
||||
steps = ((snapshot or {}).get('installment_json') or {}).get('steps') or []
|
||||
if steps:
|
||||
return sorted(steps, key=lambda s: int(s.get('seq') or 0))
|
||||
# 一次全解:单步 remain,天数用 freeze_min_days(已在 next_eligible_at)
|
||||
return [{'seq': 1, 'after_days': 0, 'type': 'remain', 'value': 1, 'need_condition': False}]
|
||||
|
||||
|
||||
def apply_unfreeze_step(
|
||||
ledger_id: int,
|
||||
*,
|
||||
trigger: str = 'schedule',
|
||||
operator_id: str = '',
|
||||
reason: str = '',
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
执行一笔冻结单的当前分期步(骨架:先支持到期/手动;条件解冻 P4 再接指标)。
|
||||
force=True 时忽略 next_eligible_at(后台手动)。
|
||||
"""
|
||||
from django.db import transaction
|
||||
from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan
|
||||
from jituan.models import FundUnfreezeEvent
|
||||
|
||||
with transaction.atomic():
|
||||
ledger = (
|
||||
FundFreezeLedger.objects.select_for_update()
|
||||
.filter(pk=ledger_id)
|
||||
.first()
|
||||
)
|
||||
if not ledger:
|
||||
return {'ok': False, 'msg': '冻结单不存在'}
|
||||
if ledger.status in (FundFreezeLedger.STATUS_DONE, FundFreezeLedger.STATUS_VOID):
|
||||
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')
|
||||
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}
|
||||
else:
|
||||
step = steps[step_idx]
|
||||
|
||||
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}
|
||||
|
||||
profile_map = {
|
||||
ROLE_DASHOU: UserDashou,
|
||||
ROLE_GUANSHI: UserGuanshi,
|
||||
ROLE_ZUZHANG: UserZuzhang,
|
||||
ROLE_SHENHEGUAN: UserShenheguan,
|
||||
}
|
||||
model = profile_map.get(ledger.role)
|
||||
if not model:
|
||||
return {'ok': False, 'msg': f'未知角色 {ledger.role}'}
|
||||
|
||||
from users.models import User
|
||||
user = User.query.filter(UserUID=ledger.user_id).first()
|
||||
if not user:
|
||||
return {'ok': False, 'msg': '用户不存在'}
|
||||
profile = model.objects.select_for_update().filter(user=user).first()
|
||||
if not profile:
|
||||
return {'ok': False, 'msg': '角色扩展表不存在'}
|
||||
|
||||
# 冻结池不足则按实际可解
|
||||
cur_frozen = _money(getattr(profile, 'dongjie_yue', 0) or 0)
|
||||
if cur_frozen < amount:
|
||||
amount = cur_frozen
|
||||
if amount <= 0:
|
||||
return {'ok': False, 'msg': '冻结池余额不足'}
|
||||
|
||||
if ledger.role == ROLE_ZUZHANG:
|
||||
model.query.filter(pk=profile.pk).update(
|
||||
dongjie_yue=F('dongjie_yue') - amount,
|
||||
ketixian_jine=F('ketixian_jine') + amount,
|
||||
)
|
||||
else:
|
||||
model.query.filter(pk=profile.pk).update(
|
||||
dongjie_yue=F('dongjie_yue') - amount,
|
||||
yue=F('yue') + amount,
|
||||
)
|
||||
|
||||
new_unfrozen = _money(ledger.amount_unfrozen + amount)
|
||||
remain = _money(ledger.amount_total - new_unfrozen)
|
||||
ledger.amount_unfrozen = new_unfrozen
|
||||
next_step = int(ledger.current_step or 1) + 1
|
||||
|
||||
if remain <= 0:
|
||||
ledger.status = FundFreezeLedger.STATUS_DONE
|
||||
ledger.current_step = next_step
|
||||
ledger.next_eligible_at = None
|
||||
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)
|
||||
else:
|
||||
ledger.next_eligible_at = now
|
||||
|
||||
ledger.save(update_fields=[
|
||||
'amount_unfrozen', 'status', 'current_step', 'next_eligible_at', 'UpdateTime',
|
||||
])
|
||||
|
||||
FundUnfreezeEvent.query.create(
|
||||
ledger_id=ledger.id,
|
||||
club_id=ledger.club_id,
|
||||
user_id=ledger.user_id,
|
||||
role=ledger.role,
|
||||
step_seq=int(step.get('seq') or ledger.current_step),
|
||||
amount=amount,
|
||||
trigger=trigger,
|
||||
reason=(reason or f'{trigger}解冻')[:255],
|
||||
operator_id=(operator_id or '')[:32],
|
||||
condition_snapshot={},
|
||||
)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'amount': amount,
|
||||
'remain': max(remain, Decimal('0.00')),
|
||||
'done': remain <= 0,
|
||||
'ledger_id': ledger.id,
|
||||
}
|
||||
|
||||
|
||||
def scan_due_freeze_ledgers(limit: int = 200) -> Dict[str, Any]:
|
||||
"""定时任务入口:扫到期冻结单并尝试解冻一步。"""
|
||||
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]
|
||||
)
|
||||
ok_n = fail_n = 0
|
||||
for row in qs:
|
||||
try:
|
||||
r = apply_unfreeze_step(row.id, 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'))
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user