diff --git a/jituan/migrations/0030_fund_freeze_dashou_yajin_role.py b/jituan/migrations/0030_fund_freeze_dashou_yajin_role.py new file mode 100644 index 0000000..986e553 --- /dev/null +++ b/jituan/migrations/0030_fund_freeze_dashou_yajin_role.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('jituan', '0029_club_wecom_kefu_config'), + ] + + operations = [ + migrations.AlterField( + model_name='clubfundfreezeconfig', + name='role', + field=models.CharField( + choices=[ + ('dashou', '打手佣金'), + ('dashou_yajin', '打手押金'), + ('guanshi', '管事'), + ('zuzhang', '组长'), + ('shenheguan', '考核官'), + ], + db_index=True, + max_length=16, + verbose_name='角色', + ), + ), + ] diff --git a/jituan/models.py b/jituan/models.py index 1860b2b..30d9ca3 100644 --- a/jituan/models.py +++ b/jituan/models.py @@ -485,7 +485,8 @@ class ClubOrderGrabLink(QModel): class ClubFundFreezeConfig(QModel): """俱乐部×角色冻结规则。enabled=False 时入账行为与现网完全一致。""" ROLE_CHOICES = ( - ('dashou', '打手'), + ('dashou', '打手佣金'), + ('dashou_yajin', '打手押金'), ('guanshi', '管事'), ('zuzhang', '组长'), ('shenheguan', '考核官'), diff --git a/jituan/services/fund_freeze.py b/jituan/services/fund_freeze.py index a4319bd..f2e29e5 100644 --- a/jituan/services/fund_freeze.py +++ b/jituan/services/fund_freeze.py @@ -22,6 +22,7 @@ from jituan.models import ClubFundFreezeConfig, FundFreezeLedger logger = logging.getLogger(__name__) ROLE_DASHOU = 'dashou' +ROLE_DASHOU_YAJIN = 'dashou_yajin' # 仅配置维度:押金独立冻结规则;ledger.role 仍写 dashou ROLE_GUANSHI = 'guanshi' ROLE_ZUZHANG = 'zuzhang' ROLE_SHENHEGUAN = 'shenheguan' @@ -287,6 +288,7 @@ def credit_role_balance( also_credit_zonge: bool = False, ignore_source_scope: bool = False, credit_target: str = CREDIT_TARGET_YUE, + config_role: Optional[str] = None, ) -> Dict[str, Any]: """ 统一入账。返回 {available, frozen, ledger_id, skipped_freeze}。 @@ -295,8 +297,10 @@ def credit_role_balance( profile: 已加载的角色扩展实例(UserDashou 等);若空则按 role+user_id 查。 ignore_source_scope: True 时跳过 source_scope 检查(仍受 enabled / 金额规则约束)。 credit_target: yue=可提现佣金(默认);yajin=押金可提部分(冻结进同一 dongjie_yue)。 + config_role: 查冻结配置用的角色键;默认=role。押金请传 dashou_yajin,ledger.role 仍写 dashou。 """ role = (role or '').strip().lower() + cfg_role = (config_role or role).strip().lower() or role cid = _club(club_id) credit = _money(amount) biz_id = (biz_id or '').strip() @@ -315,8 +319,8 @@ def credit_role_balance( if profile is None: raise ValueError(f'找不到角色资料 role={role} user_id={user_id}') - cfg = get_freeze_config(cid, role) - source_ok = True if ignore_source_scope else _source_allowed(cfg, source_type, role=role) if cfg else False + cfg = get_freeze_config(cid, cfg_role) + source_ok = True if ignore_source_scope else _source_allowed(cfg, source_type, role=cfg_role) if cfg else False freeze_on = bool(cfg and cfg.enabled and source_ok and biz_id) skip_reason = '' if not freeze_on: @@ -331,8 +335,8 @@ def credit_role_balance( else: skip_reason = 'unknown' logger.info( - 'fund_freeze skip club=%s role=%s source=%s biz=%s user=%s amount=%s reason=%s', - cid, role, source_type, biz_id, user_id, credit, skip_reason, + 'fund_freeze skip club=%s role=%s cfg_role=%s source=%s biz=%s user=%s amount=%s reason=%s', + cid, role, cfg_role, source_type, biz_id, user_id, credit, skip_reason, ) frozen = calc_freeze_amount(credit, cfg) if freeze_on else Decimal('0.00') @@ -340,8 +344,8 @@ def credit_role_balance( if freeze_on and frozen <= 0: skip_reason = 'calc_zero' logger.info( - 'fund_freeze calc_zero club=%s role=%s source=%s biz=%s amount=%s mode=%s rate=%s', - cid, role, source_type, biz_id, credit, + 'fund_freeze calc_zero club=%s role=%s cfg_role=%s source=%s biz=%s amount=%s mode=%s rate=%s', + cid, role, cfg_role, source_type, biz_id, credit, getattr(cfg, 'freeze_mode', None), getattr(cfg, 'freeze_rate', None), ) @@ -374,6 +378,7 @@ def credit_role_balance( next_at = timezone.now() + timedelta(days=delay) if delay else timezone.now() snap = _config_snapshot(cfg) snap['credit_target'] = credit_target + snap['config_role'] = cfg_role ledger = FundFreezeLedger.query.create( club_id=cid, user_id=str(user_id), @@ -396,9 +401,10 @@ def credit_role_balance( ) ledger_id = ledger.id logger.info( - 'fund_freeze applied club=%s role=%s source=%s target=%s biz=%s user=%s ' + 'fund_freeze applied club=%s role=%s cfg_role=%s source=%s target=%s biz=%s user=%s ' 'credit=%s available=%s frozen=%s ledger=%s', - cid, role, source_type, credit_target, biz_id, user_id, credit, available, frozen, ledger_id, + cid, role, cfg_role, source_type, credit_target, biz_id, user_id, + credit, available, frozen, ledger_id, ) return { @@ -409,6 +415,7 @@ def credit_role_balance( 'skip_reason': skip_reason or None, 'club_id': cid, 'credit_target': credit_target, + 'config_role': cfg_role, } @@ -607,11 +614,11 @@ def credit_dashou_yajin( freeze_reason: str = '', ) -> Dict[str, Any]: """ - 打手押金入账网关。 + 打手押金入账网关(独立配置键 dashou_yajin,与佣金冻结规则分离)。 - - 未开启冻结 / 未勾选押金来源:全额进 yajin(与现网一致) - - 开启且勾选:按规则拆「可提押金 yajin + 冻结池 dongjie_yue」,ledger.source=yajin_* - - 解冻时回 yajin,绝不进佣金 yue + - 未开启押金冻结 / 未勾选押金来源:全额进 yajin(与现网一致) + - 开启且勾选:按「打手押金」规则拆「可提押金 yajin + 冻结池 dongjie_yue」 + - 解冻时回 yajin,绝不进佣金 yue;有效押金仍可抢单 调用方须在 transaction.atomic() 内。 """ uid = str(user_id or '').strip() @@ -631,6 +638,7 @@ def credit_dashou_yajin( freeze_reason=freeze_reason or '押金充值冻结', also_credit_zonge=False, credit_target=CREDIT_TARGET_YAJIN, + config_role=ROLE_DASHOU_YAJIN, ) diff --git a/jituan/services/fund_freeze_admin.py b/jituan/services/fund_freeze_admin.py index 4d17859..838f8ff 100644 --- a/jituan/services/fund_freeze_admin.py +++ b/jituan/services/fund_freeze_admin.py @@ -12,21 +12,29 @@ from jituan.models import ClubFundFreezeConfig, FundFreezeLedger, FundUnfreezeEv from jituan.services.club_context import filter_user_related_by_club, resolve_club_id_from_request from jituan.services.fund_freeze import ( ROLE_DASHOU, + ROLE_DASHOU_YAJIN, ROLE_GUANSHI, ROLE_SHENHEGUAN, ROLE_ZUZHANG, + SOURCE_YAJIN_EXCHANGE, + SOURCE_YAJIN_RECHARGE, + YAJIN_SOURCE_TYPES, apply_unfreeze_step, invalidate_freeze_config_cache, split_preview, ) ROLES = ( - (ROLE_DASHOU, '打手'), + (ROLE_DASHOU, '打手佣金'), + (ROLE_DASHOU_YAJIN, '打手押金'), (ROLE_GUANSHI, '管事'), (ROLE_ZUZHANG, '组长'), (ROLE_SHENHEGUAN, '考核官'), ) +YAJIN_DEFAULT_SCOPE = [SOURCE_YAJIN_RECHARGE, SOURCE_YAJIN_EXCHANGE] +COMMISSION_DEFAULT_SCOPE = ['order_settle', 'fenhong', 'withdraw_reject', 'club_migrate'] + DEFAULT_INSTALLMENT = { 'steps': [ {'seq': 1, 'after_days': 7, 'type': 'ratio', 'value': 0.3, 'need_condition': False}, @@ -48,6 +56,9 @@ def _dec(v, default='0') -> str: def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) -> Dict[str, Any]: + default_scope = ( + list(YAJIN_DEFAULT_SCOPE) if role == ROLE_DASHOU_YAJIN else ['order_settle'] + ) if not row: return { 'club_id': club_id, @@ -58,7 +69,7 @@ def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) - 'freeze_fixed': '0', 'freeze_cap': '0', 'min_credit_to_freeze': '0', - 'source_scope': ['order_settle'], + 'source_scope': default_scope, 'unfreeze_trigger': 'time_only', 'schedule_mode': 'all_at_once', 'installment_json': {'steps': [{'seq': 1, 'after_days': 15, 'type': 'remain', 'value': 1, 'need_condition': False}]}, @@ -74,6 +85,7 @@ def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) - 'remark': '', 'display_reason': '', 'exists': False, + 'is_yajin_config': role == ROLE_DASHOU_YAJIN, } return { 'club_id': row.club_id, @@ -94,20 +106,69 @@ def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) - 'display_reason': row.remark or '', 'updated_by': row.updated_by or '', 'exists': True, + 'is_yajin_config': role == ROLE_DASHOU_YAJIN, } +def _migrate_legacy_yajin_scope(club_id: str, rows: Dict[str, ClubFundFreezeConfig]) -> None: + """ + 一次性兼容:旧「打手」配置里勾了押金来源时,拆到独立 dashou_yajin 行, + 并从打手佣金 scope 去掉押金项,避免双轨。 + """ + dashou = rows.get(ROLE_DASHOU) + if not dashou: + return + if rows.get(ROLE_DASHOU_YAJIN): + return + scope = list(dashou.source_scope or []) + yajin_items = [s for s in scope if s in YAJIN_SOURCE_TYPES] + if not yajin_items: + return + new_row = ClubFundFreezeConfig( + club_id=club_id, + role=ROLE_DASHOU_YAJIN, + enabled=bool(dashou.enabled), + freeze_mode=dashou.freeze_mode, + freeze_rate=dashou.freeze_rate, + freeze_fixed=dashou.freeze_fixed, + freeze_cap=dashou.freeze_cap, + min_credit_to_freeze=dashou.min_credit_to_freeze, + source_scope=yajin_items, + unfreeze_trigger=dashou.unfreeze_trigger, + schedule_mode=dashou.schedule_mode, + installment_json=dashou.installment_json or {}, + condition_json=dashou.condition_json or {}, + freeze_min_days=dashou.freeze_min_days or 0, + remark=(dashou.remark or '')[:255], + updated_by='migrate_yajin_split', + ) + new_row.save() + dashou.source_scope = [s for s in scope if s not in YAJIN_SOURCE_TYPES] + dashou.save(update_fields=['source_scope', 'UpdateTime']) + invalidate_freeze_config_cache(club_id, ROLE_DASHOU) + invalidate_freeze_config_cache(club_id, ROLE_DASHOU_YAJIN) + rows[ROLE_DASHOU_YAJIN] = new_row + + def build_config_payload(request) -> Dict[str, Any]: club_id = _club(request) rows = { r.role: r for r in ClubFundFreezeConfig.query.filter(club_id=club_id) } + try: + _migrate_legacy_yajin_scope(club_id, rows) + except Exception: + # 不阻断后台读取 + pass items = [] for role, name in ROLES: d = _cfg_to_dict(rows.get(role), club_id, role) d['role_name'] = name - avail, frozen = split_preview(club_id, role, Decimal('100'), 'order_settle') + preview_src = ( + SOURCE_YAJIN_RECHARGE if role == ROLE_DASHOU_YAJIN else 'order_settle' + ) + avail, frozen = split_preview(club_id, role, Decimal('100'), preview_src) d['preview_100'] = {'available': str(avail), 'frozen': str(frozen)} items.append(d) return {'club_id': club_id, 'roles': items} @@ -137,13 +198,34 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict] source_scope = data.get('source_scope') if source_scope is None: - # 默认同时覆盖订单结算与分红(管事/组长主要靠分红入账) - source_scope = ['order_settle', 'fenhong', 'withdraw_reject', 'club_migrate'] + if role == ROLE_DASHOU_YAJIN: + source_scope = list(YAJIN_DEFAULT_SCOPE) + else: + # 默认同时覆盖订单结算与分红(管事/组长主要靠分红入账) + source_scope = list(COMMISSION_DEFAULT_SCOPE) if isinstance(source_scope, str): try: source_scope = json.loads(source_scope) except Exception: source_scope = [source_scope] + if not isinstance(source_scope, list): + return None, 'source_scope 格式错误' + source_scope = [str(s).strip() for s in source_scope if str(s).strip()] + + # 押金配置与佣金配置严格分离:互不污染 source_scope + if role == ROLE_DASHOU_YAJIN: + source_scope = [s for s in source_scope if s in YAJIN_SOURCE_TYPES] + if not source_scope: + source_scope = list(YAJIN_DEFAULT_SCOPE) + elif role == ROLE_DASHOU: + source_scope = [s for s in source_scope if s not in YAJIN_SOURCE_TYPES] + if not source_scope: + return None, '打手佣金请至少选择一个非押金入账来源(押金请到「打手押金」页配置)' + else: + # 其他角色也不允许勾押金来源 + source_scope = [s for s in source_scope if s not in YAJIN_SOURCE_TYPES] + if not source_scope: + return None, '请至少选择一个入账来源' unfreeze_trigger = (data.get('unfreeze_trigger') or 'time_only').strip() if unfreeze_trigger not in (