diff --git a/jituan/management/commands/seed_fund_freeze_perm.py b/jituan/management/commands/seed_fund_freeze_perm.py new file mode 100644 index 0000000..b78ce4d --- /dev/null +++ b/jituan/management/commands/seed_fund_freeze_perm.py @@ -0,0 +1,125 @@ +"""写入资金冻结相关权限码,并可按俱乐部挂到角色(可重复执行)。 + +权限码: + djcfg001 资金冻结规则配置 + djled001 资金冻结明细/手动解冻 + djrisk001 罚款投诉风险指标 + +用法: + python manage.py seed_fund_freeze_perm + python manage.py seed_fund_freeze_perm --club-id lxs --role-name 管理员 + python manage.py seed_fund_freeze_perm --all-clubs --role-name 管理员 --dry-run +""" +import uuid + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from gvsdsdk.models import Permission, Role, RolePermission + +from jituan.models import Club + +PERMS = [ + ('djcfg001', '资金冻结规则配置', '配置本俱乐部各角色冻结比例/分期/解冻条件', '资金冻结'), + ('djled001', '资金冻结明细', '查看冻结流水并手动解冻', '资金冻结'), + ('djrisk001', '罚款投诉风险指标', '查看罚款率/投诉率等解冻相关指标', '资金冻结'), +] + + +class Command(BaseCommand): + help = '写入资金冻结权限码,并按俱乐部挂到角色' + + def add_arguments(self, parser): + parser.add_argument('--club-id', default='', help='仅处理该俱乐部') + parser.add_argument('--all-clubs', action='store_true') + parser.add_argument('--role-name', default='', help='角色名;空则只确保权限码') + parser.add_argument('--dry-run', action='store_true') + + def handle(self, *args, **options): + dry_run = options['dry_run'] + club_id = (options.get('club_id') or '').strip() + all_clubs = options.get('all_clubs') + role_name = (options.get('role_name') or '').strip() + + sample = Permission.objects.filter(PermStatus=1).first() + tenant_uuid = sample.TenantUUID if sample else uuid.UUID( + '00000000-0000-0000-0000-000000000001' + ).bytes + + perm_objs = [] + for code, name, desc, cat in PERMS: + perm = Permission.objects.filter(PermCode=code).first() + if perm: + self.stdout.write(f' 权限码已存在: {code}') + if not dry_run: + changed = False + if perm.PermName != name: + perm.PermName = name + changed = True + if (perm.PermDesc or '') != desc: + perm.PermDesc = desc + changed = True + if (perm.PermCategory or '') != cat: + perm.PermCategory = cat + changed = True + if changed: + perm.save(update_fields=['PermName', 'PermDesc', 'PermCategory']) + else: + if dry_run: + self.stdout.write(f'would create perm {code}') + else: + perm = Permission.objects.create( + PermUUID=uuid.uuid4().bytes, + TenantUUID=tenant_uuid, + PermCode=code, + PermName=name, + PermDesc=desc, + PermCategory=cat, + PermStatus=1, + CreateTime=timezone.now(), + UpdateTime=timezone.now(), + ) + self.stdout.write(self.style.SUCCESS(f' 已创建权限码: {code}')) + if perm: + perm_objs.append(perm) + + if not role_name: + self.stdout.write('未指定 --role-name,跳过挂角色') + return + + clubs = [] + if all_clubs: + clubs = list(Club.query.filter(status=1).values_list('club_id', flat=True)) + elif club_id: + clubs = [club_id] + else: + self.stdout.write(self.style.WARNING('请指定 --club-id 或 --all-clubs')) + return + + with transaction.atomic(): + for cid in clubs: + roles = Role.objects.filter(RoleName=role_name, ClubID=cid, RoleStatus=1) + if not roles.exists(): + # 兼容无 ClubID 字段旧数据:仅精确 club 时再试全局同名 + roles = Role.objects.filter(RoleName=role_name, RoleStatus=1) + roles = [r for r in roles if (getattr(r, 'ClubID', None) or '') == cid] + for role in roles: + for perm in perm_objs: + exists = RolePermission.objects.filter( + RoleUUID=role.RoleUUID, PermUUID=perm.PermUUID, + ).exists() + if exists: + self.stdout.write(f' 已挂: {cid} {role_name} <- {perm.PermCode}') + continue + if dry_run: + self.stdout.write(f'would link {cid} {role_name} <- {perm.PermCode}') + else: + RolePermission.objects.create( + RoleUUID=role.RoleUUID, + PermUUID=perm.PermUUID, + CreateTime=timezone.now(), + ) + self.stdout.write(self.style.SUCCESS( + f' 已挂: {cid} {role_name} <- {perm.PermCode}' + )) diff --git a/jituan/management/commands/seed_gvsdsdk_permissions.py b/jituan/management/commands/seed_gvsdsdk_permissions.py index 9a3b17d..6632447 100644 --- a/jituan/management/commands/seed_gvsdsdk_permissions.py +++ b/jituan/management/commands/seed_gvsdsdk_permissions.py @@ -50,6 +50,24 @@ EXTRA_PERMISSIONS = [ '审核本俱乐部提现(同意/拒绝/自动打款)。跨俱乐部仅集团高管;须角色 ClubID + 数据范围任职。', '提现管理', ), + ( + 'djcfg001', + '资金冻结规则配置', + '配置本俱乐部各角色冻结比例/分期/解冻条件', + '资金冻结', + ), + ( + 'djled001', + '资金冻结明细', + '查看冻结流水并手动解冻', + '资金冻结', + ), + ( + 'djrisk001', + '罚款投诉风险指标', + '查看罚款率/投诉率等解冻相关指标', + '资金冻结', + ), # ---------- 店铺管理(999 系列:列表/详情/复制/提现等) ---------- ('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'), ('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'), diff --git a/jituan/management/commands/seed_kefu_menu.py b/jituan/management/commands/seed_kefu_menu.py index 4930d60..c800cec 100644 --- a/jituan/management/commands/seed_kefu_menu.py +++ b/jituan/management/commands/seed_kefu_menu.py @@ -37,3 +37,8 @@ class Command(BaseCommand): call_command('seed_gvsdsdk_permissions') except Exception as e: self.stdout.write(self.style.WARNING(f'seed_gvsdsdk_permissions 跳过: {e}')) + try: + from django.core.management import call_command + call_command('seed_fund_freeze_perm') + except Exception as e: + self.stdout.write(self.style.WARNING(f'seed_fund_freeze_perm 跳过: {e}')) diff --git a/jituan/migrations/0017_fund_freeze.py b/jituan/migrations/0017_fund_freeze.py new file mode 100644 index 0000000..77c93c4 --- /dev/null +++ b/jituan/migrations/0017_fund_freeze.py @@ -0,0 +1,131 @@ +# Generated manually for fund freeze P0 tables + +from decimal import Decimal + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('jituan', '0016_club_order_grab_link'), + ] + + operations = [ + migrations.CreateModel( + name='ClubFundFreezeConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部')), + ('role', models.CharField(choices=[('dashou', '打手'), ('guanshi', '管事'), ('zuzhang', '组长'), ('shenheguan', '考核官')], db_index=True, max_length=16, verbose_name='角色')), + ('enabled', models.BooleanField(default=False, verbose_name='是否启用冻结')), + ('freeze_mode', models.SmallIntegerField(choices=[(1, '按比例'), (2, '固定额'), (3, '比例+单笔上限')], default=1)), + ('freeze_rate', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='0.2=20%', max_digits=8, verbose_name='冻结比例')), + ('freeze_fixed', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12)), + ('freeze_cap', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, verbose_name='单笔冻结上限(0=不限)')), + ('min_credit_to_freeze', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12, verbose_name='低于此入账不冻')), + ('source_scope', models.JSONField(blank=True, default=list, help_text='空=全部来源;如 ["order_settle"]', verbose_name='入账来源白名单')), + ('unfreeze_trigger', models.CharField(choices=[('time_only', '仅到期'), ('condition_only', '仅条件达标'), ('time_and_condition', '到期且达标'), ('time_or_condition', '到期或达标'), ('manual_only', '仅手动')], default='time_only', max_length=32)), + ('schedule_mode', models.CharField(choices=[('all_at_once', '一次全解'), ('installment', '分期解冻')], default='all_at_once', max_length=16)), + ('installment_json', models.JSONField(blank=True, default=dict, help_text='{"steps":[...]}', verbose_name='分期计划')), + ('condition_json', models.JSONField(blank=True, default=dict, verbose_name='解冻条件')), + ('freeze_min_days', models.IntegerField(default=0, verbose_name='最短冻结天数')), + ('remark', models.CharField(blank=True, default='', max_length=255)), + ('updated_by', models.CharField(blank=True, default='', max_length=32)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '资金冻结规则', + 'db_table': 'club_fund_freeze_config', + 'unique_together': {('club_id', 'role')}, + }, + ), + migrations.CreateModel( + name='FundFreezeLedger', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('user_id', models.CharField(db_index=True, max_length=16, verbose_name='UserUID')), + ('role', models.CharField(db_index=True, max_length=16)), + ('amount_total', models.DecimalField(decimal_places=2, max_digits=12)), + ('amount_unfrozen', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=12)), + ('status', models.SmallIntegerField(db_index=True, default=1)), + ('frozen_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('source_type', models.CharField(db_index=True, max_length=32)), + ('order_id', models.CharField(blank=True, db_index=True, default='', max_length=64)), + ('czjilu_id', models.CharField(blank=True, db_index=True, default='', max_length=64)), + ('biz_id', models.CharField(max_length=64, verbose_name='幂等业务键')), + ('freeze_reason', models.CharField(blank=True, default='', max_length=255)), + ('config_snapshot', models.JSONField(blank=True, default=dict)), + ('current_step', models.IntegerField(default=1)), + ('next_eligible_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '资金冻结主单', + 'db_table': 'fund_freeze_ledger', + 'unique_together': {('club_id', 'role', 'source_type', 'biz_id')}, + }, + ), + migrations.CreateModel( + name='FundUnfreezeEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ledger_id', models.BigIntegerField(db_index=True)), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('user_id', models.CharField(db_index=True, max_length=16)), + ('role', models.CharField(max_length=16)), + ('step_seq', models.IntegerField(default=1)), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('unfrozen_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('trigger', models.CharField(default='schedule', max_length=16)), + ('reason', models.CharField(blank=True, default='', max_length=255)), + ('operator_id', models.CharField(blank=True, default='', max_length=32)), + ('condition_snapshot', models.JSONField(blank=True, default=dict)), + ], + options={ + 'verbose_name': '资金解冻事件', + 'db_table': 'fund_unfreeze_event', + }, + ), + migrations.CreateModel( + name='FundRiskStatDaily', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('user_id', models.CharField(db_index=True, max_length=16)), + ('role', models.CharField(db_index=True, max_length=16)), + ('day', models.DateField(db_index=True)), + ('completed_orders', models.IntegerField(default=0)), + ('fine_count', models.IntegerField(default=0)), + ('complaint_count', models.IntegerField(default=0)), + ('valid_invite_count', models.IntegerField(default=0)), + ('meta_json', models.JSONField(blank=True, default=dict)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '资金风险日汇总', + 'db_table': 'fund_risk_stat_daily', + 'unique_together': {('club_id', 'user_id', 'role', 'day')}, + }, + ), + migrations.AddIndex( + model_name='fundfreezeledger', + index=models.Index(fields=['status', 'next_eligible_at'], name='idx_ffl_status_next'), + ), + migrations.AddIndex( + model_name='fundfreezeledger', + index=models.Index(fields=['user_id', 'role', 'status'], name='idx_ffl_user_role'), + ), + migrations.AddIndex( + model_name='fundfreezeledger', + index=models.Index(fields=['club_id', 'status', 'frozen_at'], name='idx_ffl_club_st'), + ), + migrations.AddIndex( + model_name='fundriskstatdaily', + index=models.Index(fields=['club_id', 'role', 'day'], name='idx_frsd_club_role_day'), + ), + ] diff --git a/jituan/models.py b/jituan/models.py index 3aa2682..54f372f 100644 --- a/jituan/models.py +++ b/jituan/models.py @@ -464,3 +464,154 @@ class ClubOrderGrabLink(QModel): models.Index(fields=['grabber_club_id'], name='idx_cogl_grabber'), models.Index(fields=['order_club_id'], name='idx_cogl_order'), ] + + +# ==================== 资金冻结(默认关闭,按俱乐部×角色开启)==================== + +class ClubFundFreezeConfig(QModel): + """俱乐部×角色冻结规则。enabled=False 时入账行为与现网完全一致。""" + ROLE_CHOICES = ( + ('dashou', '打手'), + ('guanshi', '管事'), + ('zuzhang', '组长'), + ('shenheguan', '考核官'), + ) + FREEZE_MODE_CHOICES = ( + (1, '按比例'), + (2, '固定额'), + (3, '比例+单笔上限'), + ) + UNFREEZE_TRIGGER_CHOICES = ( + ('time_only', '仅到期'), + ('condition_only', '仅条件达标'), + ('time_and_condition', '到期且达标'), + ('time_or_condition', '到期或达标'), + ('manual_only', '仅手动'), + ) + SCHEDULE_MODE_CHOICES = ( + ('all_at_once', '一次全解'), + ('installment', '分期解冻'), + ) + + club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部') + role = models.CharField(max_length=16, choices=ROLE_CHOICES, db_index=True, verbose_name='角色') + enabled = models.BooleanField(default=False, verbose_name='是否启用冻结') + freeze_mode = models.SmallIntegerField(default=1, choices=FREEZE_MODE_CHOICES) + freeze_rate = models.DecimalField( + max_digits=8, decimal_places=6, default=Decimal('0'), + verbose_name='冻结比例', help_text='0.2=20%', + ) + freeze_fixed = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0')) + freeze_cap = models.DecimalField( + max_digits=12, decimal_places=2, default=Decimal('0'), + verbose_name='单笔冻结上限(0=不限)', + ) + min_credit_to_freeze = models.DecimalField( + max_digits=12, decimal_places=2, default=Decimal('0'), + verbose_name='低于此入账不冻', + ) + source_scope = models.JSONField( + default=list, blank=True, + verbose_name='入账来源白名单', + help_text='空=全部来源;如 ["order_settle"]', + ) + unfreeze_trigger = models.CharField( + max_length=32, default='time_only', choices=UNFREEZE_TRIGGER_CHOICES, + ) + schedule_mode = models.CharField( + max_length=16, default='all_at_once', choices=SCHEDULE_MODE_CHOICES, + ) + installment_json = models.JSONField( + default=dict, blank=True, + verbose_name='分期计划', + help_text='{"steps":[{"seq":1,"after_days":7,"type":"ratio","value":0.3,"need_condition":false}]}', + ) + condition_json = models.JSONField(default=dict, blank=True, verbose_name='解冻条件') + freeze_min_days = models.IntegerField(default=0, verbose_name='最短冻结天数') + remark = models.CharField(max_length=255, blank=True, default='') + updated_by = models.CharField(max_length=32, blank=True, default='') + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'club_fund_freeze_config' + verbose_name = '资金冻结规则' + unique_together = [['club_id', 'role']] + + +class FundFreezeLedger(QModel): + """单笔入账冻结主单。""" + STATUS_FROZEN = 1 + STATUS_PARTIAL = 2 + STATUS_DONE = 3 + STATUS_VOID = 4 + + club_id = models.CharField(max_length=16, db_index=True) + user_id = models.CharField(max_length=16, db_index=True, verbose_name='UserUID') + role = models.CharField(max_length=16, db_index=True) + amount_total = models.DecimalField(max_digits=12, decimal_places=2) + amount_unfrozen = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0')) + status = models.SmallIntegerField(default=1, db_index=True) + frozen_at = models.DateTimeField(auto_now_add=True, db_index=True) + source_type = models.CharField(max_length=32, db_index=True) + order_id = models.CharField(max_length=64, blank=True, default='', db_index=True) + czjilu_id = models.CharField(max_length=64, blank=True, default='', db_index=True) + biz_id = models.CharField(max_length=64, verbose_name='幂等业务键') + freeze_reason = models.CharField(max_length=255, blank=True, default='') + config_snapshot = models.JSONField(default=dict, blank=True) + current_step = models.IntegerField(default=1) + next_eligible_at = models.DateTimeField(null=True, blank=True, db_index=True) + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'fund_freeze_ledger' + verbose_name = '资金冻结主单' + unique_together = [['club_id', 'role', 'source_type', 'biz_id']] + indexes = [ + models.Index(fields=['status', 'next_eligible_at'], name='idx_ffl_status_next'), + models.Index(fields=['user_id', 'role', 'status'], name='idx_ffl_user_role'), + models.Index(fields=['club_id', 'status', 'frozen_at'], name='idx_ffl_club_st'), + ] + + +class FundUnfreezeEvent(QModel): + """每一次解冻执行记录。""" + ledger_id = models.BigIntegerField(db_index=True) + club_id = models.CharField(max_length=16, db_index=True) + user_id = models.CharField(max_length=16, db_index=True) + role = models.CharField(max_length=16) + step_seq = models.IntegerField(default=1) + amount = models.DecimalField(max_digits=12, decimal_places=2) + unfrozen_at = models.DateTimeField(auto_now_add=True, db_index=True) + trigger = models.CharField(max_length=16, default='schedule') + reason = models.CharField(max_length=255, blank=True, default='') + operator_id = models.CharField(max_length=32, blank=True, default='') + condition_snapshot = models.JSONField(default=dict, blank=True) + + class Meta: + db_table = 'fund_unfreeze_event' + verbose_name = '资金解冻事件' + + +class FundRiskStatDaily(QModel): + """日汇总:罚款/投诉/完成单等,供解冻条件与后台展示。""" + club_id = models.CharField(max_length=16, db_index=True) + user_id = models.CharField(max_length=16, db_index=True) + role = models.CharField(max_length=16, db_index=True) + day = models.DateField(db_index=True) + completed_orders = models.IntegerField(default=0) + fine_count = models.IntegerField(default=0) + complaint_count = models.IntegerField(default=0) + valid_invite_count = models.IntegerField(default=0) + meta_json = models.JSONField(default=dict, blank=True) + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'fund_risk_stat_daily' + verbose_name = '资金风险日汇总' + unique_together = [['club_id', 'user_id', 'role', 'day']] + indexes = [ + models.Index(fields=['club_id', 'role', 'day'], name='idx_frsd_club_role_day'), + ] diff --git a/jituan/services/fund_freeze.py b/jituan/services/fund_freeze.py new file mode 100644 index 0000000..69d2d7e --- /dev/null +++ b/jituan/services/fund_freeze.py @@ -0,0 +1,275 @@ +"""资金冻结入账网关(P0)。 + +默认:无配置或 enabled=False → 全额进可提现,与现网一致,不写流水。 +开启后:拆「可提现 + 冻结池」并写 FundFreezeLedger。 + +注意:P0 仅提供网关;结算调用点在 P1 收口,避免一次改太多接口。 +""" +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.core.cache import cache +from django.db.models import F +from django.utils import timezone + +from jituan.constants import CLUB_ID_DEFAULT +from jituan.models import ClubFundFreezeConfig, FundFreezeLedger + +logger = logging.getLogger(__name__) + +ROLE_DASHOU = 'dashou' +ROLE_GUANSHI = 'guanshi' +ROLE_ZUZHANG = 'zuzhang' +ROLE_SHENHEGUAN = 'shenheguan' + +SOURCE_ORDER_SETTLE = 'order_settle' +SOURCE_FENHONG = 'fenhong' +SOURCE_KAOHE_FEE = 'kaohe_fee' + +_CFG_CACHE_TTL = 60 + + +def _money(v) -> Decimal: + return Decimal(str(v or 0)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + + +def _club(club_id: Optional[str]) -> str: + return (club_id or '').strip() or CLUB_ID_DEFAULT + + +def get_freeze_config(club_id: Optional[str], role: str) -> Optional[ClubFundFreezeConfig]: + cid = _club(club_id) + role = (role or '').strip().lower() + key = f'fund_freeze_cfg:{cid}:{role}' + cached = cache.get(key) + if cached is not None: + return cached or None + row = ClubFundFreezeConfig.query.filter(club_id=cid, role=role).first() + cache.set(key, row or False, _CFG_CACHE_TTL) + return row + + +def invalidate_freeze_config_cache(club_id: Optional[str] = None, role: Optional[str] = None): + if club_id and role: + cache.delete(f'fund_freeze_cfg:{_club(club_id)}:{(role or "").strip().lower()}') + return + # 粗粒度:调用方改配置后建议带 club+role 清;此处留空避免误扫 + + +def calc_freeze_amount(credit: Decimal, cfg: ClubFundFreezeConfig) -> Decimal: + credit = _money(credit) + if credit <= 0: + return Decimal('0.00') + min_c = _money(cfg.min_credit_to_freeze) + if min_c > 0 and credit < min_c: + return Decimal('0.00') + + mode = int(cfg.freeze_mode or 1) + frozen = Decimal('0.00') + if mode == 2: + frozen = _money(cfg.freeze_fixed) + elif mode == 3: + frozen = _money(credit * Decimal(str(cfg.freeze_rate or 0))) + cap = _money(cfg.freeze_cap) + if cap > 0: + frozen = min(frozen, cap) + else: + frozen = _money(credit * Decimal(str(cfg.freeze_rate or 0))) + cap = _money(cfg.freeze_cap) + if cap > 0: + frozen = min(frozen, cap) + + if frozen < 0: + frozen = Decimal('0.00') + if frozen > credit: + frozen = credit + return frozen + + +def _source_allowed(cfg: ClubFundFreezeConfig, source_type: str) -> bool: + scope = cfg.source_scope + if not scope: + return True + if isinstance(scope, str): + return source_type == scope + try: + return source_type in list(scope) + except TypeError: + return True + + +def _config_snapshot(cfg: ClubFundFreezeConfig) -> Dict[str, Any]: + return { + 'club_id': cfg.club_id, + 'role': cfg.role, + 'enabled': cfg.enabled, + 'freeze_mode': cfg.freeze_mode, + 'freeze_rate': str(cfg.freeze_rate), + 'freeze_fixed': str(cfg.freeze_fixed), + 'freeze_cap': str(cfg.freeze_cap), + 'min_credit_to_freeze': str(cfg.min_credit_to_freeze), + 'source_scope': cfg.source_scope, + 'unfreeze_trigger': cfg.unfreeze_trigger, + 'schedule_mode': cfg.schedule_mode, + 'installment_json': cfg.installment_json, + 'condition_json': cfg.condition_json, + 'freeze_min_days': cfg.freeze_min_days, + } + + +def _first_step_delay_days(cfg: ClubFundFreezeConfig) -> int: + if (cfg.schedule_mode or '') == 'installment': + steps = (cfg.installment_json or {}).get('steps') or [] + if steps: + try: + return max(0, int(steps[0].get('after_days') or 0)) + except (TypeError, ValueError): + pass + return max(0, int(cfg.freeze_min_days or 0)) + + +def _add_withdrawable(role: str, profile, available: Decimal, also_zonge: bool = False): + """原子增加可提现;组长字段为 ketixian_jine。""" + available = _money(available) + if available == 0: + return + if role == ROLE_ZUZHANG: + type(profile).query.filter(pk=profile.pk).update( + ketixian_jine=F('ketixian_jine') + available, + ) + else: + updates = {'yue': F('yue') + available} + if also_zonge and hasattr(profile, 'zonge'): + updates['zonge'] = F('zonge') + available + type(profile).query.filter(pk=profile.pk).update(**updates) + + +def _add_dongjie(profile, frozen: Decimal): + frozen = _money(frozen) + if frozen == 0: + return + type(profile).query.filter(pk=profile.pk).update( + dongjie_yue=F('dongjie_yue') + frozen, + ) + + +def credit_role_balance( + *, + club_id: Optional[str], + user_id: str, + role: str, + amount, + source_type: str, + biz_id: str, + profile=None, + order_id: str = '', + czjilu_id: str = '', + freeze_reason: str = '', + also_credit_zonge: bool = False, +) -> Dict[str, Any]: + """ + 统一入账。返回 {available, frozen, ledger_id, skipped_freeze}。 + + 调用方须在外层 transaction.atomic() 内,并已持有/即将更新的业务一致性。 + profile: 已加载的角色扩展实例(UserDashou 等);若空则按 role+user_id 查。 + """ + role = (role or '').strip().lower() + cid = _club(club_id) + credit = _money(amount) + biz_id = (biz_id or '').strip() + source_type = (source_type or '').strip() or 'unknown' + + if credit <= 0: + return {'available': Decimal('0.00'), 'frozen': Decimal('0.00'), 'ledger_id': None, 'skipped_freeze': True} + + if profile is None: + profile = _load_profile(role, user_id) + if profile is None: + raise ValueError(f'找不到角色资料 role={role} user_id={user_id}') + + cfg = get_freeze_config(cid, role) + freeze_on = bool( + cfg and cfg.enabled and _source_allowed(cfg, source_type) and biz_id + ) + frozen = calc_freeze_amount(credit, cfg) if freeze_on else Decimal('0.00') + available = _money(credit - frozen) + + # 幂等:已存在同 biz 冻结单则只补可提现差额不重复冻(极端重入) + if freeze_on and frozen > 0: + existed = FundFreezeLedger.query.filter( + club_id=cid, role=role, source_type=source_type, biz_id=biz_id, + ).first() + if existed: + logger.warning( + 'fund_freeze idempotent hit club=%s role=%s source=%s biz=%s — 不再入账', + cid, role, source_type, biz_id, + ) + return { + 'available': Decimal('0.00'), + 'frozen': Decimal('0.00'), + 'ledger_id': existed.id, + 'skipped_freeze': True, + 'idempotent': True, + } + + _add_withdrawable(role, profile, available, also_zonge=also_credit_zonge) + ledger_id = None + if frozen > 0: + _add_dongjie(profile, frozen) + delay = _first_step_delay_days(cfg) + next_at = timezone.now() + timedelta(days=delay) if delay else timezone.now() + ledger = FundFreezeLedger.query.create( + club_id=cid, + user_id=str(user_id), + role=role, + amount_total=frozen, + amount_unfrozen=Decimal('0.00'), + status=FundFreezeLedger.STATUS_FROZEN, + source_type=source_type, + 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], + config_snapshot=_config_snapshot(cfg), + current_step=1, + next_eligible_at=next_at, + ) + ledger_id = ledger.id + + return { + 'available': available, + 'frozen': frozen, + 'ledger_id': ledger_id, + 'skipped_freeze': frozen <= 0, + } + + +def _load_profile(role: str, user_id: str): + from users.models import User, UserDashou, UserGuanshi, UserZuzhang, UserShenheguan + user = User.query.filter(UserUID=user_id).first() + if not user: + return None + mapping = { + ROLE_DASHOU: UserDashou, + ROLE_GUANSHI: UserGuanshi, + ROLE_ZUZHANG: UserZuzhang, + ROLE_SHENHEGUAN: UserShenheguan, + } + model = mapping.get(role) + if not model: + return None + return model.query.filter(user=user).first() + + +def split_preview(club_id: Optional[str], role: str, amount, source_type: str = SOURCE_ORDER_SETTLE) -> Tuple[Decimal, Decimal]: + """只算不写库:后台试算用。""" + credit = _money(amount) + cfg = get_freeze_config(club_id, role) + if not cfg or not cfg.enabled or not _source_allowed(cfg, source_type): + return credit, Decimal('0.00') + frozen = calc_freeze_amount(credit, cfg) + return _money(credit - frozen), frozen diff --git a/jituan/services/kefu_menu_definitions.py b/jituan/services/kefu_menu_definitions.py index f99ef42..e762f9d 100644 --- a/jituan/services/kefu_menu_definitions.py +++ b/jituan/services/kefu_menu_definitions.py @@ -6,6 +6,12 @@ KEFU_MENU_ROW_DEFS = [ 'perm_codes': ['bankuai']}, {'page_id': 'finance.data', 'name': '财务数据', 'path': '/finance/data', 'parent_id': 'finance', 'sort_order': 12, 'perm_codes': ['caiwu']}, + {'page_id': 'finance.fund-freeze-config', 'name': '资金冻结规则', 'path': '/finance/fund-freeze-config', + 'parent_id': 'finance', 'sort_order': 13, 'perm_codes': ['djcfg001']}, + {'page_id': 'finance.fund-freeze-ledger', 'name': '资金冻结明细', 'path': '/finance/fund-freeze-ledger', + 'parent_id': 'finance', 'sort_order': 14, 'perm_codes': ['djled001']}, + {'page_id': 'finance.fund-risk-stats', 'name': '罚款投诉指标', 'path': '/finance/fund-risk-stats', + 'parent_id': 'finance', 'sort_order': 15, 'perm_codes': ['djrisk001']}, {'page_id': 'assessment', 'name': '考核管理', 'path': '', 'parent_id': '', 'sort_order': 20}, {'page_id': 'assessment.examiner', 'name': '考核官管理', 'path': '/assessment/examiner', 'parent_id': 'assessment', 'sort_order': 21, 'perm_codes': ['kaohepeizhi']}, diff --git a/users/migrations/0007_role_fund_freeze_fields.py b/users/migrations/0007_role_fund_freeze_fields.py new file mode 100644 index 0000000..b6038b0 --- /dev/null +++ b/users/migrations/0007_role_fund_freeze_fields.py @@ -0,0 +1,133 @@ +# Generated manually for fund freeze P0 fields on role profiles + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0006_yonghu_c_yaoqing'), + ] + + operations = [ + migrations.AddField( + model_name='userdashou', + name='dongjie_yue', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='冻结资金池'), + ), + migrations.AddField( + model_name='userdashou', + name='risk_fine_rate', + field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口罚款率'), + ), + migrations.AddField( + model_name='userdashou', + name='risk_complaint_rate', + field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口投诉率'), + ), + migrations.AddField( + model_name='userdashou', + name='risk_completed_orders', + field=models.IntegerField(default=0, verbose_name='窗口完成单量'), + ), + migrations.AddField( + model_name='userdashou', + name='risk_valid_invites', + field=models.IntegerField(default=0, verbose_name='窗口有效邀请数'), + ), + migrations.AddField( + model_name='userdashou', + name='risk_stat_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='风险快照时间'), + ), + migrations.AddField( + model_name='userguanshi', + name='dongjie_yue', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='冻结资金池'), + ), + migrations.AddField( + model_name='userguanshi', + name='risk_fine_rate', + field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口罚款率'), + ), + migrations.AddField( + model_name='userguanshi', + name='risk_complaint_rate', + field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口投诉率'), + ), + migrations.AddField( + model_name='userguanshi', + name='risk_completed_orders', + field=models.IntegerField(default=0, verbose_name='窗口完成单量'), + ), + migrations.AddField( + model_name='userguanshi', + name='risk_valid_invites', + field=models.IntegerField(default=0, verbose_name='窗口有效邀请数'), + ), + migrations.AddField( + model_name='userguanshi', + name='risk_stat_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='风险快照时间'), + ), + migrations.AddField( + model_name='userzuzhang', + name='dongjie_yue', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='冻结资金池'), + ), + migrations.AddField( + model_name='userzuzhang', + name='risk_fine_rate', + field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口罚款率'), + ), + migrations.AddField( + model_name='userzuzhang', + name='risk_complaint_rate', + field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口投诉率'), + ), + migrations.AddField( + model_name='userzuzhang', + name='risk_completed_orders', + field=models.IntegerField(default=0, verbose_name='窗口完成单量'), + ), + migrations.AddField( + model_name='userzuzhang', + name='risk_valid_invites', + field=models.IntegerField(default=0, verbose_name='窗口有效邀请数'), + ), + migrations.AddField( + model_name='userzuzhang', + name='risk_stat_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='风险快照时间'), + ), + migrations.AddField( + model_name='usershenheguan', + name='dongjie_yue', + field=models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='冻结资金池'), + ), + migrations.AddField( + model_name='usershenheguan', + name='risk_fine_rate', + field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口罚款率'), + ), + migrations.AddField( + model_name='usershenheguan', + name='risk_complaint_rate', + field=models.DecimalField(decimal_places=6, default=0, max_digits=8, verbose_name='窗口投诉率'), + ), + migrations.AddField( + model_name='usershenheguan', + name='risk_completed_orders', + field=models.IntegerField(default=0, verbose_name='窗口完成单量'), + ), + migrations.AddField( + model_name='usershenheguan', + name='risk_valid_invites', + field=models.IntegerField(default=0, verbose_name='窗口有效邀请数'), + ), + migrations.AddField( + model_name='usershenheguan', + name='risk_stat_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='风险快照时间'), + ), + ] diff --git a/users/models.py b/users/models.py index d35048e..628c8bb 100644 --- a/users/models.py +++ b/users/models.py @@ -158,6 +158,13 @@ class UserDashou(QModel): jinyueshouyi = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='今月收益金额') jifen = models.IntegerField(default=0, verbose_name='打手积分') yajin= models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='打手押金') + # 资金冻结池 + 风险快照(冻结功能;未开启俱乐部保持 0,不影响现网) + dongjie_yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='冻结资金池') + risk_fine_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口罚款率') + risk_complaint_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口投诉率') + risk_completed_orders = models.IntegerField(default=0, verbose_name='窗口完成单量') + risk_valid_invites = models.IntegerField(default=0, verbose_name='窗口有效邀请数') + risk_stat_at = models.DateTimeField(null=True, blank=True, verbose_name='风险快照时间') CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') last_tixian_date = models.DateField(null=True, blank=True, verbose_name='最近提现日期') # 用于重置今日提现金额 @@ -223,6 +230,12 @@ class UserGuanshi(QModel): jinyuechongzhi = models.IntegerField(default=0, verbose_name='今月充值打手数量') chongzhifenrun = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='充值分佣总额') yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='可提现余额') + dongjie_yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='冻结资金池') + risk_fine_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口罚款率') + risk_complaint_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口投诉率') + risk_completed_orders = models.IntegerField(default=0, verbose_name='窗口完成单量') + risk_valid_invites = models.IntegerField(default=0, verbose_name='窗口有效邀请数') + risk_stat_at = models.DateTimeField(null=True, blank=True, verbose_name='风险快照时间') CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') # 新增:首次分红金额(针对特殊用户) fenhong_yici = models.DecimalField(max_digits=10,decimal_places=2,default=0.00,verbose_name='首次分红金额') @@ -266,6 +279,12 @@ class UserZuzhang(QModel): fenyong_zonge = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='分佣总额(元)') # 可提现金额 ketixian_jine = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='可提现金额(元)') + dongjie_yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='冻结资金池') + risk_fine_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口罚款率') + risk_complaint_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口投诉率') + risk_completed_orders = models.IntegerField(default=0, verbose_name='窗口完成单量') + risk_valid_invites = models.IntegerField(default=0, verbose_name='窗口有效邀请数') + risk_stat_at = models.DateTimeField(null=True, blank=True, verbose_name='风险快照时间') # 邀请总数 yaoqing_zongshu = models.IntegerField(default=0, verbose_name='邀请总数') # 今日分佣 @@ -315,6 +334,12 @@ class UserShenheguan(QModel): tongguo_zongshu = models.IntegerField(default=0, verbose_name='通过总数') yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='可提现余额') zonge = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='赚取总额') + dongjie_yue = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name='冻结资金池') + risk_fine_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口罚款率') + risk_complaint_rate = models.DecimalField(max_digits=8, decimal_places=6, default=0, verbose_name='窗口投诉率') + risk_completed_orders = models.IntegerField(default=0, verbose_name='窗口完成单量') + risk_valid_invites = models.IntegerField(default=0, verbose_name='窗口有效邀请数') + risk_stat_at = models.DateTimeField(null=True, blank=True, verbose_name='风险快照时间') CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')