Compare commits
9 Commits
pre-freeze
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
332fde50a3 | ||
|
|
4799282579 | ||
|
|
890e465e40 | ||
|
|
fa6e679cbe | ||
|
|
6c96eb3bcb | ||
|
|
a505a1ab2d | ||
|
|
3194a47f52 | ||
|
|
6a1895e566 | ||
|
|
d40a27a5ca |
@@ -17,7 +17,7 @@ app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
# 自动发现所有已注册app中的tasks.py文件
|
||||
#app.autodiscover_tasks()
|
||||
# 自动发现任务(不含 orders.tasks:订单超时自动结算已永久关闭)
|
||||
app.autodiscover_tasks(['users.tasks', 'users.ranking_tasks', 'config.tasks'])
|
||||
app.autodiscover_tasks(['users.tasks', 'users.ranking_tasks', 'config.tasks', 'jituan.tasks'])
|
||||
|
||||
# 配置周期性任务(Celery Beat Schedule)
|
||||
app.conf.beat_schedule = {
|
||||
@@ -84,7 +84,17 @@ app.conf.beat_schedule = {
|
||||
},
|
||||
'args': (),
|
||||
'kwargs': {}
|
||||
}
|
||||
},
|
||||
|
||||
# 5. 资金冻结到期解冻(默认无冻结单时几乎空跑)
|
||||
'scan_fund_freeze_due': {
|
||||
'task': 'jituan.tasks.scan_fund_freeze_due',
|
||||
'schedule': crontab(minute='*/5'),
|
||||
'options': {
|
||||
'queue': 'periodic_tasks',
|
||||
'priority': 4,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +107,7 @@ app.conf.task_routes = {
|
||||
# 'orders.tasks.*': {'queue': 'order_tasks'},
|
||||
'users.tasks.*': {'queue': 'periodic_tasks'},
|
||||
'config.tasks.*': {'queue': 'periodic_tasks'},
|
||||
'jituan.tasks.*': {'queue': 'periodic_tasks'},
|
||||
}
|
||||
|
||||
# 任务序列化
|
||||
|
||||
@@ -330,6 +330,9 @@ class KefuGetDashouDetailView(APIView):
|
||||
'tuikuanliang': dashou.tuikuanliang,
|
||||
'jinrijiedan': dashou.jinrijiedan,
|
||||
'yue': float(dashou.yue) if dashou.yue else 0.00,
|
||||
'dongjie_yue': float(getattr(dashou, 'dongjie_yue', 0) or 0),
|
||||
'risk_fine_rate': float(getattr(dashou, 'risk_fine_rate', 0) or 0),
|
||||
'risk_complaint_rate': float(getattr(dashou, 'risk_complaint_rate', 0) or 0),
|
||||
'zonge': float(dashou.zonge) if dashou.zonge else 0.00,
|
||||
'jifen': dashou.jifen,
|
||||
'yajin': float(dashou.yajin) if dashou.yajin else 0.00,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""投诉工单常量。"""
|
||||
|
||||
# 投诉类型
|
||||
TYPE_ORDER = 1 # 订单投诉(需订单 OrderID)
|
||||
TYPE_RECHARGE = 2 # 充值投诉(需 Czjilu.dingdan_id)
|
||||
TYPE_PENALTY = 3 # 罚款投诉(需罚单 Penalty.id)
|
||||
TYPE_ORDER = 1 # 订单投诉
|
||||
TYPE_RECHARGE = 2 # 充值投诉
|
||||
TYPE_PENALTY = 3 # 罚款投诉
|
||||
TYPE_GUANSHI = 4 # 管事投诉
|
||||
TYPE_FRAUD = 5 # 被骗投诉
|
||||
TYPE_ZUZHANG = 6 # 组长投诉
|
||||
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_ORDER, '订单投诉'),
|
||||
@@ -13,30 +14,47 @@ TYPE_CHOICES = (
|
||||
(TYPE_PENALTY, '罚款投诉'),
|
||||
(TYPE_GUANSHI, '管事投诉'),
|
||||
(TYPE_FRAUD, '被骗投诉'),
|
||||
(TYPE_ZUZHANG, '组长投诉'),
|
||||
)
|
||||
|
||||
TYPE_LABELS = dict(TYPE_CHOICES)
|
||||
|
||||
# 状态:1处理中 2已处理 3不满意(可升级)4已关闭
|
||||
# 状态:1处理中 2已处理 3不满意 4已关闭 5协商中
|
||||
STATUS_PROCESSING = 1
|
||||
STATUS_DONE = 2
|
||||
STATUS_DISSATISFIED = 3
|
||||
STATUS_CLOSED = 4
|
||||
STATUS_NEGOTIATING = 5
|
||||
|
||||
STATUS_CHOICES = (
|
||||
(STATUS_PROCESSING, '处理中'),
|
||||
(STATUS_DONE, '已处理'),
|
||||
(STATUS_DISSATISFIED, '不满意'),
|
||||
(STATUS_CLOSED, '已关闭'),
|
||||
(STATUS_NEGOTIATING, '协商中'),
|
||||
)
|
||||
|
||||
STATUS_LABELS = dict(STATUS_CHOICES)
|
||||
|
||||
# 未结清(拦提现):处理中 / 不满意 / 协商中
|
||||
OPEN_STATUSES = (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING)
|
||||
|
||||
TARGET_ROLE_DASHOU = 'dashou'
|
||||
TARGET_ROLE_GUANSHI = 'guanshi'
|
||||
TARGET_ROLE_ZUZHANG = 'zuzhang'
|
||||
TARGET_ROLE_PLATFORM = 'platform'
|
||||
TARGET_ROLE_LABELS = {
|
||||
TARGET_ROLE_DASHOU: '接单员',
|
||||
TARGET_ROLE_GUANSHI: '管事',
|
||||
TARGET_ROLE_ZUZHANG: '组长',
|
||||
TARGET_ROLE_PLATFORM: '平台',
|
||||
}
|
||||
|
||||
MAX_IMAGES = 9
|
||||
MAX_SHUOMING_LEN = 500
|
||||
MAX_SHENGJI_TIMES = 5
|
||||
OSS_PREFIX = 'gongdan'
|
||||
|
||||
# 权限码
|
||||
PERM_VIEW = 'gdck666' # 查看工单
|
||||
PERM_HANDLE = 'gdcl666' # 处理工单
|
||||
PERM_VIEW = 'gdck666'
|
||||
PERM_HANDLE = 'gdcl666'
|
||||
|
||||
0
gongdan/management/__init__.py
Normal file
0
gongdan/management/__init__.py
Normal file
0
gongdan/management/commands/__init__.py
Normal file
0
gongdan/management/commands/__init__.py
Normal file
24
gongdan/management/commands/seed_gongdan_withdraw_block.py
Normal file
24
gongdan/management/commands/seed_gongdan_withdraw_block.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""种子:俱乐部工单提现拦截开关。默认仅 lxs 打开。"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from gongdan.services import ensure_club_gongdan_config
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '初始化俱乐部工单配置(complaint_withdraw_block)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--club-id', default='lxs', help='俱乐部 ID,默认 lxs')
|
||||
parser.add_argument('--enable', action='store_true', default=True, help='打开提现拦截')
|
||||
parser.add_argument('--disable', action='store_true', help='关闭提现拦截')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
club_id = (options.get('club_id') or 'lxs').strip()
|
||||
enable = not options.get('disable')
|
||||
row = ensure_club_gongdan_config(club_id, withdraw_block=enable)
|
||||
if row.complaint_withdraw_block != enable:
|
||||
row.complaint_withdraw_block = enable
|
||||
row.save(update_fields=['complaint_withdraw_block', 'UpdateTime'])
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'club={club_id} complaint_withdraw_block={row.complaint_withdraw_block}'
|
||||
))
|
||||
81
gongdan/migrations/0002_gongdan_target_intervene.py
Normal file
81
gongdan/migrations/0002_gongdan_target_intervene.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# 工单:被投诉对象 / 平台介入 / 协商中状态 / 俱乐部提现拦截开关
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gongdan', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='target_role',
|
||||
field=models.CharField(blank=True, db_index=True, default='', max_length=16, verbose_name='被投诉角色'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='target_user_id',
|
||||
field=models.CharField(blank=True, db_index=True, default='', max_length=32, verbose_name='被投诉人UID'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='platform_intervened',
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name='平台已介入'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='intervene_at',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='介入时间'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='intervene_by',
|
||||
field=models.CharField(blank=True, default='', max_length=32, verbose_name='介入操作人UID(仅存库)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gongdan',
|
||||
name='intervene_reason',
|
||||
field=models.CharField(blank=True, default='', max_length=32, verbose_name='介入原因码'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gongdan',
|
||||
name='leixing',
|
||||
field=models.IntegerField(
|
||||
choices=[
|
||||
(1, '订单投诉'), (2, '充值投诉'), (3, '罚款投诉'),
|
||||
(4, '管事投诉'), (5, '被骗投诉'), (6, '组长投诉'),
|
||||
],
|
||||
db_index=True,
|
||||
verbose_name='投诉类型',
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gongdan',
|
||||
name='zhuangtai',
|
||||
field=models.IntegerField(
|
||||
choices=[
|
||||
(1, '处理中'), (2, '已处理'), (3, '不满意'),
|
||||
(4, '已关闭'), (5, '协商中'),
|
||||
],
|
||||
db_index=True,
|
||||
default=1,
|
||||
verbose_name='状态',
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubGongdanConfig',
|
||||
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, unique=True, verbose_name='俱乐部')),
|
||||
('complaint_withdraw_block', models.BooleanField(default=False, verbose_name='投诉拦截提现')),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '俱乐部工单配置',
|
||||
'db_table': 'club_gongdan_config',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""投诉工单模型:主单 + 证据图 + 升级补充说明。"""
|
||||
"""投诉工单模型:主单 + 证据图 + 升级补充说明 + 俱乐部开关。"""
|
||||
from django.db import models
|
||||
from gvsdsdk.model_base import QModel
|
||||
|
||||
@@ -9,11 +9,14 @@ class Gongdan(QModel):
|
||||
"""投诉工单主表。"""
|
||||
gongdan_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='工单号')
|
||||
leixing = models.IntegerField(db_index=True, choices=TYPE_CHOICES, verbose_name='投诉类型')
|
||||
# 按类型选填关联业务 ID(字符串统一存,便于展示与复制)
|
||||
order_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='订单ID')
|
||||
chongzhi_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='充值单ID')
|
||||
fadan_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='罚单ID')
|
||||
guanshi_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='被投诉管事UID')
|
||||
guanshi_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='被投诉管事UID(兼容)')
|
||||
|
||||
# 通用被投诉对象(存库;接口对普通端脱敏不展示 UID)
|
||||
target_role = models.CharField(max_length=16, blank=True, default='', db_index=True, verbose_name='被投诉角色')
|
||||
target_user_id = models.CharField(max_length=32, blank=True, default='', db_index=True, verbose_name='被投诉人UID')
|
||||
|
||||
shuoming = models.CharField(max_length=500, verbose_name='投诉说明')
|
||||
shenqingren_id = models.CharField(max_length=32, db_index=True, verbose_name='申请人UserUID')
|
||||
@@ -27,6 +30,12 @@ class Gongdan(QModel):
|
||||
chuliren_name = models.CharField(max_length=64, null=True, blank=True, verbose_name='处理人展示名')
|
||||
chuli_time = models.DateTimeField(null=True, blank=True, verbose_name='处理时间')
|
||||
|
||||
# 平台介入(对外只展示「平台已介入」,不露客服 UID)
|
||||
platform_intervened = models.BooleanField(default=False, db_index=True, verbose_name='平台已介入')
|
||||
intervene_at = models.DateTimeField(null=True, blank=True, verbose_name='介入时间')
|
||||
intervene_by = models.CharField(max_length=32, blank=True, default='', verbose_name='介入操作人UID(仅存库)')
|
||||
intervene_reason = models.CharField(max_length=32, blank=True, default='', verbose_name='介入原因码')
|
||||
|
||||
shengji_cishu = models.IntegerField(default=0, verbose_name='升级次数')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
@@ -38,11 +47,10 @@ class Gongdan(QModel):
|
||||
|
||||
|
||||
class GongdanImage(QModel):
|
||||
"""工单证据图(最多9张,以主单当前版本为准;升级时可整体替换)。"""
|
||||
"""工单证据图(最多9张)。"""
|
||||
gongdan = models.ForeignKey(
|
||||
Gongdan, on_delete=models.CASCADE, related_name='images', db_column='gongdan_pk',
|
||||
)
|
||||
# 冗余业务工单号(勿命名 gongdan_id,会与 FK 访问器冲突)
|
||||
gongdan_hao = models.CharField(max_length=32, db_index=True, verbose_name='工单号冗余')
|
||||
url = models.CharField(max_length=512, verbose_name='图片URL')
|
||||
paixu = models.IntegerField(default=0, verbose_name='排序')
|
||||
@@ -55,7 +63,7 @@ class GongdanImage(QModel):
|
||||
|
||||
|
||||
class GongdanShengji(QModel):
|
||||
"""升级/继续投诉补充说明(只记文字与次数;图片改主表 images)。"""
|
||||
"""升级/继续投诉补充说明。"""
|
||||
gongdan = models.ForeignKey(
|
||||
Gongdan, on_delete=models.CASCADE, related_name='shengji_list', db_column='gongdan_pk',
|
||||
)
|
||||
@@ -70,3 +78,16 @@ class GongdanShengji(QModel):
|
||||
verbose_name = '工单升级记录'
|
||||
ordering = ['cishu', 'id']
|
||||
unique_together = [['gongdan', 'cishu']]
|
||||
|
||||
|
||||
class ClubGongdanConfig(QModel):
|
||||
"""俱乐部工单相关开关(默认全关,仅种子打开的俱乐部生效)。"""
|
||||
club_id = models.CharField(max_length=16, unique=True, db_index=True, verbose_name='俱乐部')
|
||||
# 未结清被投诉/牵连时禁止提现
|
||||
complaint_withdraw_block = models.BooleanField(default=False, verbose_name='投诉拦截提现')
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_gongdan_config'
|
||||
verbose_name = '俱乐部工单配置'
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"""工单业务:创建 / 升级 / 客服处理(幂等与校验)。"""
|
||||
"""工单业务:创建 / 升级 / 客服处理 / 提现拦截 / 列表范围。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from gongdan.constants import (
|
||||
TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD,
|
||||
TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD, TYPE_ZUZHANG,
|
||||
TYPE_LABELS, STATUS_PROCESSING, STATUS_DONE, STATUS_DISSATISFIED, STATUS_CLOSED,
|
||||
STATUS_LABELS, MAX_IMAGES, MAX_SHUOMING_LEN, MAX_SHENGJI_TIMES, OSS_PREFIX,
|
||||
STATUS_NEGOTIATING, STATUS_LABELS, OPEN_STATUSES, MAX_IMAGES, MAX_SHUOMING_LEN,
|
||||
MAX_SHENGJI_TIMES, TARGET_ROLE_GUANSHI, TARGET_ROLE_ZUZHANG, TARGET_ROLE_LABELS,
|
||||
TARGET_ROLE_DASHOU, TARGET_ROLE_PLATFORM,
|
||||
)
|
||||
from gongdan.models import Gongdan, GongdanImage, GongdanShengji
|
||||
from gongdan.models import Gongdan, GongdanImage, GongdanShengji, ClubGongdanConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,8 +38,107 @@ def _trim(text, max_len=MAX_SHUOMING_LEN):
|
||||
return (text or '').strip()[:max_len]
|
||||
|
||||
|
||||
def is_complaint_withdraw_block_enabled(club_id: str) -> bool:
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return False
|
||||
row = ClubGongdanConfig.query.filter(club_id=cid).first()
|
||||
return bool(row and row.complaint_withdraw_block)
|
||||
|
||||
|
||||
def ensure_club_gongdan_config(club_id: str, *, withdraw_block: bool = False) -> ClubGongdanConfig:
|
||||
cid = (club_id or '').strip()
|
||||
row = ClubGongdanConfig.query.filter(club_id=cid).first()
|
||||
if not row:
|
||||
row = ClubGongdanConfig(club_id=cid, complaint_withdraw_block=bool(withdraw_block))
|
||||
row.save()
|
||||
return row
|
||||
|
||||
|
||||
def get_club_gongdan_config(club_id: str) -> dict:
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
return {'club_id': '', 'complaint_withdraw_block': False}
|
||||
row = ClubGongdanConfig.query.filter(club_id=cid).first()
|
||||
return {
|
||||
'club_id': cid,
|
||||
'complaint_withdraw_block': bool(row and row.complaint_withdraw_block),
|
||||
}
|
||||
|
||||
|
||||
def set_complaint_withdraw_block(club_id: str, enabled: bool) -> dict:
|
||||
cid = (club_id or '').strip()
|
||||
if not cid:
|
||||
raise GongdanError('俱乐部不能为空', 'need_club')
|
||||
row = ensure_club_gongdan_config(cid, withdraw_block=bool(enabled))
|
||||
enabled = bool(enabled)
|
||||
if row.complaint_withdraw_block != enabled:
|
||||
row.complaint_withdraw_block = enabled
|
||||
row.save(update_fields=['complaint_withdraw_block', 'UpdateTime'])
|
||||
return get_club_gongdan_config(cid)
|
||||
|
||||
|
||||
def _user_nicheng(uid: str) -> str:
|
||||
"""展示昵称:打手 nicheng → 老板 nickname → 用户{UID}。不把 UID 当昵称展示。"""
|
||||
uid = (uid or '').strip()
|
||||
if not uid:
|
||||
return ''
|
||||
try:
|
||||
from users.models import UserDashou, UserBoss
|
||||
from users.business_models import User
|
||||
|
||||
ds = UserDashou.query.filter(user__UserUID=uid).select_related('user').first()
|
||||
if ds and (ds.nicheng or '').strip():
|
||||
return (ds.nicheng or '').strip()[:32]
|
||||
boss = UserBoss.query.filter(user__UserUID=uid).select_related('user').first()
|
||||
if boss and (boss.nickname or '').strip():
|
||||
return (boss.nickname or '').strip()[:32]
|
||||
u = User.query.filter(UserUID=uid).first() or User.objects.filter(UserUID=uid).first()
|
||||
if u and (getattr(u, 'Phone', None) or '').strip():
|
||||
phone = str(u.Phone).strip()
|
||||
if len(phone) >= 4:
|
||||
return f'用户*{phone[-4:]}'
|
||||
return '用户'
|
||||
except Exception:
|
||||
return '用户'
|
||||
|
||||
|
||||
def _resolve_target(leixing, guanshi_id=None, target_role=None, target_user_id=None):
|
||||
"""归一化被投诉对象;兼容旧 guanshi_id。管事/组长投诉必须选人。"""
|
||||
role = (target_role or '').strip().lower()
|
||||
tid = (target_user_id or '').strip()
|
||||
gid = (guanshi_id or '').strip() or None
|
||||
|
||||
if leixing == TYPE_GUANSHI:
|
||||
role = TARGET_ROLE_GUANSHI
|
||||
tid = tid or (gid or '')
|
||||
if not tid:
|
||||
raise GongdanError('请选择被投诉的管事', 'need_guanshi')
|
||||
from users.models import UserGuanshi
|
||||
if not UserGuanshi.query.filter(user__UserUID=tid).exists():
|
||||
if not UserGuanshi.objects.filter(user__UserUID=tid).exists():
|
||||
raise GongdanError('管事不存在,请重新选择', 'guanshi_not_found')
|
||||
return role, tid, tid
|
||||
|
||||
if leixing == TYPE_ZUZHANG:
|
||||
role = TARGET_ROLE_ZUZHANG
|
||||
if not tid:
|
||||
raise GongdanError('请选择被投诉的组长', 'need_zuzhang')
|
||||
from users.models import UserZuzhang
|
||||
if not UserZuzhang.query.filter(user__UserUID=tid).exists():
|
||||
if not UserZuzhang.objects.filter(user__UserUID=tid).exists():
|
||||
raise GongdanError('组长不存在,请重新选择', 'zuzhang_not_found')
|
||||
return role, tid, None
|
||||
|
||||
if role and tid:
|
||||
if role not in TARGET_ROLE_LABELS and role not in ('order', 'recharge', 'penalty'):
|
||||
raise GongdanError('无效的被投诉角色', 'bad_target_role')
|
||||
return role, tid, gid if role == TARGET_ROLE_GUANSHI else None
|
||||
|
||||
return role or '', tid or '', gid
|
||||
|
||||
|
||||
def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None, guanshi_id=None):
|
||||
"""按类型校验必填关联 ID,并尽量核对库内是否存在。"""
|
||||
order_id = (order_id or '').strip() or None
|
||||
chongzhi_id = (chongzhi_id or '').strip() or None
|
||||
fadan_id = (fadan_id or '').strip() or None
|
||||
@@ -72,20 +173,46 @@ def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None,
|
||||
return None, None, str(fid), None
|
||||
|
||||
if leixing == TYPE_GUANSHI:
|
||||
# 管事UID 选填,有则校验存在
|
||||
if guanshi_id:
|
||||
from users.models import UserGuanshi
|
||||
if not UserGuanshi.objects.filter(user__UserUID=guanshi_id).exists():
|
||||
raise GongdanError('管事不存在,请核对管事ID', 'guanshi_not_found')
|
||||
return None, None, None, guanshi_id
|
||||
|
||||
if leixing == TYPE_FRAUD:
|
||||
if leixing in (TYPE_FRAUD, TYPE_ZUZHANG):
|
||||
return None, None, None, None
|
||||
|
||||
raise GongdanError('不支持的投诉类型', 'bad_type')
|
||||
|
||||
|
||||
def serialize_gongdan(gd, include_images=True, include_shengji=False):
|
||||
def _effective_target_uid(gd) -> str:
|
||||
return (gd.target_user_id or gd.guanshi_id or '').strip()
|
||||
|
||||
|
||||
def _effective_target_role(gd) -> str:
|
||||
role = (gd.target_role or '').strip()
|
||||
if role:
|
||||
return role
|
||||
if gd.leixing == TYPE_GUANSHI or gd.guanshi_id:
|
||||
return TARGET_ROLE_GUANSHI
|
||||
if gd.leixing == TYPE_ZUZHANG:
|
||||
return TARGET_ROLE_ZUZHANG
|
||||
return ''
|
||||
|
||||
|
||||
def serialize_gongdan(gd, include_images=True, include_shengji=False, *, viewer_uid=None, for_admin=False):
|
||||
"""
|
||||
序列化。默认脱敏:不把申请人/被投诉人/客服 UID 作为展示字段下发。
|
||||
for_admin=True 时仍不主动展示 UID 列,只多给内部审计需要的标记。
|
||||
contact_peer_uid 仅当 viewer 是当事方时返回,供 IM 打开(前端勿渲染)。
|
||||
"""
|
||||
t_role = _effective_target_role(gd)
|
||||
t_uid = _effective_target_uid(gd)
|
||||
relation = ''
|
||||
if viewer_uid:
|
||||
if gd.shenqingren_id == viewer_uid:
|
||||
relation = 'created'
|
||||
elif t_uid and t_uid == viewer_uid:
|
||||
relation = 'received'
|
||||
else:
|
||||
relation = 'linked' if _viewer_is_linked_zuzhang(viewer_uid, gd) else ''
|
||||
|
||||
data = {
|
||||
'gongdan_id': gd.gongdan_id,
|
||||
'leixing': gd.leixing,
|
||||
@@ -93,20 +220,58 @@ def serialize_gongdan(gd, include_images=True, include_shengji=False):
|
||||
'order_id': gd.order_id or '',
|
||||
'chongzhi_id': gd.chongzhi_id or '',
|
||||
'fadan_id': gd.fadan_id or '',
|
||||
'guanshi_id': gd.guanshi_id or '',
|
||||
'shuoming': gd.shuoming or '',
|
||||
'shenqingren_id': gd.shenqingren_id,
|
||||
'club_id': gd.club_id or '',
|
||||
'zhuangtai': gd.zhuangtai,
|
||||
'zhuangtai_label': STATUS_LABELS.get(gd.zhuangtai, str(gd.zhuangtai)),
|
||||
'chuli_jieguo': gd.chuli_jieguo or '',
|
||||
'chuliren_id': gd.chuliren_id or '',
|
||||
'chuliren_name': gd.chuliren_name or '',
|
||||
# 对外:只显示「平台已介入」,不返回客服 UID
|
||||
'platform_intervened': bool(gd.platform_intervened),
|
||||
'intervene_at': gd.intervene_at.isoformat() if gd.intervene_at else '',
|
||||
'intervene_reason': gd.intervene_reason or '',
|
||||
'chuli_time': gd.chuli_time.isoformat() if gd.chuli_time else '',
|
||||
'shengji_cishu': gd.shengji_cishu or 0,
|
||||
'CreateTime': gd.CreateTime.isoformat() if gd.CreateTime else '',
|
||||
'UpdateTime': gd.UpdateTime.isoformat() if gd.UpdateTime else '',
|
||||
'target_role': t_role,
|
||||
'target_role_label': TARGET_ROLE_LABELS.get(t_role, t_role or ''),
|
||||
'target_nicheng': _user_nicheng(t_uid),
|
||||
'complainant_nicheng': _user_nicheng(gd.shenqingren_id),
|
||||
'relation': relation,
|
||||
'is_open': gd.zhuangtai in OPEN_STATUSES,
|
||||
'can_contact': False,
|
||||
'contact_peer_uid': '',
|
||||
'contact_peer_nicheng': '',
|
||||
'can_contact_complainant': False,
|
||||
'contact_complainant_uid': '',
|
||||
'contact_complainant_nicheng': '',
|
||||
}
|
||||
if viewer_uid and relation in ('created', 'received'):
|
||||
peer = t_uid if relation == 'created' else gd.shenqingren_id
|
||||
if peer and peer != viewer_uid:
|
||||
data['can_contact'] = True
|
||||
data['contact_peer_uid'] = peer
|
||||
data['contact_peer_nicheng'] = _user_nicheng(peer)
|
||||
elif viewer_uid and relation == 'linked':
|
||||
# 牵连组长:默认可联系主被投诉管事;另可联系投诉人
|
||||
if t_uid and t_uid != viewer_uid:
|
||||
data['can_contact'] = True
|
||||
data['contact_peer_uid'] = t_uid
|
||||
data['contact_peer_nicheng'] = _user_nicheng(t_uid)
|
||||
if gd.shenqingren_id and gd.shenqingren_id != viewer_uid:
|
||||
data['can_contact_complainant'] = True
|
||||
data['contact_complainant_uid'] = gd.shenqingren_id
|
||||
data['contact_complainant_nicheng'] = _user_nicheng(gd.shenqingren_id)
|
||||
if for_admin:
|
||||
# 客服需要 UID 做内部处理;用户端绝不下发
|
||||
data['shenqingren_id'] = gd.shenqingren_id or ''
|
||||
data['target_user_id'] = t_uid or ''
|
||||
data['guanshi_id'] = gd.guanshi_id or ''
|
||||
data['chuliren_id'] = gd.chuliren_id or ''
|
||||
data['chuliren_name'] = gd.chuliren_name or ('平台客服' if gd.platform_intervened or gd.chuliren_id else '')
|
||||
else:
|
||||
data['chuliren_name'] = '平台客服' if (gd.platform_intervened or gd.chuliren_name) else ''
|
||||
|
||||
if include_images:
|
||||
data['images'] = [
|
||||
{'url': img.url, 'paixu': img.paixu}
|
||||
@@ -124,6 +289,209 @@ def serialize_gongdan(gd, include_images=True, include_shengji=False):
|
||||
return data
|
||||
|
||||
|
||||
def _viewer_is_linked_zuzhang(viewer_uid: str, gd) -> bool:
|
||||
"""下级管事被投诉时,其上级组长视为牵连方。"""
|
||||
t_uid = _effective_target_uid(gd)
|
||||
t_role = _effective_target_role(gd)
|
||||
if not t_uid or t_role != TARGET_ROLE_GUANSHI:
|
||||
return False
|
||||
try:
|
||||
from users.models import UserGuanshi
|
||||
gs = UserGuanshi.objects.filter(user__UserUID=t_uid).first()
|
||||
if not gs:
|
||||
return False
|
||||
return (gs.yaoqingren or '').strip() == str(viewer_uid)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _sub_guanshi_uids(zuzhang_uid: str):
|
||||
from users.models import UserGuanshi
|
||||
rows = UserGuanshi.objects.filter(yaoqingren=str(zuzhang_uid)).select_related('user')
|
||||
out = []
|
||||
for r in rows:
|
||||
uid = getattr(getattr(r, 'user', None), 'UserUID', None)
|
||||
if uid:
|
||||
out.append(str(uid))
|
||||
return out
|
||||
|
||||
|
||||
def open_complaint_q_for_target_uids(uids):
|
||||
"""未结清且指向这些 UID 的工单条件。"""
|
||||
uids = [str(u) for u in (uids or []) if u]
|
||||
if not uids:
|
||||
return Q(pk__in=[])
|
||||
return (
|
||||
Q(zhuangtai__in=OPEN_STATUSES)
|
||||
& (Q(target_user_id__in=uids) | Q(guanshi_id__in=uids))
|
||||
)
|
||||
|
||||
|
||||
def check_complaint_blocks_withdraw(user_main, club_id=None):
|
||||
"""
|
||||
提现拦截。
|
||||
返回 (ok, msg, extra)
|
||||
extra: reason=received|linked, tab, count
|
||||
"""
|
||||
try:
|
||||
from jituan.services.club_user import get_user_club_id
|
||||
cid = (club_id or get_user_club_id(user_main) or '').strip()
|
||||
except Exception:
|
||||
cid = (club_id or '').strip()
|
||||
|
||||
if not is_complaint_withdraw_block_enabled(cid):
|
||||
return True, '', {}
|
||||
|
||||
uid = str(user_main.UserUID)
|
||||
base = Gongdan.query.filter(club_id=cid) if cid else Gongdan.query.all()
|
||||
|
||||
# 1) 自己是主被投诉人
|
||||
qs_recv = base.filter(open_complaint_q_for_target_uids([uid]))
|
||||
cnt = qs_recv.count()
|
||||
if cnt:
|
||||
return False, '你有未处理完的投诉,请处理后再提现', {
|
||||
'reason': 'received',
|
||||
'tab': 'received',
|
||||
'count': cnt,
|
||||
'code': 'COMPLAINT_BLOCK_WITHDRAW',
|
||||
}
|
||||
|
||||
# 2) 自己是组长,下级管事被投诉 → 牵连
|
||||
try:
|
||||
from users.models import UserZuzhang
|
||||
is_zz = UserZuzhang.query.filter(user=user_main).exists()
|
||||
except Exception:
|
||||
is_zz = False
|
||||
if is_zz:
|
||||
subs = _sub_guanshi_uids(uid)
|
||||
if subs:
|
||||
qs_link = base.filter(open_complaint_q_for_target_uids(subs)).filter(
|
||||
Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI)
|
||||
)
|
||||
cnt2 = qs_link.count()
|
||||
if cnt2:
|
||||
return False, '下级管事有未处理投诉,暂不可提现', {
|
||||
'reason': 'linked',
|
||||
'tab': 'linked',
|
||||
'count': cnt2,
|
||||
'code': 'COMPLAINT_BLOCK_WITHDRAW',
|
||||
}
|
||||
|
||||
return True, '', {}
|
||||
|
||||
|
||||
def search_complaint_targets(*, role, keyword='', club_id=None, page=1, page_size=20):
|
||||
"""按昵称/UID 搜管事或组长,供投诉选人(返回 uid 仅供提交,前端展示昵称)。"""
|
||||
role = (role or '').strip().lower()
|
||||
keyword = (keyword or '').strip()
|
||||
page = max(int(page or 1), 1)
|
||||
page_size = min(max(int(page_size or 20), 1), 50)
|
||||
cid = (club_id or '').strip()
|
||||
|
||||
from users.models import UserGuanshi, UserZuzhang, UserBoss
|
||||
|
||||
rows = []
|
||||
if role == TARGET_ROLE_GUANSHI:
|
||||
qs = UserGuanshi.query.filter(zhuangtai=1).select_related('user')
|
||||
if cid:
|
||||
qs = qs.filter(user__ClubID=cid)
|
||||
if keyword:
|
||||
boss_uids = list(
|
||||
UserBoss.query.filter(nickname__icontains=keyword).values_list('user__UserUID', flat=True)[:80]
|
||||
)
|
||||
qs = qs.filter(Q(user__UserUID__icontains=keyword) | Q(user__UserUID__in=boss_uids))
|
||||
qs = qs.order_by('-id')
|
||||
total = qs.count()
|
||||
start = (page - 1) * page_size
|
||||
for g in qs[start:start + page_size]:
|
||||
uid = getattr(getattr(g, 'user', None), 'UserUID', None)
|
||||
if not uid:
|
||||
continue
|
||||
rows.append({
|
||||
'user_id': str(uid),
|
||||
'nicheng': _user_nicheng(str(uid)),
|
||||
'role': TARGET_ROLE_GUANSHI,
|
||||
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_GUANSHI],
|
||||
})
|
||||
elif role == TARGET_ROLE_ZUZHANG:
|
||||
qs = UserZuzhang.query.filter(zhuangtai=1).select_related('user')
|
||||
if cid:
|
||||
qs = qs.filter(user__ClubID=cid)
|
||||
if keyword:
|
||||
boss_uids = list(
|
||||
UserBoss.query.filter(nickname__icontains=keyword).values_list('user__UserUID', flat=True)[:80]
|
||||
)
|
||||
qs = qs.filter(Q(user__UserUID__icontains=keyword) | Q(user__UserUID__in=boss_uids))
|
||||
qs = qs.order_by('-id')
|
||||
total = qs.count()
|
||||
start = (page - 1) * page_size
|
||||
for z in qs[start:start + page_size]:
|
||||
uid = getattr(getattr(z, 'user', None), 'UserUID', None)
|
||||
if not uid:
|
||||
continue
|
||||
rows.append({
|
||||
'user_id': str(uid),
|
||||
'nicheng': _user_nicheng(str(uid)),
|
||||
'role': TARGET_ROLE_ZUZHANG,
|
||||
'role_label': TARGET_ROLE_LABELS[TARGET_ROLE_ZUZHANG],
|
||||
})
|
||||
else:
|
||||
raise GongdanError('role 须为 guanshi 或 zuzhang', 'bad_role')
|
||||
|
||||
return {'list': rows, 'total': total, 'page': page, 'page_size': page_size, 'role': role}
|
||||
|
||||
|
||||
def list_gongdan_for_user(yonghuid, *, scope='created', club_id=None, page=1, page_size=20):
|
||||
"""
|
||||
scope:
|
||||
created — 我发起的
|
||||
received — 我被投诉的
|
||||
linked — 下级牵连(组长)
|
||||
"""
|
||||
scope = (scope or 'created').strip().lower()
|
||||
page = max(int(page or 1), 1)
|
||||
page_size = min(max(int(page_size or 20), 1), 50)
|
||||
uid = str(yonghuid)
|
||||
qs = Gongdan.query.all()
|
||||
if club_id:
|
||||
qs = qs.filter(club_id=club_id)
|
||||
|
||||
if scope == 'received':
|
||||
qs = qs.filter(Q(target_user_id=uid) | Q(guanshi_id=uid)).order_by('-CreateTime')
|
||||
elif scope == 'linked':
|
||||
subs = _sub_guanshi_uids(uid)
|
||||
if not subs:
|
||||
qs = qs.none()
|
||||
else:
|
||||
qs = qs.filter(Q(target_user_id__in=subs) | Q(guanshi_id__in=subs)).filter(
|
||||
Q(target_role=TARGET_ROLE_GUANSHI) | Q(target_role='') | Q(leixing=TYPE_GUANSHI)
|
||||
).order_by('-CreateTime')
|
||||
else:
|
||||
qs = qs.filter(shenqingren_id=uid).order_by('-CreateTime')
|
||||
|
||||
total = qs.count()
|
||||
start = (page - 1) * page_size
|
||||
rows = list(qs[start:start + page_size])
|
||||
return {
|
||||
'list': [serialize_gongdan(g, include_images=False, viewer_uid=uid) for g in rows],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'scope': scope,
|
||||
}
|
||||
|
||||
|
||||
def user_can_view_gongdan(gd, yonghuid) -> bool:
|
||||
uid = str(yonghuid)
|
||||
if gd.shenqingren_id == uid:
|
||||
return True
|
||||
if _effective_target_uid(gd) == uid:
|
||||
return True
|
||||
if _viewer_is_linked_zuzhang(uid, gd):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _replace_images(gd, image_urls):
|
||||
urls = [u.strip() for u in (image_urls or []) if u and str(u).strip()]
|
||||
if len(urls) > MAX_IMAGES:
|
||||
@@ -149,6 +517,8 @@ def create_gongdan(
|
||||
chongzhi_id=None,
|
||||
fadan_id=None,
|
||||
guanshi_id=None,
|
||||
target_role=None,
|
||||
target_user_id=None,
|
||||
image_urls=None,
|
||||
):
|
||||
try:
|
||||
@@ -163,7 +533,10 @@ def create_gongdan(
|
||||
raise GongdanError('请填写投诉说明', 'need_shuoming')
|
||||
|
||||
order_id, chongzhi_id, fadan_id, guanshi_id = validate_type_refs(
|
||||
leixing, order_id, chongzhi_id, fadan_id, guanshi_id,
|
||||
leixing, order_id, chongzhi_id, fadan_id, guanshi_id or target_user_id,
|
||||
)
|
||||
t_role, t_uid, guanshi_id = _resolve_target(
|
||||
leixing, guanshi_id=guanshi_id, target_role=target_role, target_user_id=target_user_id or guanshi_id,
|
||||
)
|
||||
|
||||
gid = _gen_gongdan_id()
|
||||
@@ -179,6 +552,8 @@ def create_gongdan(
|
||||
chongzhi_id=chongzhi_id,
|
||||
fadan_id=fadan_id,
|
||||
guanshi_id=guanshi_id,
|
||||
target_role=t_role or '',
|
||||
target_user_id=t_uid or '',
|
||||
shuoming=text,
|
||||
shenqingren_id=yonghuid,
|
||||
club_id=club_id or None,
|
||||
@@ -186,13 +561,12 @@ def create_gongdan(
|
||||
shengji_cishu=0,
|
||||
)
|
||||
_replace_images(gd, image_urls)
|
||||
logger.info('创建工单 %s type=%s user=%s', gid, leixing, yonghuid)
|
||||
logger.info('创建工单 %s type=%s user=%s target=%s/%s', gid, leixing, yonghuid, t_role, t_uid)
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None):
|
||||
"""不满意后继续投诉:写升级记录;可选整体替换证据图。"""
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
raise GongdanError('工单不存在', 'not_found')
|
||||
@@ -219,13 +593,11 @@ def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None):
|
||||
gd.zhuangtai = STATUS_PROCESSING
|
||||
gd.chuli_jieguo = ''
|
||||
gd.chuli_time = None
|
||||
# 保留原处理人记录便于审计,但状态回到处理中
|
||||
gd.save(update_fields=[
|
||||
'shengji_cishu', 'zhuangtai', 'chuli_jieguo', 'chuli_time', 'UpdateTime',
|
||||
])
|
||||
if image_urls is not None:
|
||||
_replace_images(gd, image_urls)
|
||||
logger.info('工单升级 %s cishu=%s', gongdan_id, next_cishu)
|
||||
return gd
|
||||
|
||||
|
||||
@@ -237,13 +609,16 @@ def handle_gongdan(
|
||||
handler_name,
|
||||
action,
|
||||
chuli_jieguo='',
|
||||
intervene_reason='',
|
||||
):
|
||||
"""
|
||||
客服处理:
|
||||
action=done → 已处理
|
||||
action=dissatisfied → 标记不满意(一般由用户侧触发,客服 sparingly)
|
||||
action=close → 关闭
|
||||
action=processing → 重新标处理中
|
||||
action:
|
||||
done/finish/resolve → 已处理
|
||||
close → 关闭
|
||||
processing/reopen → 处理中
|
||||
dissatisfied → 不满意
|
||||
intervene → 标记平台介入(不改结案状态)
|
||||
negotiate → 协商中(客服 sparingly)
|
||||
"""
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
@@ -252,22 +627,40 @@ def handle_gongdan(
|
||||
action = (action or '').strip().lower()
|
||||
jieguo = _trim(chuli_jieguo)
|
||||
|
||||
if action in ('intervene', 'platform_intervene'):
|
||||
gd.platform_intervened = True
|
||||
gd.intervene_at = timezone.now()
|
||||
gd.intervene_by = (handler_uid or '')[:32]
|
||||
gd.intervene_reason = (intervene_reason or 'kefu')[:32]
|
||||
if gd.zhuangtai not in OPEN_STATUSES:
|
||||
gd.zhuangtai = STATUS_PROCESSING
|
||||
gd.save()
|
||||
logger.info('工单平台介入 %s by=%s', gongdan_id, handler_uid)
|
||||
return gd
|
||||
|
||||
if action in ('done', 'finish', 'resolve'):
|
||||
if not jieguo:
|
||||
raise GongdanError('请填写处理结果', 'need_jieguo')
|
||||
gd.zhuangtai = STATUS_DONE
|
||||
gd.chuli_jieguo = jieguo
|
||||
gd.chuliren_id = handler_uid
|
||||
gd.chuliren_name = (handler_name or '')[:64]
|
||||
gd.chuliren_name = (handler_name or '平台客服')[:64]
|
||||
gd.chuli_time = timezone.now()
|
||||
if not gd.platform_intervened:
|
||||
gd.platform_intervened = True
|
||||
gd.intervene_at = gd.intervene_at or timezone.now()
|
||||
gd.intervene_by = (handler_uid or '')[:32]
|
||||
gd.intervene_reason = gd.intervene_reason or 'kefu_done'
|
||||
elif action in ('close',):
|
||||
gd.zhuangtai = STATUS_CLOSED
|
||||
gd.chuli_jieguo = jieguo or gd.chuli_jieguo or '已关闭'
|
||||
gd.chuliren_id = handler_uid
|
||||
gd.chuliren_name = (handler_name or '')[:64]
|
||||
gd.chuliren_name = (handler_name or '平台客服')[:64]
|
||||
gd.chuli_time = timezone.now()
|
||||
elif action in ('processing', 'reopen'):
|
||||
gd.zhuangtai = STATUS_PROCESSING
|
||||
elif action in ('negotiate',):
|
||||
gd.zhuangtai = STATUS_NEGOTIATING
|
||||
elif action in ('dissatisfied',):
|
||||
gd.zhuangtai = STATUS_DISSATISFIED
|
||||
else:
|
||||
@@ -290,3 +683,55 @@ def user_mark_dissatisfied(*, gongdan_id, yonghuid):
|
||||
gd.zhuangtai = STATUS_DISSATISFIED
|
||||
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def user_resolve_gongdan(*, gongdan_id, yonghuid):
|
||||
"""投诉人确认已解决(私了结清)。"""
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
raise GongdanError('工单不存在', 'not_found')
|
||||
if gd.shenqingren_id != yonghuid:
|
||||
raise GongdanError('仅投诉人可确认解决', 'forbidden')
|
||||
if gd.zhuangtai not in OPEN_STATUSES:
|
||||
raise GongdanError('当前状态无需确认解决', 'bad_status')
|
||||
gd.zhuangtai = STATUS_DONE
|
||||
gd.chuli_jieguo = gd.chuli_jieguo or '双方协商已解决'
|
||||
gd.chuli_time = timezone.now()
|
||||
gd.chuliren_name = gd.chuliren_name or '双方协商'
|
||||
gd.save()
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def user_start_negotiate(*, gongdan_id, yonghuid):
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
raise GongdanError('工单不存在', 'not_found')
|
||||
if not user_can_view_gongdan(gd, yonghuid):
|
||||
raise GongdanError('无权操作此工单', 'forbidden')
|
||||
if gd.zhuangtai not in (STATUS_PROCESSING, STATUS_DISSATISFIED, STATUS_NEGOTIATING):
|
||||
raise GongdanError('当前状态不可转协商', 'bad_status')
|
||||
gd.zhuangtai = STATUS_NEGOTIATING
|
||||
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
|
||||
return gd
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def user_apply_intervene(*, gongdan_id, yonghuid):
|
||||
"""用户申请平台介入:打标,仍保持未结清。"""
|
||||
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||
if not gd:
|
||||
raise GongdanError('工单不存在', 'not_found')
|
||||
if gd.shenqingren_id != yonghuid and _effective_target_uid(gd) != yonghuid:
|
||||
raise GongdanError('无权申请介入', 'forbidden')
|
||||
if gd.zhuangtai not in OPEN_STATUSES:
|
||||
raise GongdanError('当前状态不可申请介入', 'bad_status')
|
||||
gd.platform_intervened = True
|
||||
if not gd.intervene_at:
|
||||
gd.intervene_at = timezone.now()
|
||||
gd.intervene_reason = gd.intervene_reason or 'user_apply'
|
||||
if gd.zhuangtai == STATUS_NEGOTIATING:
|
||||
gd.zhuangtai = STATUS_PROCESSING
|
||||
gd.save()
|
||||
return gd
|
||||
|
||||
@@ -7,10 +7,16 @@ from gongdan.views import (
|
||||
GongdanDetailView,
|
||||
GongdanEscalateView,
|
||||
GongdanDissatisfiedView,
|
||||
GongdanResolveView,
|
||||
GongdanNegotiateView,
|
||||
GongdanApplyInterveneView,
|
||||
GongdanWithdrawCheckView,
|
||||
GongdanSearchTargetView,
|
||||
GongdanMetaView,
|
||||
ChongzhiRecordListView,
|
||||
GongdanAdminListView,
|
||||
GongdanAdminHandleView,
|
||||
GongdanAdminClubConfigView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -20,8 +26,14 @@ urlpatterns = [
|
||||
path('detail/', GongdanDetailView.as_view(), name='工单详情'),
|
||||
path('escalate/', GongdanEscalateView.as_view(), name='继续投诉升级'),
|
||||
path('dissatisfied/', GongdanDissatisfiedView.as_view(), name='标记不满意'),
|
||||
path('resolve/', GongdanResolveView.as_view(), name='投诉人确认解决'),
|
||||
path('negotiate/', GongdanNegotiateView.as_view(), name='转协商中'),
|
||||
path('apply-intervene/', GongdanApplyInterveneView.as_view(), name='申请平台介入'),
|
||||
path('withdraw-block-check/', GongdanWithdrawCheckView.as_view(), name='提现投诉拦截检查'),
|
||||
path('search-target/', GongdanSearchTargetView.as_view(), name='搜索被投诉对象'),
|
||||
path('meta/', GongdanMetaView.as_view(), name='工单枚举'),
|
||||
path('chongzhi-list/', ChongzhiRecordListView.as_view(), name='我的充值记录'),
|
||||
path('admin/list/', GongdanAdminListView.as_view(), name='客服工单列表'),
|
||||
path('admin/handle/', GongdanAdminHandleView.as_view(), name='客服处理工单'),
|
||||
path('admin/club-config/', GongdanAdminClubConfigView.as_view(), name='工单俱乐部开关'),
|
||||
]
|
||||
|
||||
263
gongdan/views.py
263
gongdan/views.py
@@ -20,6 +20,15 @@ from gongdan.services import (
|
||||
handle_gongdan,
|
||||
serialize_gongdan,
|
||||
user_mark_dissatisfied,
|
||||
list_gongdan_for_user,
|
||||
user_can_view_gongdan,
|
||||
user_resolve_gongdan,
|
||||
user_start_negotiate,
|
||||
user_apply_intervene,
|
||||
check_complaint_blocks_withdraw,
|
||||
search_complaint_targets,
|
||||
get_club_gongdan_config,
|
||||
set_complaint_withdraw_block,
|
||||
)
|
||||
from utils.oss_utils import upload_to_oss, validate_image
|
||||
|
||||
@@ -93,9 +102,14 @@ class GongdanCreateView(APIView):
|
||||
chongzhi_id=d.get('chongzhi_id') or d.get('dingdanid') or d.get('dingdan_id'),
|
||||
fadan_id=d.get('fadan_id') or d.get('penalty_id'),
|
||||
guanshi_id=d.get('guanshi_id'),
|
||||
target_role=d.get('target_role'),
|
||||
target_user_id=d.get('target_user_id') or d.get('zuzhang_id') or d.get('guanshi_id'),
|
||||
image_urls=images,
|
||||
)
|
||||
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '提交成功')
|
||||
return _ok(
|
||||
serialize_gongdan(gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID),
|
||||
'提交成功',
|
||||
)
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
except Exception as e:
|
||||
@@ -114,18 +128,24 @@ class GongdanMyListView(APIView):
|
||||
return self._list(request)
|
||||
|
||||
def _list(self, request):
|
||||
scope = (
|
||||
request.data.get('scope')
|
||||
or request.data.get('tab')
|
||||
or request.query_params.get('scope')
|
||||
or request.query_params.get('tab')
|
||||
or 'created'
|
||||
)
|
||||
page = int(request.data.get('page') or request.query_params.get('page') or 1)
|
||||
page_size = min(int(request.data.get('page_size') or request.query_params.get('page_size') or 20), 50)
|
||||
qs = Gongdan.query.filter(shenqingren_id=request.user.UserUID).order_by('-CreateTime')
|
||||
total = qs.count()
|
||||
start = max(page - 1, 0) * page_size
|
||||
rows = qs[start:start + page_size]
|
||||
return _ok({
|
||||
'list': [serialize_gongdan(g, include_images=False) for g in rows],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
})
|
||||
club_id = _resolve_club(request)
|
||||
data = list_gongdan_for_user(
|
||||
request.user.UserUID,
|
||||
scope=scope,
|
||||
club_id=club_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return _ok(data)
|
||||
|
||||
|
||||
class GongdanDetailView(APIView):
|
||||
@@ -139,22 +159,26 @@ class GongdanDetailView(APIView):
|
||||
gd = Gongdan.query.filter(gongdan_id=gid).first()
|
||||
if not gd:
|
||||
return _err('工单不存在', http=404)
|
||||
# 申请人或有客服查看权限
|
||||
if gd.shenqingren_id != request.user.UserUID:
|
||||
from backend.utils import verify_kefu_permission
|
||||
from gongdan.constants import PERM_VIEW, PERM_HANDLE
|
||||
kefu, perms_or_resp = verify_kefu_permission(
|
||||
request, request.data.get('username') or getattr(request.user, 'Phone', None),
|
||||
)
|
||||
if not kefu:
|
||||
return perms_or_resp
|
||||
perms = perms_or_resp if isinstance(perms_or_resp, list) else []
|
||||
if PERM_VIEW not in perms and PERM_HANDLE not in perms and '000001' not in perms:
|
||||
# 超管占位:is admin via kefu object
|
||||
is_admin = getattr(request.user, 'UserType', '') == 'admin' or getattr(request.user, 'IsSuperuser', False)
|
||||
if not is_admin:
|
||||
return _err('无权查看', http=403)
|
||||
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True))
|
||||
# 申请人 / 被投诉人 / 牵连组长 / 客服
|
||||
if user_can_view_gongdan(gd, request.user.UserUID):
|
||||
return _ok(serialize_gongdan(
|
||||
gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID,
|
||||
))
|
||||
from backend.utils import verify_kefu_permission
|
||||
from gongdan.constants import PERM_VIEW, PERM_HANDLE
|
||||
kefu, perms_or_resp = verify_kefu_permission(
|
||||
request, request.data.get('username') or getattr(request.user, 'Phone', None),
|
||||
)
|
||||
if not kefu:
|
||||
return perms_or_resp
|
||||
perms = perms_or_resp if isinstance(perms_or_resp, list) else []
|
||||
if PERM_VIEW not in perms and PERM_HANDLE not in perms and '000001' not in perms:
|
||||
is_admin = getattr(request.user, 'UserType', '') == 'admin' or getattr(request.user, 'IsSuperuser', False)
|
||||
if not is_admin:
|
||||
return _err('无权查看', http=403)
|
||||
return _ok(serialize_gongdan(
|
||||
gd, include_images=True, include_shengji=True, for_admin=True,
|
||||
))
|
||||
|
||||
|
||||
class GongdanEscalateView(APIView):
|
||||
@@ -173,7 +197,10 @@ class GongdanEscalateView(APIView):
|
||||
shuoming=d.get('shuoming') or '',
|
||||
image_urls=images,
|
||||
)
|
||||
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '已提交补充投诉')
|
||||
return _ok(
|
||||
serialize_gongdan(gd, include_images=True, include_shengji=True, viewer_uid=request.user.UserUID),
|
||||
'已提交补充投诉',
|
||||
)
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
except Exception as e:
|
||||
@@ -191,7 +218,86 @@ class GongdanDissatisfiedView(APIView):
|
||||
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
|
||||
yonghuid=request.user.UserUID,
|
||||
)
|
||||
return _ok(serialize_gongdan(gd), '已标记不满意,可继续投诉')
|
||||
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已标记不满意,可继续投诉')
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
|
||||
|
||||
class GongdanResolveView(APIView):
|
||||
"""投诉人确认已解决。POST /gongdan/resolve/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
gd = user_resolve_gongdan(
|
||||
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
|
||||
yonghuid=request.user.UserUID,
|
||||
)
|
||||
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已确认解决')
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
|
||||
|
||||
class GongdanNegotiateView(APIView):
|
||||
"""转协商中。POST /gongdan/negotiate/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
gd = user_start_negotiate(
|
||||
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
|
||||
yonghuid=request.user.UserUID,
|
||||
)
|
||||
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已进入协商')
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
|
||||
|
||||
class GongdanApplyInterveneView(APIView):
|
||||
"""申请平台介入。POST /gongdan/apply-intervene/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
gd = user_apply_intervene(
|
||||
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
|
||||
yonghuid=request.user.UserUID,
|
||||
)
|
||||
return _ok(serialize_gongdan(gd, viewer_uid=request.user.UserUID), '已申请平台介入')
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
|
||||
|
||||
class GongdanWithdrawCheckView(APIView):
|
||||
"""提现前投诉拦截检查。POST /gongdan/withdraw-block-check/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
ok, msg, extra = check_complaint_blocks_withdraw(request.user, club_id=_resolve_club(request))
|
||||
return _ok({
|
||||
'blocked': not ok,
|
||||
'msg': msg,
|
||||
'reason': extra.get('reason') or '',
|
||||
'tab': extra.get('tab') or '',
|
||||
'count': extra.get('count') or 0,
|
||||
})
|
||||
|
||||
|
||||
class GongdanSearchTargetView(APIView):
|
||||
"""搜索被投诉管事/组长。POST /gongdan/search-target/"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
d = request.data
|
||||
try:
|
||||
data = search_complaint_targets(
|
||||
role=d.get('role') or d.get('target_role') or '',
|
||||
keyword=d.get('keyword') or d.get('q') or '',
|
||||
club_id=_resolve_club(request),
|
||||
page=d.get('page') or 1,
|
||||
page_size=d.get('page_size') or 20,
|
||||
)
|
||||
return _ok(data)
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
|
||||
@@ -233,19 +339,41 @@ class ChongzhiRecordListView(APIView):
|
||||
CZJILU_LEIXING_SHANGJIA: '商家充值',
|
||||
5: '考核',
|
||||
6: '罚款',
|
||||
7: '转盘',
|
||||
}
|
||||
# 与 czjilu 实际状态一致:待支付/已取消/已支付等全量展示,不筛选
|
||||
status_map = {
|
||||
9: '待支付',
|
||||
3: '已支付',
|
||||
2: '已取消',
|
||||
1: '处理中',
|
||||
8: '履约失败',
|
||||
10: '业务失败',
|
||||
0: '未知',
|
||||
}
|
||||
status_map = {9: '待支付', 3: '已支付', 2: '已取消', 8: '履约失败', 10: '业务失败'}
|
||||
rows = []
|
||||
for o in qs[start:start + page_size]:
|
||||
z = o.zhuangtai
|
||||
try:
|
||||
z_int = int(z) if z is not None else None
|
||||
except (TypeError, ValueError):
|
||||
z_int = None
|
||||
ct = ''
|
||||
if getattr(o, 'CreateTime', None):
|
||||
try:
|
||||
ct = o.CreateTime.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception:
|
||||
ct = o.CreateTime.isoformat()
|
||||
rows.append({
|
||||
'dingdan_id': o.dingdan_id,
|
||||
'leixing': o.leixing,
|
||||
'leixing_label': type_map.get(o.leixing, str(o.leixing)),
|
||||
'leixing_label': type_map.get(o.leixing, f'类型{o.leixing}'),
|
||||
'jine': str(o.jine),
|
||||
'zhuangtai': o.zhuangtai,
|
||||
'zhuangtai_label': status_map.get(o.zhuangtai, str(o.zhuangtai)),
|
||||
'zhuangtai': z_int if z_int is not None else z,
|
||||
'zhuangtai_label': status_map.get(z_int, f'状态{z}' if z is not None else '未知'),
|
||||
'shuoming': o.shuoming or '',
|
||||
'CreateTime': o.CreateTime.isoformat() if getattr(o, 'CreateTime', None) else '',
|
||||
'club_id': getattr(o, 'club_id', '') or '',
|
||||
'CreateTime': ct,
|
||||
})
|
||||
return _ok({'list': rows, 'total': total, 'page': page, 'page_size': page_size})
|
||||
|
||||
@@ -294,24 +422,29 @@ class GongdanAdminListView(APIView):
|
||||
only_escalated = str(request.data.get('only_escalated') or '').lower() in ('1', 'true', 'yes')
|
||||
need_handle = str(request.data.get('need_handle') or '').lower() in ('1', 'true', 'yes')
|
||||
if need_handle:
|
||||
qs = qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3))
|
||||
qs = qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5))
|
||||
elif zhuangtai not in (None, ''):
|
||||
qs = qs.filter(zhuangtai=int(zhuangtai))
|
||||
if only_escalated:
|
||||
qs = qs.filter(shengji_cishu__gt=0).filter(Q(zhuangtai=1) | Q(zhuangtai=3))
|
||||
qs = qs.filter(shengji_cishu__gt=0).filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5))
|
||||
if leixing not in (None, ''):
|
||||
qs = qs.filter(leixing=int(leixing))
|
||||
if keyword:
|
||||
qs = qs.filter(
|
||||
Q(gongdan_id__icontains=keyword)
|
||||
| Q(shenqingren_id__icontains=keyword)
|
||||
| Q(target_user_id__icontains=keyword)
|
||||
| Q(guanshi_id__icontains=keyword)
|
||||
| Q(order_id__icontains=keyword)
|
||||
| Q(chongzhi_id__icontains=keyword)
|
||||
| Q(fadan_id__icontains=keyword)
|
||||
)
|
||||
total = qs.count()
|
||||
start = max(page - 1, 0) * page_size
|
||||
rows = [serialize_gongdan(g, include_images=False) for g in qs[start:start + page_size]]
|
||||
rows = [
|
||||
serialize_gongdan(g, include_images=False, for_admin=True)
|
||||
for g in qs[start:start + page_size]
|
||||
]
|
||||
|
||||
# 统计基于俱乐部范围全量(不受当前筛选影响),方便客服一眼看到待办量
|
||||
stats_qs = Gongdan.query.all()
|
||||
@@ -326,10 +459,11 @@ class GongdanAdminListView(APIView):
|
||||
'done': stats_qs.filter(zhuangtai=2).count(),
|
||||
'dissatisfied': stats_qs.filter(zhuangtai=3).count(),
|
||||
'closed': stats_qs.filter(zhuangtai=4).count(),
|
||||
'negotiating': stats_qs.filter(zhuangtai=5).count(),
|
||||
'escalated': stats_qs.filter(shengji_cishu__gt=0).filter(
|
||||
Q(zhuangtai=1) | Q(zhuangtai=3)
|
||||
Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)
|
||||
).count(),
|
||||
'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3)).count(),
|
||||
'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3) | Q(zhuangtai=5)).count(),
|
||||
}
|
||||
return _ok({
|
||||
'list': rows,
|
||||
@@ -358,10 +492,59 @@ class GongdanAdminHandleView(APIView):
|
||||
handler_name=handler_name,
|
||||
action=d.get('action') or 'done',
|
||||
chuli_jieguo=d.get('chuli_jieguo') or d.get('jieguo') or '',
|
||||
intervene_reason=d.get('intervene_reason') or '',
|
||||
)
|
||||
return _ok(
|
||||
serialize_gongdan(gd, include_images=True, include_shengji=True, for_admin=True),
|
||||
'处理成功',
|
||||
)
|
||||
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '处理成功')
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
except Exception as e:
|
||||
logger.error('处理工单失败: %s', e, exc_info=True)
|
||||
return _err('处理失败', http=500)
|
||||
|
||||
|
||||
class GongdanAdminClubConfigView(APIView):
|
||||
"""
|
||||
当前俱乐部工单开关。
|
||||
GET/POST /gongdan/admin/club-config/
|
||||
字段:complaint_withdraw_block(未结清投诉/牵连时禁止提现)
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def _club_id(self, request):
|
||||
try:
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
return (resolve_club_id_from_request(request) or '').strip()
|
||||
except Exception:
|
||||
return (getattr(request.user, 'ClubID', None) or getattr(request.user, 'club_id', None) or '').strip()
|
||||
|
||||
def get(self, request):
|
||||
kefu, perms, err = _require_kefu_perm(request, PERM_VIEW, PERM_HANDLE)
|
||||
if err:
|
||||
return err
|
||||
club_id = self._club_id(request)
|
||||
if not club_id:
|
||||
return _err('请先选择俱乐部')
|
||||
return _ok(get_club_gongdan_config(club_id))
|
||||
|
||||
def post(self, request):
|
||||
kefu, perms, err = _require_kefu_perm(request, PERM_HANDLE)
|
||||
if err:
|
||||
return err
|
||||
club_id = self._club_id(request)
|
||||
if not club_id:
|
||||
return _err('请先选择俱乐部')
|
||||
raw = request.data.get('complaint_withdraw_block')
|
||||
if raw is None:
|
||||
return _err('缺少 complaint_withdraw_block')
|
||||
if isinstance(raw, str):
|
||||
enabled = raw.strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
else:
|
||||
enabled = bool(raw)
|
||||
try:
|
||||
data = set_complaint_withdraw_block(club_id, enabled)
|
||||
return _ok(data, '已保存')
|
||||
except GongdanError as e:
|
||||
return _err(str(e))
|
||||
|
||||
125
jituan/management/commands/seed_fund_freeze_perm.py
Normal file
125
jituan/management/commands/seed_fund_freeze_perm.py
Normal file
@@ -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}'
|
||||
))
|
||||
@@ -50,6 +50,30 @@ EXTRA_PERMISSIONS = [
|
||||
'审核本俱乐部提现(同意/拒绝/自动打款)。跨俱乐部仅集团高管;须角色 ClubID + 数据范围任职。',
|
||||
'提现管理',
|
||||
),
|
||||
(
|
||||
'djcfg001',
|
||||
'资金冻结规则配置',
|
||||
'配置本俱乐部各角色冻结比例/分期/解冻条件',
|
||||
'资金冻结',
|
||||
),
|
||||
(
|
||||
'djled001',
|
||||
'资金冻结明细',
|
||||
'查看冻结流水并手动解冻',
|
||||
'资金冻结',
|
||||
),
|
||||
(
|
||||
'djrisk001',
|
||||
'罚款投诉风险指标',
|
||||
'查看罚款率/投诉率等解冻相关指标',
|
||||
'资金冻结',
|
||||
),
|
||||
(
|
||||
'xycfg001',
|
||||
'角色协议配置',
|
||||
'配置打手/管事/组长用户协议并发布版本',
|
||||
'小程序配置',
|
||||
),
|
||||
# ---------- 店铺管理(999 系列:列表/详情/复制/提现等) ----------
|
||||
('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'),
|
||||
('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'),
|
||||
|
||||
@@ -37,3 +37,12 @@ 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}'))
|
||||
try:
|
||||
call_command('seed_role_agreement_perm')
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.WARNING(f'seed_role_agreement_perm 跳过: {e}'))
|
||||
|
||||
119
jituan/management/commands/seed_role_agreement_perm.py
Normal file
119
jituan/management/commands/seed_role_agreement_perm.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""写入角色协议权限码,并可按俱乐部挂到角色。
|
||||
|
||||
权限码:
|
||||
xycfg001 角色协议配置
|
||||
|
||||
用法:
|
||||
python manage.py seed_role_agreement_perm
|
||||
python manage.py seed_role_agreement_perm --club-id lxs --role-name 管理员
|
||||
"""
|
||||
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 = [
|
||||
('xycfg001', '角色协议配置', '配置打手/管事/组长用户协议并发布版本', '小程序配置'),
|
||||
]
|
||||
|
||||
|
||||
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():
|
||||
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}'
|
||||
))
|
||||
131
jituan/migrations/0017_fund_freeze.py
Normal file
131
jituan/migrations/0017_fund_freeze.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
66
jituan/migrations/0018_role_agreement.py
Normal file
66
jituan/migrations/0018_role_agreement.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# Generated manually for role agreement tables
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0017_fund_freeze'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClubRoleAgreement',
|
||||
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)),
|
||||
('role', models.CharField(choices=[('dashou', '打手'), ('guanshi', '管事'), ('zuzhang', '组长')], db_index=True, max_length=16)),
|
||||
('enabled', models.BooleanField(default=False, verbose_name='是否要求签署')),
|
||||
('title', models.CharField(blank=True, default='', max_length=128, verbose_name='协议标题')),
|
||||
('content_text', models.TextField(blank=True, default='', verbose_name='协议正文')),
|
||||
('images_json', models.JSONField(blank=True, default=list, verbose_name='说明图片URL列表')),
|
||||
('version', models.PositiveIntegerField(default=0, verbose_name='已发布版本号')),
|
||||
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||
('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_role_agreement',
|
||||
'unique_together': {('club_id', 'role')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ClubRoleAgreementSign',
|
||||
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)),
|
||||
('agreement_id', models.BigIntegerField(db_index=True, default=0)),
|
||||
('version', models.PositiveIntegerField(db_index=True)),
|
||||
('title_snapshot', models.CharField(blank=True, default='', max_length=128)),
|
||||
('content_snapshot', models.TextField(blank=True, default='')),
|
||||
('images_snapshot', models.JSONField(blank=True, default=list)),
|
||||
('sign_image_url', models.CharField(blank=True, default='', max_length=512)),
|
||||
('client_meta', models.JSONField(blank=True, default=dict)),
|
||||
('signed_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '角色协议签署记录',
|
||||
'db_table': 'club_role_agreement_sign',
|
||||
'unique_together': {('club_id', 'user_id', 'role', 'version')},
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubroleagreementsign',
|
||||
index=models.Index(fields=['user_id', 'role', 'version'], name='idx_cras_user_role_ver'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='clubroleagreementsign',
|
||||
index=models.Index(fields=['club_id', 'role', 'signed_at'], name='idx_cras_club_role_t'),
|
||||
),
|
||||
]
|
||||
22
jituan/migrations/0019_role_agreement_gates.py
Normal file
22
jituan/migrations/0019_role_agreement_gates.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# 角色协议:阅读秒数 + 卡点配置(去掉手写依赖,字段兼容保留)
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('jituan', '0018_role_agreement'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='clubroleagreement',
|
||||
name='read_seconds',
|
||||
field=models.PositiveIntegerField(default=2, verbose_name='阅读秒数'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clubroleagreement',
|
||||
name='gate_config',
|
||||
field=models.JSONField(blank=True, default=dict, verbose_name='签署卡点配置'),
|
||||
),
|
||||
]
|
||||
208
jituan/models.py
208
jituan/models.py
@@ -464,3 +464,211 @@ 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'),
|
||||
]
|
||||
|
||||
|
||||
class ClubRoleAgreement(QModel):
|
||||
"""俱乐部×身份用户协议(当前生效配置,发布时 version+1)。"""
|
||||
ROLE_CHOICES = (
|
||||
('dashou', '打手'),
|
||||
('guanshi', '管事'),
|
||||
('zuzhang', '组长'),
|
||||
)
|
||||
|
||||
club_id = models.CharField(max_length=16, db_index=True)
|
||||
role = models.CharField(max_length=16, choices=ROLE_CHOICES, db_index=True)
|
||||
enabled = models.BooleanField(default=False, verbose_name='是否要求签署')
|
||||
title = models.CharField(max_length=128, blank=True, default='', verbose_name='协议标题')
|
||||
content_text = models.TextField(blank=True, default='', verbose_name='协议正文')
|
||||
images_json = models.JSONField(default=list, blank=True, verbose_name='说明图片URL列表')
|
||||
# 勾选前强制停留秒数,0=可立即勾选
|
||||
read_seconds = models.PositiveIntegerField(default=2, verbose_name='阅读秒数')
|
||||
# 卡点开关:qiangdan/register/tixian/invite,按角色默认见服务层
|
||||
gate_config = models.JSONField(default=dict, blank=True, verbose_name='签署卡点配置')
|
||||
version = models.PositiveIntegerField(default=0, verbose_name='已发布版本号')
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
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_role_agreement'
|
||||
verbose_name = '角色协议'
|
||||
unique_together = [['club_id', 'role']]
|
||||
|
||||
|
||||
class ClubRoleAgreementSign(QModel):
|
||||
"""用户签署某俱乐部某身份某版本协议的留痕(勾选确认,无手写图)。"""
|
||||
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)
|
||||
agreement_id = models.BigIntegerField(db_index=True, default=0)
|
||||
version = models.PositiveIntegerField(db_index=True)
|
||||
title_snapshot = models.CharField(max_length=128, blank=True, default='')
|
||||
content_snapshot = models.TextField(blank=True, default='')
|
||||
images_snapshot = models.JSONField(default=list, blank=True)
|
||||
# 兼容旧字段,新签署固定留空
|
||||
sign_image_url = models.CharField(max_length=512, blank=True, default='')
|
||||
client_meta = models.JSONField(default=dict, blank=True)
|
||||
signed_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||
UpdateTime = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'club_role_agreement_sign'
|
||||
verbose_name = '角色协议签署记录'
|
||||
unique_together = [['club_id', 'user_id', 'role', 'version']]
|
||||
indexes = [
|
||||
models.Index(fields=['user_id', 'role', 'version'], name='idx_cras_user_role_ver'),
|
||||
models.Index(fields=['club_id', 'role', 'signed_at'], name='idx_cras_club_role_t'),
|
||||
]
|
||||
|
||||
644
jituan/services/fund_freeze.py
Normal file
644
jituan/services/fund_freeze.py
Normal file
@@ -0,0 +1,644 @@
|
||||
"""资金冻结入账网关与解冻骨架。
|
||||
|
||||
默认:无配置或 enabled=False → 全额进可提现,与现网一致,不写流水。
|
||||
开启后:拆「可提现 + 冻结池」并写 FundFreezeLedger。
|
||||
|
||||
P1:打手订单结算入口已统一走 credit_dashou_order_settle(未开冻结时行为与改造前一致)。
|
||||
"""
|
||||
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,
|
||||
'display_reason': (cfg.remark or '')[:255],
|
||||
'remark': (cfg.remark or '')[:255],
|
||||
}
|
||||
|
||||
|
||||
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 (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,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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]:
|
||||
"""
|
||||
执行一笔冻结单的当前分期步。
|
||||
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 = (
|
||||
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()
|
||||
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': '仅允许手动解冻'}
|
||||
|
||||
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, 'need_condition': False}
|
||||
else:
|
||||
step = steps[step_idx]
|
||||
need_step_cond = bool(step.get('need_condition'))
|
||||
|
||||
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,
|
||||
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': '角色扩展表不存在'}
|
||||
|
||||
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:
|
||||
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)
|
||||
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,
|
||||
user_id=ledger.user_id,
|
||||
role=ledger.role,
|
||||
step_seq=int(step.get('seq') or ledger.current_step),
|
||||
amount=amount,
|
||||
trigger=event_trigger,
|
||||
reason=(reason or f'{event_trigger}解冻')[:255],
|
||||
operator_id=(operator_id or '')[:32],
|
||||
condition_snapshot=cond_snap or {},
|
||||
)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'amount': amount,
|
||||
'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()
|
||||
base = FundFreezeLedger.query.filter(
|
||||
status__in=[FundFreezeLedger.STATUS_FROZEN, FundFreezeLedger.STATUS_PARTIAL],
|
||||
)
|
||||
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:
|
||||
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
|
||||
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', lid, e)
|
||||
return {
|
||||
'ok': ok_n,
|
||||
'fail': fail_n,
|
||||
'retry': retry_n,
|
||||
'scanned': len(ids),
|
||||
'due': len(due_ids),
|
||||
'cond_scan': len(cond_ids),
|
||||
}
|
||||
421
jituan/services/fund_freeze_admin.py
Normal file
421
jituan/services/fund_freeze_admin.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""客服后台:资金冻结配置 / 明细 / 风险指标。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from jituan.constants import CLUB_ID_DEFAULT
|
||||
from jituan.models import ClubFundFreezeConfig, FundFreezeLedger, FundUnfreezeEvent
|
||||
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_GUANSHI,
|
||||
ROLE_SHENHEGUAN,
|
||||
ROLE_ZUZHANG,
|
||||
apply_unfreeze_step,
|
||||
invalidate_freeze_config_cache,
|
||||
split_preview,
|
||||
)
|
||||
|
||||
ROLES = (
|
||||
(ROLE_DASHOU, '打手'),
|
||||
(ROLE_GUANSHI, '管事'),
|
||||
(ROLE_ZUZHANG, '组长'),
|
||||
(ROLE_SHENHEGUAN, '考核官'),
|
||||
)
|
||||
|
||||
DEFAULT_INSTALLMENT = {
|
||||
'steps': [
|
||||
{'seq': 1, 'after_days': 7, 'type': 'ratio', 'value': 0.3, 'need_condition': False},
|
||||
{'seq': 2, 'after_days': 14, 'type': 'ratio', 'value': 0.3, 'need_condition': True},
|
||||
{'seq': 3, 'after_days': 30, 'type': 'remain', 'value': 1, 'need_condition': True},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _club(request) -> str:
|
||||
return (resolve_club_id_from_request(request) or CLUB_ID_DEFAULT).strip() or CLUB_ID_DEFAULT
|
||||
|
||||
|
||||
def _dec(v, default='0') -> str:
|
||||
try:
|
||||
return str(Decimal(str(v if v is not None else default)))
|
||||
except Exception:
|
||||
return str(default)
|
||||
|
||||
|
||||
def _cfg_to_dict(row: Optional[ClubFundFreezeConfig], club_id: str, role: str) -> Dict[str, Any]:
|
||||
if not row:
|
||||
return {
|
||||
'club_id': club_id,
|
||||
'role': role,
|
||||
'enabled': False,
|
||||
'freeze_mode': 1,
|
||||
'freeze_rate': '0',
|
||||
'freeze_fixed': '0',
|
||||
'freeze_cap': '0',
|
||||
'min_credit_to_freeze': '0',
|
||||
'source_scope': ['order_settle'],
|
||||
'unfreeze_trigger': 'time_only',
|
||||
'schedule_mode': 'all_at_once',
|
||||
'installment_json': {'steps': [{'seq': 1, 'after_days': 15, 'type': 'remain', 'value': 1, 'need_condition': False}]},
|
||||
'condition_json': {
|
||||
'min_completed_orders': 0,
|
||||
'max_fine_rate': 1,
|
||||
'fine_rate_window_days': 30,
|
||||
'max_complaint_rate': 1,
|
||||
'complaint_rate_window_days': 30,
|
||||
'min_valid_invites': 0,
|
||||
},
|
||||
'freeze_min_days': 0,
|
||||
'remark': '',
|
||||
'display_reason': '',
|
||||
'exists': False,
|
||||
}
|
||||
return {
|
||||
'club_id': row.club_id,
|
||||
'role': row.role,
|
||||
'enabled': bool(row.enabled),
|
||||
'freeze_mode': int(row.freeze_mode or 1),
|
||||
'freeze_rate': _dec(row.freeze_rate),
|
||||
'freeze_fixed': _dec(row.freeze_fixed),
|
||||
'freeze_cap': _dec(row.freeze_cap),
|
||||
'min_credit_to_freeze': _dec(row.min_credit_to_freeze),
|
||||
'source_scope': row.source_scope or [],
|
||||
'unfreeze_trigger': row.unfreeze_trigger or 'time_only',
|
||||
'schedule_mode': row.schedule_mode or 'all_at_once',
|
||||
'installment_json': row.installment_json or {},
|
||||
'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,
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
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')
|
||||
d['preview_100'] = {'available': str(avail), 'frozen': str(frozen)}
|
||||
items.append(d)
|
||||
return {'club_id': club_id, 'roles': items}
|
||||
|
||||
|
||||
def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
|
||||
club_id = _club(request)
|
||||
role = (data.get('role') or '').strip().lower()
|
||||
if role not in {r[0] for r in ROLES}:
|
||||
return None, '无效角色'
|
||||
|
||||
enabled = bool(data.get('enabled'))
|
||||
freeze_mode = int(data.get('freeze_mode') or 1)
|
||||
if freeze_mode not in (1, 2, 3):
|
||||
return None, 'freeze_mode 无效'
|
||||
|
||||
try:
|
||||
freeze_rate = Decimal(str(data.get('freeze_rate') or 0))
|
||||
freeze_fixed = Decimal(str(data.get('freeze_fixed') or 0))
|
||||
freeze_cap = Decimal(str(data.get('freeze_cap') or 0))
|
||||
min_credit = Decimal(str(data.get('min_credit_to_freeze') or 0))
|
||||
except Exception:
|
||||
return None, '金额/比例格式错误'
|
||||
|
||||
if freeze_rate < 0 or freeze_rate > 1:
|
||||
return None, '冻结比例须在 0~1 之间(如 0.2=20%)'
|
||||
|
||||
source_scope = data.get('source_scope')
|
||||
if source_scope is None:
|
||||
source_scope = ['order_settle']
|
||||
if isinstance(source_scope, str):
|
||||
try:
|
||||
source_scope = json.loads(source_scope)
|
||||
except Exception:
|
||||
source_scope = [source_scope]
|
||||
|
||||
unfreeze_trigger = (data.get('unfreeze_trigger') or 'time_only').strip()
|
||||
if unfreeze_trigger not in (
|
||||
'time_only', 'condition_only', 'time_and_condition', 'time_or_condition', 'manual_only',
|
||||
):
|
||||
return None, '解冻触发模式无效'
|
||||
|
||||
schedule_mode = (data.get('schedule_mode') or 'all_at_once').strip()
|
||||
if schedule_mode not in ('all_at_once', 'installment'):
|
||||
return None, '分期模式无效'
|
||||
|
||||
installment_json = data.get('installment_json') or {}
|
||||
if isinstance(installment_json, str):
|
||||
try:
|
||||
installment_json = json.loads(installment_json)
|
||||
except Exception:
|
||||
return None, 'installment_json 不是合法 JSON'
|
||||
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):
|
||||
try:
|
||||
condition_json = json.loads(condition_json)
|
||||
except Exception:
|
||||
return None, 'condition_json 不是合法 JSON'
|
||||
|
||||
freeze_min_days = int(data.get('freeze_min_days') or 0)
|
||||
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(
|
||||
club_id=club_id,
|
||||
role=role,
|
||||
)
|
||||
row.enabled = enabled
|
||||
row.freeze_mode = freeze_mode
|
||||
row.freeze_rate = freeze_rate
|
||||
row.freeze_fixed = freeze_fixed
|
||||
row.freeze_cap = freeze_cap
|
||||
row.min_credit_to_freeze = min_credit
|
||||
row.source_scope = source_scope
|
||||
row.unfreeze_trigger = unfreeze_trigger
|
||||
row.schedule_mode = schedule_mode
|
||||
row.installment_json = installment_json
|
||||
row.condition_json = condition_json
|
||||
row.freeze_min_days = freeze_min_days
|
||||
row.remark = display_reason
|
||||
row.updated_by = (operator or '')[:32]
|
||||
row.save()
|
||||
invalidate_freeze_config_cache(club_id, role)
|
||||
out = _cfg_to_dict(row, club_id, role)
|
||||
out['role_name'] = dict(ROLES).get(role, role)
|
||||
return out, None
|
||||
|
||||
|
||||
STATUS_LABEL = {
|
||||
FundFreezeLedger.STATUS_FROZEN: '冻结中',
|
||||
FundFreezeLedger.STATUS_PARTIAL: '部分解冻',
|
||||
FundFreezeLedger.STATUS_DONE: '已解冻',
|
||||
FundFreezeLedger.STATUS_VOID: '已作废',
|
||||
}
|
||||
|
||||
|
||||
def list_ledgers(request, data: dict) -> Dict[str, Any]:
|
||||
club_id = _club(request)
|
||||
page = max(1, int(data.get('page') or 1))
|
||||
page_size = min(100, max(1, int(data.get('page_size') or 20)))
|
||||
role = (data.get('role') or '').strip().lower()
|
||||
status = data.get('status')
|
||||
user_id = (data.get('user_id') or data.get('yonghuid') or '').strip()
|
||||
order_id = (data.get('order_id') or '').strip()
|
||||
kw = (data.get('keyword') or '').strip()
|
||||
|
||||
qs = FundFreezeLedger.query.filter(club_id=club_id)
|
||||
if role:
|
||||
qs = qs.filter(role=role)
|
||||
if status not in (None, ''):
|
||||
try:
|
||||
qs = qs.filter(status=int(status))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if user_id:
|
||||
qs = qs.filter(user_id=user_id)
|
||||
if order_id:
|
||||
qs = qs.filter(order_id=order_id)
|
||||
if kw:
|
||||
qs = qs.filter(Q(user_id__icontains=kw) | Q(order_id__icontains=kw) | Q(biz_id__icontains=kw))
|
||||
|
||||
total = qs.count()
|
||||
rows = qs.order_by('-id')[(page - 1) * page_size: page * page_size]
|
||||
items = []
|
||||
for r in rows:
|
||||
remain = Decimal(str(r.amount_total or 0)) - Decimal(str(r.amount_unfrozen or 0))
|
||||
items.append({
|
||||
'id': r.id,
|
||||
'club_id': r.club_id,
|
||||
'user_id': r.user_id,
|
||||
'role': r.role,
|
||||
'role_name': dict(ROLES).get(r.role, r.role),
|
||||
'amount_total': _dec(r.amount_total),
|
||||
'amount_unfrozen': _dec(r.amount_unfrozen),
|
||||
'amount_remain': _dec(remain),
|
||||
'status': r.status,
|
||||
'status_label': STATUS_LABEL.get(r.status, str(r.status)),
|
||||
'frozen_at': r.frozen_at.isoformat() if r.frozen_at else '',
|
||||
'next_eligible_at': r.next_eligible_at.isoformat() if r.next_eligible_at else '',
|
||||
'source_type': r.source_type,
|
||||
'order_id': r.order_id,
|
||||
'czjilu_id': r.czjilu_id,
|
||||
'biz_id': r.biz_id,
|
||||
'freeze_reason': r.freeze_reason,
|
||||
'current_step': r.current_step,
|
||||
})
|
||||
return {
|
||||
'club_id': club_id,
|
||||
'list': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
}
|
||||
|
||||
|
||||
def list_unfreeze_events(ledger_id: int, club_id: str) -> List[dict]:
|
||||
qs = FundUnfreezeEvent.query.filter(ledger_id=ledger_id, club_id=club_id).order_by('-id')[:50]
|
||||
return [{
|
||||
'id': e.id,
|
||||
'step_seq': e.step_seq,
|
||||
'amount': _dec(e.amount),
|
||||
'unfrozen_at': e.unfrozen_at.isoformat() if e.unfrozen_at else '',
|
||||
'trigger': e.trigger,
|
||||
'reason': e.reason,
|
||||
'operator_id': e.operator_id,
|
||||
} for e in qs]
|
||||
|
||||
|
||||
def manual_unfreeze(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
|
||||
club_id = _club(request)
|
||||
try:
|
||||
ledger_id = int(data.get('ledger_id') or data.get('id') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None, 'ledger_id 无效'
|
||||
if ledger_id <= 0:
|
||||
return None, '缺少 ledger_id'
|
||||
|
||||
ledger = FundFreezeLedger.query.filter(id=ledger_id, club_id=club_id).first()
|
||||
if not ledger:
|
||||
return None, '冻结单不存在或不属于当前俱乐部'
|
||||
|
||||
mode = (data.get('mode') or 'step').strip() # step | all
|
||||
if mode == 'all':
|
||||
# 循环解完(最多 20 步防死循环)
|
||||
last = None
|
||||
for _ in range(20):
|
||||
last = apply_unfreeze_step(
|
||||
ledger_id,
|
||||
trigger='manual',
|
||||
operator_id=operator,
|
||||
reason=(data.get('reason') or '后台手动全部解冻')[:255],
|
||||
force=True,
|
||||
)
|
||||
if not last.get('ok'):
|
||||
return None, last.get('msg') or '解冻失败'
|
||||
if last.get('done'):
|
||||
break
|
||||
return last, None
|
||||
|
||||
result = apply_unfreeze_step(
|
||||
ledger_id,
|
||||
trigger='manual',
|
||||
operator_id=operator,
|
||||
reason=(data.get('reason') or '后台手动解冻一步')[:255],
|
||||
force=True,
|
||||
)
|
||||
if not result.get('ok'):
|
||||
return None, result.get('msg') or '解冻失败'
|
||||
return result, None
|
||||
|
||||
|
||||
def list_risk_users(request, data: dict) -> Dict[str, Any]:
|
||||
"""从角色扩展表读风险快照 + 冻结池(按俱乐部用户过滤)。"""
|
||||
from users.models import UserDashou, UserGuanshi, UserZuzhang, UserShenheguan
|
||||
from jituan.services.club_context import filter_user_related_by_club
|
||||
|
||||
club_id = _club(request)
|
||||
role = (data.get('role') or ROLE_DASHOU).strip().lower()
|
||||
page = max(1, int(data.get('page') or 1))
|
||||
page_size = min(100, max(1, int(data.get('page_size') or 20)))
|
||||
keyword = (data.get('keyword') or '').strip()
|
||||
only_frozen = bool(data.get('only_frozen'))
|
||||
|
||||
mapping = {
|
||||
ROLE_DASHOU: UserDashou,
|
||||
ROLE_GUANSHI: UserGuanshi,
|
||||
ROLE_ZUZHANG: UserZuzhang,
|
||||
ROLE_SHENHEGUAN: UserShenheguan,
|
||||
}
|
||||
model = mapping.get(role)
|
||||
if not model:
|
||||
return {'club_id': club_id, 'list': [], 'total': 0, 'page': page, 'page_size': page_size}
|
||||
|
||||
qs = model.query.select_related('user').all()
|
||||
qs = filter_user_related_by_club(qs, request, user_club_path='user__ClubID')
|
||||
|
||||
if keyword:
|
||||
qs = qs.filter(user__UserUID__icontains=keyword)
|
||||
if only_frozen:
|
||||
qs = qs.filter(dongjie_yue__gt=0)
|
||||
|
||||
total = qs.count()
|
||||
rows = qs.order_by('-dongjie_yue', '-id')[(page - 1) * page_size: page * page_size]
|
||||
items = []
|
||||
for p in rows:
|
||||
uid = getattr(p.user, 'UserUID', '') if p.user_id else ''
|
||||
if role == ROLE_ZUZHANG:
|
||||
available = _dec(getattr(p, 'ketixian_jine', 0))
|
||||
else:
|
||||
available = _dec(getattr(p, 'yue', 0))
|
||||
items.append({
|
||||
'user_id': uid,
|
||||
'role': role,
|
||||
'role_name': dict(ROLES).get(role, role),
|
||||
'available': available,
|
||||
'dongjie_yue': _dec(getattr(p, 'dongjie_yue', 0)),
|
||||
'risk_fine_rate': _dec(getattr(p, 'risk_fine_rate', 0)),
|
||||
'risk_complaint_rate': _dec(getattr(p, 'risk_complaint_rate', 0)),
|
||||
'risk_completed_orders': int(getattr(p, 'risk_completed_orders', 0) or 0),
|
||||
'risk_valid_invites': int(getattr(p, 'risk_valid_invites', 0) or 0),
|
||||
'risk_stat_at': p.risk_stat_at.isoformat() if getattr(p, 'risk_stat_at', None) else '',
|
||||
'nicheng': getattr(p, 'nicheng', '') or '',
|
||||
})
|
||||
return {
|
||||
'club_id': club_id,
|
||||
'role': role,
|
||||
'list': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
}
|
||||
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
|
||||
@@ -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']},
|
||||
@@ -101,6 +107,8 @@ KEFU_MENU_ROW_DEFS = [
|
||||
'perm_codes': ['phbj666', '8080a']},
|
||||
{'page_id': 'miniapp.recharge-copy', 'name': '充值页文案', 'path': '/miniapp/recharge-copy', 'parent_id': 'miniapp', 'sort_order': 119,
|
||||
'perm_codes': ['8080a']},
|
||||
{'page_id': 'miniapp.role-agreement', 'name': '角色协议', 'path': '/miniapp/role-agreement', 'parent_id': 'miniapp', 'sort_order': 120,
|
||||
'perm_codes': ['xycfg001', '8080a']},
|
||||
{'page_id': 'shop', 'name': '店铺管理', 'path': '', 'parent_id': '', 'sort_order': 120},
|
||||
{'page_id': 'shop.list', 'name': '店铺列表', 'path': '/shop/list', 'parent_id': 'shop', 'sort_order': 121,
|
||||
'perm_codes': ['999a', '999b', '999c', '999d', '999e']},
|
||||
|
||||
355
jituan/services/role_agreement.py
Normal file
355
jituan/services/role_agreement.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""角色协议:后台配置 + 小程序勾选签署(按俱乐部隔离)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from jituan.constants import CLUB_ID_DEFAULT
|
||||
from jituan.models import ClubRoleAgreement, ClubRoleAgreementSign
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
|
||||
ROLES = (
|
||||
('dashou', '打手/接单员'),
|
||||
('guanshi', '管事'),
|
||||
('zuzhang', '组长'),
|
||||
)
|
||||
|
||||
ROLE_SET = {r[0] for r in ROLES}
|
||||
|
||||
# 卡点:可后台逐项开关;默认贴近产品约定
|
||||
GATE_DEFS = (
|
||||
('qiangdan', '抢单/接单时'),
|
||||
('register', '身份注册时'),
|
||||
('tixian', '提现时'),
|
||||
('invite', '邀请下级时'),
|
||||
)
|
||||
GATE_SET = {g[0] for g in GATE_DEFS}
|
||||
|
||||
DEFAULT_GATES = {
|
||||
'dashou': {'qiangdan': True, 'register': False, 'tixian': False, 'invite': False},
|
||||
'guanshi': {'qiangdan': False, 'register': True, 'tixian': True, 'invite': False},
|
||||
'zuzhang': {'qiangdan': False, 'register': False, 'tixian': True, 'invite': False},
|
||||
}
|
||||
|
||||
|
||||
def _club(request) -> str:
|
||||
return (resolve_club_id_from_request(request) or CLUB_ID_DEFAULT).strip() or CLUB_ID_DEFAULT
|
||||
|
||||
|
||||
def _images(val) -> List[str]:
|
||||
if not val:
|
||||
return []
|
||||
if isinstance(val, list):
|
||||
return [str(x).strip() for x in val if str(x).strip()][:20]
|
||||
return []
|
||||
|
||||
|
||||
def _clamp_read_seconds(val) -> int:
|
||||
try:
|
||||
n = int(val)
|
||||
except (TypeError, ValueError):
|
||||
n = 2
|
||||
if n < 0:
|
||||
n = 0
|
||||
if n > 300:
|
||||
n = 300
|
||||
return n
|
||||
|
||||
|
||||
def normalize_gates(role: str, raw) -> Dict[str, bool]:
|
||||
base = dict(DEFAULT_GATES.get(role) or {k: False for k in GATE_SET})
|
||||
if isinstance(raw, dict):
|
||||
for k in GATE_SET:
|
||||
if k in raw:
|
||||
base[k] = bool(raw.get(k))
|
||||
return base
|
||||
|
||||
|
||||
def _row_to_dict(row: Optional[ClubRoleAgreement], club_id: str, role: str) -> Dict[str, Any]:
|
||||
if not row:
|
||||
return {
|
||||
'club_id': club_id,
|
||||
'role': role,
|
||||
'enabled': False,
|
||||
'title': '',
|
||||
'content_text': '',
|
||||
'images': [],
|
||||
'read_seconds': 2,
|
||||
'gate_config': normalize_gates(role, None),
|
||||
'gate_defs': [{'key': k, 'label': lab} for k, lab in GATE_DEFS],
|
||||
'version': 0,
|
||||
'published_at': '',
|
||||
'exists': False,
|
||||
}
|
||||
return {
|
||||
'club_id': row.club_id,
|
||||
'role': row.role,
|
||||
'enabled': bool(row.enabled),
|
||||
'title': row.title or '',
|
||||
'content_text': row.content_text or '',
|
||||
'images': _images(row.images_json),
|
||||
'read_seconds': _clamp_read_seconds(getattr(row, 'read_seconds', 2)),
|
||||
'gate_config': normalize_gates(role, getattr(row, 'gate_config', None)),
|
||||
'gate_defs': [{'key': k, 'label': lab} for k, lab in GATE_DEFS],
|
||||
'version': int(row.version or 0),
|
||||
'published_at': row.published_at.isoformat() if row.published_at else '',
|
||||
'updated_by': row.updated_by or '',
|
||||
'exists': True,
|
||||
'id': row.id,
|
||||
}
|
||||
|
||||
|
||||
def build_admin_payload(request) -> Dict[str, Any]:
|
||||
club_id = _club(request)
|
||||
rows = {
|
||||
r.role: r
|
||||
for r in ClubRoleAgreement.query.filter(club_id=club_id)
|
||||
}
|
||||
items = []
|
||||
for role, name in ROLES:
|
||||
d = _row_to_dict(rows.get(role), club_id, role)
|
||||
d['role_name'] = name
|
||||
items.append(d)
|
||||
return {
|
||||
'club_id': club_id,
|
||||
'roles': items,
|
||||
'gate_defs': [{'key': k, 'label': lab} for k, lab in GATE_DEFS],
|
||||
}
|
||||
|
||||
|
||||
def save_agreement(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
|
||||
club_id = _club(request)
|
||||
role = (data.get('role') or '').strip().lower()
|
||||
if role not in ROLE_SET:
|
||||
return None, '无效角色'
|
||||
|
||||
title = (data.get('title') or '')[:128]
|
||||
content_text = data.get('content_text') or ''
|
||||
if len(content_text) > 50000:
|
||||
return None, '正文过长'
|
||||
images = _images(data.get('images') or data.get('images_json'))
|
||||
enabled = bool(data.get('enabled'))
|
||||
read_seconds = _clamp_read_seconds(data.get('read_seconds', 2))
|
||||
gate_config = normalize_gates(role, data.get('gate_config'))
|
||||
|
||||
row = ClubRoleAgreement.query.filter(club_id=club_id, role=role).first()
|
||||
if not row:
|
||||
row = ClubRoleAgreement(club_id=club_id, role=role, version=0)
|
||||
row.enabled = enabled
|
||||
row.title = title
|
||||
row.content_text = content_text
|
||||
row.images_json = images
|
||||
row.read_seconds = read_seconds
|
||||
row.gate_config = gate_config
|
||||
row.updated_by = (operator or '')[:32]
|
||||
row.save()
|
||||
out = _row_to_dict(row, club_id, role)
|
||||
out['role_name'] = dict(ROLES).get(role, role)
|
||||
return out, None
|
||||
|
||||
|
||||
def publish_agreement(request, data: dict, operator: str = '') -> Tuple[Optional[dict], Optional[str]]:
|
||||
"""保存内容并 version+1,强制用户重签新版本。"""
|
||||
club_id = _club(request)
|
||||
role = (data.get('role') or '').strip().lower()
|
||||
if role not in ROLE_SET:
|
||||
return None, '无效角色'
|
||||
|
||||
saved, err = save_agreement(request, data, operator=operator)
|
||||
if err:
|
||||
return None, err
|
||||
|
||||
row = ClubRoleAgreement.query.filter(club_id=club_id, role=role).first()
|
||||
if not row:
|
||||
return None, '协议不存在'
|
||||
if not (row.title or '').strip() and not (row.content_text or '').strip() and not _images(row.images_json):
|
||||
return None, '请先填写标题、正文或上传图片再发布'
|
||||
|
||||
row.version = int(row.version or 0) + 1
|
||||
row.published_at = timezone.now()
|
||||
row.enabled = True if data.get('enabled') is None else bool(data.get('enabled'))
|
||||
row.updated_by = (operator or '')[:32]
|
||||
row.save()
|
||||
out = _row_to_dict(row, club_id, role)
|
||||
out['role_name'] = dict(ROLES).get(role, role)
|
||||
return out, None
|
||||
|
||||
|
||||
def get_current_for_user(
|
||||
club_id: str,
|
||||
user_id: str,
|
||||
role: str,
|
||||
*,
|
||||
gate: str = '',
|
||||
view_only: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取某角色当前协议。
|
||||
- gate 非空:仅当该卡点开启且未签当前版时 need_sign=True
|
||||
- view_only:只读查看,不强制签署
|
||||
"""
|
||||
role = (role or '').strip().lower()
|
||||
if role not in ROLE_SET:
|
||||
return {'need_sign': False, 'enabled': False, 'msg': '无效角色'}
|
||||
|
||||
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||
row = ClubRoleAgreement.query.filter(club_id=cid, role=role).first()
|
||||
empty = {
|
||||
'need_sign': False,
|
||||
'enabled': False,
|
||||
'club_id': cid,
|
||||
'role': role,
|
||||
'version': 0,
|
||||
'title': '',
|
||||
'content_text': '',
|
||||
'images': [],
|
||||
'read_seconds': 0,
|
||||
'gate_config': normalize_gates(role, None),
|
||||
'gate_active': False,
|
||||
'already_signed': False,
|
||||
}
|
||||
if not row or int(row.version or 0) <= 0:
|
||||
return empty
|
||||
|
||||
version = int(row.version)
|
||||
gates = normalize_gates(role, getattr(row, 'gate_config', None))
|
||||
signed = ClubRoleAgreementSign.query.filter(
|
||||
club_id=cid, user_id=str(user_id), role=role, version=version,
|
||||
).exists()
|
||||
enabled = bool(row.enabled)
|
||||
read_seconds = _clamp_read_seconds(getattr(row, 'read_seconds', 2))
|
||||
|
||||
gate_key = (gate or '').strip().lower()
|
||||
gate_active = True
|
||||
if gate_key:
|
||||
if gate_key not in GATE_SET:
|
||||
return {**empty, 'msg': '无效卡点', 'enabled': enabled}
|
||||
gate_active = bool(gates.get(gate_key))
|
||||
|
||||
need_sign = False
|
||||
if enabled and not view_only and not signed and gate_active:
|
||||
need_sign = True
|
||||
# 无 gate 且非 view:兼容旧调用,只要启用且未签就 need_sign
|
||||
if enabled and not view_only and not signed and not gate_key:
|
||||
need_sign = True
|
||||
|
||||
return {
|
||||
'need_sign': need_sign,
|
||||
'enabled': enabled,
|
||||
'club_id': cid,
|
||||
'role': role,
|
||||
'role_name': dict(ROLES).get(role, role),
|
||||
'version': version,
|
||||
'title': row.title or '角色服务协议',
|
||||
'content_text': row.content_text or '',
|
||||
'images': _images(row.images_json),
|
||||
'read_seconds': read_seconds,
|
||||
'gate_config': gates,
|
||||
'gate': gate_key,
|
||||
'gate_active': gate_active if gate_key else enabled,
|
||||
'agreement_id': row.id,
|
||||
'published_at': row.published_at.isoformat() if row.published_at else '',
|
||||
'already_signed': signed,
|
||||
}
|
||||
|
||||
|
||||
def list_for_user(club_id: str, user_id: str, roles: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""小程序「我的协议」列表:按俱乐部返回已发布协议摘要。"""
|
||||
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||
want = [r for r in (roles or list(ROLE_SET)) if r in ROLE_SET]
|
||||
if not want:
|
||||
want = list(ROLE_SET)
|
||||
items = []
|
||||
for role in want:
|
||||
d = get_current_for_user(cid, user_id, role, view_only=True)
|
||||
if int(d.get('version') or 0) <= 0:
|
||||
continue
|
||||
items.append({
|
||||
'role': d['role'],
|
||||
'role_name': d.get('role_name') or role,
|
||||
'title': d.get('title') or '',
|
||||
'version': d.get('version') or 0,
|
||||
'already_signed': bool(d.get('already_signed')),
|
||||
'enabled': bool(d.get('enabled')),
|
||||
'published_at': d.get('published_at') or '',
|
||||
})
|
||||
return {'club_id': cid, 'items': items}
|
||||
|
||||
|
||||
def sign_agreement(
|
||||
club_id: str,
|
||||
user_id: str,
|
||||
role: str,
|
||||
*,
|
||||
client_meta: Optional[dict] = None,
|
||||
agreed: bool = False,
|
||||
) -> Tuple[Optional[dict], Optional[str]]:
|
||||
"""勾选确认签署;不再要求手写签名图。"""
|
||||
role = (role or '').strip().lower()
|
||||
if role not in ROLE_SET:
|
||||
return None, '无效角色'
|
||||
if not agreed:
|
||||
return None, '请勾选同意协议'
|
||||
|
||||
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||
row = ClubRoleAgreement.query.filter(club_id=cid, role=role).first()
|
||||
if not row or not row.enabled or int(row.version or 0) <= 0:
|
||||
return None, '当前无需签署或协议未发布'
|
||||
|
||||
version = int(row.version)
|
||||
existed = ClubRoleAgreementSign.query.filter(
|
||||
club_id=cid, user_id=str(user_id), role=role, version=version,
|
||||
).first()
|
||||
if existed:
|
||||
return {
|
||||
'already_signed': True,
|
||||
'sign_id': existed.id,
|
||||
'version': version,
|
||||
'signed_at': existed.signed_at.isoformat() if existed.signed_at else '',
|
||||
}, None
|
||||
|
||||
meta = dict(client_meta or {})
|
||||
meta.setdefault('sign_mode', 'checkbox')
|
||||
sign = ClubRoleAgreementSign.query.create(
|
||||
club_id=cid,
|
||||
user_id=str(user_id),
|
||||
role=role,
|
||||
agreement_id=row.id,
|
||||
version=version,
|
||||
title_snapshot=(row.title or '')[:128],
|
||||
content_snapshot=row.content_text or '',
|
||||
images_snapshot=_images(row.images_json),
|
||||
sign_image_url='',
|
||||
client_meta=meta,
|
||||
)
|
||||
return {
|
||||
'already_signed': False,
|
||||
'sign_id': sign.id,
|
||||
'version': version,
|
||||
'signed_at': sign.signed_at.isoformat() if sign.signed_at else '',
|
||||
}, None
|
||||
|
||||
|
||||
def upload_agreement_image(file_obj, club_id: str, kind: str = 'content') -> Tuple[Optional[str], Optional[str]]:
|
||||
"""后台上传协议配图,返回相对路径。"""
|
||||
from utils.oss_utils import validate_image, upload_to_oss
|
||||
|
||||
ok, err = validate_image(file_obj)
|
||||
if not ok:
|
||||
return None, err or '图片无效'
|
||||
cid = (club_id or 'xq').strip() or 'xq'
|
||||
kind = 'content'
|
||||
ext = 'png'
|
||||
name = getattr(file_obj, 'name', '') or ''
|
||||
if '.' in name:
|
||||
ext = name.rsplit('.', 1)[-1].lower()[:8] or 'png'
|
||||
key = f'role_agreement/{cid}/{kind}/{uuid.uuid4().hex}.{ext}'
|
||||
try:
|
||||
path = upload_to_oss(file_obj, key)
|
||||
if not path:
|
||||
return None, '上传失败'
|
||||
except Exception as e:
|
||||
return None, f'上传失败: {e}'
|
||||
return path, None
|
||||
15
jituan/tasks.py
Normal file
15
jituan/tasks.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""资金冻结相关 Celery 定时任务。"""
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name='jituan.tasks.scan_fund_freeze_due')
|
||||
def scan_fund_freeze_due(limit: int = 200):
|
||||
"""扫到期冻结单,按配置执行一步解冻(未开冻结的俱乐部无数据,空跑)。"""
|
||||
from jituan.services.fund_freeze import scan_due_freeze_ledgers
|
||||
result = scan_due_freeze_ledgers(limit=limit)
|
||||
logger.info('scan_fund_freeze_due %s', result)
|
||||
return result
|
||||
@@ -101,6 +101,12 @@ from jituan.views_recharge_page_copy import (
|
||||
RechargePageCopyView,
|
||||
)
|
||||
from jituan.views_order_grab_share import ClubOrderGrabLinkManageView
|
||||
from jituan.views_fund_freeze import (
|
||||
FundFreezeConfigView,
|
||||
FundFreezeLedgerView,
|
||||
FundFreezeRiskView,
|
||||
)
|
||||
from jituan.views_role_agreement import RoleAgreementAdminView
|
||||
|
||||
urlpatterns = [
|
||||
path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'),
|
||||
@@ -112,6 +118,10 @@ urlpatterns = [
|
||||
path('auth/dashou-register', ClubDashouRegisterView.as_view(), name='jituan_dashou_register'),
|
||||
path('houtai/club-manage', ClubManageView.as_view(), name='jituan_club_manage'),
|
||||
path('houtai/order-grab-link', ClubOrderGrabLinkManageView.as_view(), name='jituan_order_grab_link'),
|
||||
path('houtai/fund-freeze-config', FundFreezeConfigView.as_view(), name='jituan_fund_freeze_config'),
|
||||
path('houtai/fund-freeze-ledger', FundFreezeLedgerView.as_view(), name='jituan_fund_freeze_ledger'),
|
||||
path('houtai/fund-freeze-risk', FundFreezeRiskView.as_view(), name='jituan_fund_freeze_risk'),
|
||||
path('houtai/role-agreement', RoleAgreementAdminView.as_view(), name='jituan_role_agreement'),
|
||||
path('houtai/payment-channel-manage', ClubPaymentChannelManageView.as_view(), name='jituan_payment_channel_manage'),
|
||||
path('houtai/club-cert-upload', ClubCertUploadView.as_view(), name='jituan_club_cert_upload'),
|
||||
path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'),
|
||||
|
||||
105
jituan/views_fund_freeze.py
Normal file
105
jituan/views_fund_freeze.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""客服后台资金冻结 API。"""
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from backend.utils import verify_kefu_permission
|
||||
from jituan.services import fund_freeze_admin as admin_svc
|
||||
|
||||
PERM_CFG = 'djcfg001'
|
||||
PERM_LED = 'djled001'
|
||||
PERM_RISK = 'djrisk001'
|
||||
|
||||
|
||||
def _auth(request, perm_code: str):
|
||||
username = (request.data.get('username') or request.data.get('phone') or '').strip()
|
||||
if not username:
|
||||
return None, None, Response({'code': 400, 'msg': '缺少username'})
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return None, None, permissions
|
||||
codes = set(permissions or [])
|
||||
user = getattr(request, 'user', None)
|
||||
if getattr(user, 'IsSuperuser', False) or '000001' in codes:
|
||||
return kefu, permissions, None
|
||||
if perm_code not in codes:
|
||||
return None, None, Response({'code': 403, 'msg': f'无权限(需要 {perm_code})'})
|
||||
return kefu, permissions, None
|
||||
|
||||
|
||||
class FundFreezeConfigView(APIView):
|
||||
"""
|
||||
POST /jituan/houtai/fund-freeze-config
|
||||
action: get | save
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
kefu, permissions, err = _auth(request, PERM_CFG)
|
||||
if err:
|
||||
return err
|
||||
action = (request.data.get('action') or 'get').strip().lower()
|
||||
if action == 'get':
|
||||
return Response({'code': 0, 'msg': 'ok', 'data': admin_svc.build_config_payload(request)})
|
||||
if action == 'save':
|
||||
operator = getattr(getattr(request, 'user', None), 'UserUID', '') or ''
|
||||
data, msg = admin_svc.save_config(request, request.data, operator=operator)
|
||||
if msg:
|
||||
return Response({'code': 400, 'msg': msg})
|
||||
return Response({'code': 0, 'msg': '已保存', 'data': data})
|
||||
return Response({'code': 400, 'msg': '无效 action'})
|
||||
|
||||
|
||||
class FundFreezeLedgerView(APIView):
|
||||
"""
|
||||
POST /jituan/houtai/fund-freeze-ledger
|
||||
action: list | events | unfreeze
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
kefu, permissions, err = _auth(request, PERM_LED)
|
||||
if err:
|
||||
return err
|
||||
action = (request.data.get('action') or 'list').strip().lower()
|
||||
if action == 'list':
|
||||
return Response({'code': 0, 'msg': 'ok', 'data': admin_svc.list_ledgers(request, request.data)})
|
||||
if action == 'events':
|
||||
from jituan.services.fund_freeze_admin import _club
|
||||
try:
|
||||
lid = int(request.data.get('ledger_id') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': 'ledger_id 无效'})
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': 'ok',
|
||||
'data': {'list': admin_svc.list_unfreeze_events(lid, _club(request))},
|
||||
})
|
||||
if action == 'unfreeze':
|
||||
operator = getattr(getattr(request, 'user', None), 'UserUID', '') or ''
|
||||
data, msg = admin_svc.manual_unfreeze(request, request.data, operator=operator)
|
||||
if msg:
|
||||
return Response({'code': 400, 'msg': msg})
|
||||
return Response({'code': 0, 'msg': '解冻成功', 'data': data})
|
||||
return Response({'code': 400, 'msg': '无效 action'})
|
||||
|
||||
|
||||
class FundFreezeRiskView(APIView):
|
||||
"""
|
||||
POST /jituan/houtai/fund-freeze-risk
|
||||
action: list
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
kefu, permissions, err = _auth(request, PERM_RISK)
|
||||
if err:
|
||||
return err
|
||||
action = (request.data.get('action') or 'list').strip().lower()
|
||||
if action == 'list':
|
||||
return Response({'code': 0, 'msg': 'ok', 'data': admin_svc.list_risk_users(request, request.data)})
|
||||
return Response({'code': 400, 'msg': '无效 action'})
|
||||
70
jituan/views_role_agreement.py
Normal file
70
jituan/views_role_agreement.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""客服后台:角色协议配置。"""
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from backend.utils import verify_kefu_permission
|
||||
from jituan.services import role_agreement as svc
|
||||
from jituan.services.club_context import resolve_club_id_from_request
|
||||
|
||||
PERM_CODE = 'xycfg001'
|
||||
|
||||
|
||||
def _auth(request, perm_code: str = PERM_CODE):
|
||||
username = (request.data.get('username') or request.data.get('phone') or '').strip()
|
||||
if not username:
|
||||
return None, None, Response({'code': 400, 'msg': '缺少username'})
|
||||
kefu, permissions = verify_kefu_permission(request, username)
|
||||
if kefu is None:
|
||||
return None, None, permissions
|
||||
codes = set(permissions or [])
|
||||
user = getattr(request, 'user', None)
|
||||
if getattr(user, 'IsSuperuser', False) or '000001' in codes:
|
||||
return kefu, permissions, None
|
||||
if perm_code not in codes:
|
||||
return None, None, Response({'code': 403, 'msg': f'无权限(需要 {perm_code})'})
|
||||
return kefu, permissions, None
|
||||
|
||||
|
||||
class RoleAgreementAdminView(APIView):
|
||||
"""
|
||||
POST /jituan/houtai/role-agreement
|
||||
action: get | save | publish | upload
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser, MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
kefu, permissions, err = _auth(request)
|
||||
if err:
|
||||
return err
|
||||
action = (request.data.get('action') or 'get').strip().lower()
|
||||
operator = getattr(getattr(request, 'user', None), 'UserUID', '') or ''
|
||||
|
||||
if action == 'get':
|
||||
return Response({'code': 0, 'msg': 'ok', 'data': svc.build_admin_payload(request)})
|
||||
|
||||
if action == 'save':
|
||||
data, msg = svc.save_agreement(request, request.data, operator=operator)
|
||||
if msg:
|
||||
return Response({'code': 400, 'msg': msg})
|
||||
return Response({'code': 0, 'msg': '已保存', 'data': data})
|
||||
|
||||
if action == 'publish':
|
||||
data, msg = svc.publish_agreement(request, request.data, operator=operator)
|
||||
if msg:
|
||||
return Response({'code': 400, 'msg': msg})
|
||||
return Response({'code': 0, 'msg': '已发布新版本', 'data': data})
|
||||
|
||||
if action == 'upload':
|
||||
f = request.FILES.get('file')
|
||||
if not f:
|
||||
return Response({'code': 400, 'msg': '缺少 file'})
|
||||
club_id = resolve_club_id_from_request(request) or ''
|
||||
path, msg = svc.upload_agreement_image(f, club_id, kind='content')
|
||||
if msg:
|
||||
return Response({'code': 400, 'msg': msg})
|
||||
return Response({'code': 0, 'msg': '上传成功', 'data': {'url': path, 'path': path}})
|
||||
|
||||
return Response({'code': 400, 'msg': '无效 action'})
|
||||
@@ -70,13 +70,12 @@ def process_expired_order(self, dingdan_id):
|
||||
# 获取打手扩展表
|
||||
dashou_profile = getattr(dashou_user, 'DashouProfile', None)
|
||||
if dashou_profile:
|
||||
# 使用F表达式原子更新
|
||||
UserDashou.query.filter(pk=dashou_profile.pk).update(
|
||||
chengjiaozongliang=F('chengjiaozongliang') + 1,
|
||||
yue=F('yue') + dashou_fencheng,
|
||||
zonge=F('zonge') + dashou_fencheng,
|
||||
jinrishouyi=F('jinrishouyi') + dashou_fencheng,
|
||||
jinyueshouyi=F('jinyueshouyi') + dashou_fencheng
|
||||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||||
credit_dashou_order_settle(
|
||||
order=order,
|
||||
dashou_profile=dashou_profile,
|
||||
user_id=dashou_id,
|
||||
amount=dashou_fencheng,
|
||||
)
|
||||
updated_dashou = True
|
||||
logger.info(f"更新打手{dashou_id}信息成功")
|
||||
|
||||
@@ -613,23 +613,13 @@ class AdQiangZhiJieDan(APIView):
|
||||
# 获取打手扩展表
|
||||
dashou_profile = dashou_user.DashouProfile
|
||||
|
||||
# 更新打手扩展表数据
|
||||
# ✅ 确认字段:chengjiaozongliang 成交单总量
|
||||
dashou_profile.chengjiaozongliang += 1
|
||||
|
||||
# ✅ 确认字段:yue 可提现余额
|
||||
dashou_profile.yue += dashou_fencheng
|
||||
|
||||
# ✅ 确认字段:zonge 打单赚取总额
|
||||
dashou_profile.zonge += dashou_fencheng
|
||||
|
||||
# ✅ 确认字段:jinrishouyi 今日收益金额
|
||||
dashou_profile.jinrishouyi += dashou_fencheng
|
||||
|
||||
# ✅ 确认字段:jinyueshouyi 今月收益金额
|
||||
dashou_profile.jinyueshouyi += dashou_fencheng
|
||||
|
||||
dashou_profile.save()
|
||||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||||
credit_dashou_order_settle(
|
||||
order=dingdan_obj,
|
||||
dashou_profile=dashou_profile,
|
||||
user_id=jiedan_dashou_id,
|
||||
amount=dashou_fencheng,
|
||||
)
|
||||
logger.info(f"强制结单成功,打手{jiedan_dashou_id}结算分成:{dashou_fencheng}")
|
||||
|
||||
except User.DoesNotExist:
|
||||
@@ -789,23 +779,13 @@ class AdJuJueTuiKuan(APIView):
|
||||
# 获取打手扩展表
|
||||
dashou_profile = dashou_user.DashouProfile
|
||||
|
||||
# 更新打手扩展表数据
|
||||
# ✅ 确认字段:chengjiaozongliang 成交单总量
|
||||
dashou_profile.chengjiaozongliang += 1
|
||||
|
||||
# ✅ 确认字段:yue 可提现余额
|
||||
dashou_profile.yue += dashou_fencheng
|
||||
|
||||
# ✅ 确认字段:zonge 打单赚取总额
|
||||
dashou_profile.zonge += dashou_fencheng
|
||||
|
||||
# ✅ 确认字段:jinrishouyi 今日收益金额
|
||||
dashou_profile.jinrishouyi += dashou_fencheng
|
||||
|
||||
# ✅ 确认字段:jinyueshouyi 今月收益金额
|
||||
dashou_profile.jinyueshouyi += dashou_fencheng
|
||||
|
||||
dashou_profile.save()
|
||||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||||
credit_dashou_order_settle(
|
||||
order=dingdan_obj,
|
||||
dashou_profile=dashou_profile,
|
||||
user_id=jiedan_dashou_id,
|
||||
amount=dashou_fencheng,
|
||||
)
|
||||
logger.info(f"拒绝退款成功,打手{jiedan_dashou_id}结算分成:{dashou_fencheng}")
|
||||
|
||||
except User.DoesNotExist:
|
||||
|
||||
@@ -432,21 +432,14 @@ class JiedanView(APIView):
|
||||
dashou_kuozhan = None
|
||||
|
||||
if dashou_kuozhan:
|
||||
# 更新四个金额字段
|
||||
dashou_kuozhan.zonge = (dashou_kuozhan.zonge or Decimal('0.00')) + dashou_fencheng
|
||||
dashou_kuozhan.yue = (dashou_kuozhan.yue or Decimal('0.00')) + dashou_fencheng
|
||||
dashou_kuozhan.jinrishouyi = (dashou_kuozhan.jinrishouyi or Decimal(
|
||||
'0.00')) + dashou_fencheng
|
||||
dashou_kuozhan.jinyueshouyi = (dashou_kuozhan.jinyueshouyi or Decimal(
|
||||
'0.00')) + dashou_fencheng
|
||||
|
||||
# 成交总量加1
|
||||
dashou_kuozhan.chengjiaozongliang = (dashou_kuozhan.chengjiaozongliang or 0) + 1
|
||||
|
||||
# 状态改为数字1
|
||||
dashou_kuozhan.zhuangtai = 1
|
||||
|
||||
dashou_kuozhan.save()
|
||||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||||
credit_dashou_order_settle(
|
||||
order=dingdan,
|
||||
dashou_profile=dashou_kuozhan,
|
||||
user_id=jiedan_dashou_id,
|
||||
amount=dashou_fencheng,
|
||||
set_idle_status=True,
|
||||
)
|
||||
|
||||
# 8. 更新订单状态为已完成(状态3)
|
||||
dingdan.Status = 3
|
||||
@@ -749,13 +742,14 @@ class JiedanView2(APIView):
|
||||
if dashou_user:
|
||||
try:
|
||||
dashou_ext = dashou_user.DashouProfile
|
||||
dashou_ext.zonge = (dashou_ext.zonge or Decimal('0.00')) + dashou_fencheng
|
||||
dashou_ext.yue = (dashou_ext.yue or Decimal('0.00')) + dashou_fencheng
|
||||
dashou_ext.jinrishouyi = (dashou_ext.jinrishouyi or Decimal('0.00')) + dashou_fencheng
|
||||
dashou_ext.jinyueshouyi = (dashou_ext.jinyueshouyi or Decimal('0.00')) + dashou_fencheng
|
||||
dashou_ext.chengjiaozongliang = (dashou_ext.chengjiaozongliang or 0) + 1
|
||||
dashou_ext.zhuangtai = 1
|
||||
dashou_ext.save()
|
||||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||||
credit_dashou_order_settle(
|
||||
order=order,
|
||||
dashou_profile=dashou_ext,
|
||||
user_id=jiedan_dashou_id,
|
||||
amount=dashou_fencheng,
|
||||
set_idle_status=True,
|
||||
)
|
||||
except UserDashou.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
@@ -880,13 +880,13 @@ class ShangjiaJiesuanView(APIView):
|
||||
'data': None
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 原子更新打手数据(使用 F 表达式)
|
||||
UserDashou.query.filter(id=dashou.id).update(
|
||||
chengjiaozongliang=F('chengjiaozongliang') + 1,
|
||||
yue=F('yue') + dashou_fencheng,
|
||||
zonge=F('zonge') + dashou_fencheng,
|
||||
jinrishouyi=F('jinrishouyi') + dashou_fencheng,
|
||||
jinyueshouyi=F('jinyueshouyi') + dashou_fencheng,
|
||||
# 原子更新打手数据(可提现走冻结网关;未开启时与原先全额进 yue 一致)
|
||||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||||
credit_dashou_order_settle(
|
||||
order=order,
|
||||
dashou_profile=dashou,
|
||||
user_id=jiedan_dashou_id,
|
||||
amount=dashou_fencheng,
|
||||
)
|
||||
|
||||
# 更新商家成交单量
|
||||
|
||||
@@ -379,13 +379,14 @@ class ShopForceCompleteView(APIView):
|
||||
try:
|
||||
dashou_user = User.query.select_related('DashouProfile').get(UserUID=dashou_id)
|
||||
dashou_profile = dashou_user.DashouProfile
|
||||
dashou_profile.chengjiaozongliang += 1
|
||||
dashou_profile.yue += dashou_fencheng
|
||||
dashou_profile.zonge += dashou_fencheng
|
||||
dashou_profile.jinrishouyi += dashou_fencheng
|
||||
dashou_profile.jinyueshouyi += dashou_fencheng
|
||||
dashou_profile.zhuangtai = 1 # 空闲
|
||||
dashou_profile.save()
|
||||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||||
credit_dashou_order_settle(
|
||||
order=order,
|
||||
dashou_profile=dashou_profile,
|
||||
user_id=dashou_id,
|
||||
amount=dashou_fencheng,
|
||||
set_idle_status=True,
|
||||
)
|
||||
except (User.DoesNotExist, ObjectDoesNotExist):
|
||||
logger.warning(f"本地订单结算:打手 {dashou_id} 不存在,跳过打手更新")
|
||||
|
||||
|
||||
133
users/migrations/0007_role_fund_freeze_fields.py
Normal file
133
users/migrations/0007_role_fund_freeze_fields.py
Normal file
@@ -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='风险快照时间'),
|
||||
),
|
||||
]
|
||||
@@ -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='更新时间')
|
||||
|
||||
|
||||
@@ -337,6 +337,16 @@ def validate_withdraw_eligibility(user_main, leixing, jine):
|
||||
if not fadan_ok:
|
||||
return False, fadan_msg, {}
|
||||
|
||||
# 投诉未结清 / 管事被诉牵连上级组长(仅俱乐部开关打开时)
|
||||
try:
|
||||
from gongdan.services import check_complaint_blocks_withdraw
|
||||
gd_ok, gd_msg, gd_extra = check_complaint_blocks_withdraw(user_main)
|
||||
if not gd_ok:
|
||||
return False, gd_msg, gd_extra or {}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error('投诉提现拦截检查异常: %s', e, exc_info=True)
|
||||
|
||||
if leixing == 1:
|
||||
try:
|
||||
dashou = UserDashou.query.get(user=user_main)
|
||||
|
||||
@@ -17,7 +17,9 @@ from .views import WechatMiniProgramLoginView, UserInfoUpdateView, DashouXinxiAP
|
||||
AdKfxgView,GuanshiRegisterView, WechatLoginAndGuanshiRegisterView,ZuzhangZhuceView, ZuzhangXinxiView,KefuForceCompleteView,HuoQuYaoQingRenView, DashouJianquanView, LaobanJianquanView,\
|
||||
FaKuanJiFenTongJiView, DaShouFaKuanLieBiaoView, ShangjiaCufaTongjiView, ShangjiaFakuanLieBiaoView,\
|
||||
FaKuanShenSuView, FaKuanPayView,FaKuanHuitiaoView, FaKuanPayPollView, FaKuanPayFailView,KaohePayView,KaohePayCallbackView, KaohePayPollView,KaohePayFailView, TixianAssetView, GuanshiContactView,TixianQueRenAutoView, TixianShenqingV3View, TixianCallbackV3View, \
|
||||
GetUserPhoneView, BindUserPhoneView, BossSearchDashouView
|
||||
GetUserPhoneView, BindUserPhoneView, BossSearchDashouView, \
|
||||
FundFreezeMineView, FundFreezeMyLedgerView, FundFreezeMyLedgerDetailView, \
|
||||
RoleAgreementCurrentView, RoleAgreementMineView, RoleAgreementSignView
|
||||
from .tixian_shenhe_views import TixianZddkshApplyView
|
||||
from .paihang_views import PhbHqsjView
|
||||
from .xzj_paihang_views import XzjPhbHqsjView
|
||||
@@ -164,6 +166,12 @@ urlpatterns = [
|
||||
path('khzfsb', KaohePayFailView.as_view(), name='考核支付失败'),
|
||||
|
||||
path('tixianzichan', TixianAssetView.as_view(), name='小程序提现数据获取'),
|
||||
path('fund-freeze-mine', FundFreezeMineView.as_view(), name='小程序冻结资金汇总'),
|
||||
path('fund-freeze-ledger', FundFreezeMyLedgerView.as_view(), name='小程序冻结资金明细'),
|
||||
path('fund-freeze-ledger-detail', FundFreezeMyLedgerDetailView.as_view(), name='小程序冻结单详情'),
|
||||
path('role-agreement/current', RoleAgreementCurrentView.as_view(), name='小程序角色协议当前'),
|
||||
path('role-agreement/mine', RoleAgreementMineView.as_view(), name='小程序角色协议列表'),
|
||||
path('role-agreement/sign', RoleAgreementSignView.as_view(), name='小程序角色协议签署'),
|
||||
path('guanshi_contact', GuanshiContactView.as_view(), name='获取组长'),
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,18 @@ from .tixian import (
|
||||
generate_tixian_id,
|
||||
)
|
||||
|
||||
from .fund_freeze_mp import (
|
||||
FundFreezeMineView,
|
||||
FundFreezeMyLedgerDetailView,
|
||||
FundFreezeMyLedgerView,
|
||||
)
|
||||
|
||||
from .role_agreement_mp import (
|
||||
RoleAgreementCurrentView,
|
||||
RoleAgreementMineView,
|
||||
RoleAgreementSignView,
|
||||
)
|
||||
|
||||
from .admin import (
|
||||
AdGetOrderList,
|
||||
AdGetOrderTypes,
|
||||
|
||||
@@ -1869,6 +1869,7 @@ class ZuzhangZhuceView(APIView):
|
||||
guanshi_count = UserGuanshi.query.filter(yaoqingren=user.UserUID).count()
|
||||
return {
|
||||
'ketixian': str(zuzhang.ketixian_jine), # 可提现余额
|
||||
'dongjie_yue': str(getattr(zuzhang, 'dongjie_yue', 0) or 0),
|
||||
'guanshiCount': guanshi_count, # 邀请管事数
|
||||
'fenhongZonge': str(zuzhang.fenyong_zonge) # 分红总额
|
||||
}
|
||||
@@ -1894,6 +1895,7 @@ class ZuzhangXinxiView(APIView):
|
||||
guanshi_count = UserGuanshi.query.filter(yaoqingren=user.UserUID).count()
|
||||
data = {
|
||||
'ketixian': str(zuzhang.ketixian_jine),
|
||||
'dongjie_yue': str(getattr(zuzhang, 'dongjie_yue', 0) or 0),
|
||||
'guanshiCount': guanshi_count,
|
||||
'fenhongZonge': str(zuzhang.fenyong_zonge)
|
||||
}
|
||||
|
||||
294
users/views/fund_freeze_mp.py
Normal file
294
users/views/fund_freeze_mp.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""小程序资金冻结只读接口。
|
||||
|
||||
仅返回当前登录用户自己的冻结汇总/明细,不改余额、不触发解冻。
|
||||
字段均为增量返回,旧前端忽略即可。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
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,
|
||||
ROLE_GUANSHI,
|
||||
ROLE_SHENHEGUAN,
|
||||
ROLE_ZUZHANG,
|
||||
)
|
||||
|
||||
ROLE_LABEL = {
|
||||
ROLE_DASHOU: '接单员',
|
||||
ROLE_GUANSHI: '管事',
|
||||
ROLE_ZUZHANG: '组长',
|
||||
ROLE_SHENHEGUAN: '考核官',
|
||||
}
|
||||
|
||||
STATUS_LABEL = {
|
||||
FundFreezeLedger.STATUS_FROZEN: '冻结中',
|
||||
FundFreezeLedger.STATUS_PARTIAL: '部分解冻',
|
||||
FundFreezeLedger.STATUS_DONE: '已解冻',
|
||||
FundFreezeLedger.STATUS_VOID: '已作废',
|
||||
}
|
||||
|
||||
SOURCE_LABEL = {
|
||||
'order_settle': '订单结算',
|
||||
'fenhong': '分红入账',
|
||||
'kaohe_fee': '考核费用',
|
||||
}
|
||||
|
||||
|
||||
def _money(v) -> str:
|
||||
try:
|
||||
return str(Decimal(str(v or 0)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
|
||||
except Exception:
|
||||
return '0.00'
|
||||
|
||||
|
||||
def _profile_dongjie(user, attr: str) -> str:
|
||||
try:
|
||||
profile = getattr(user, attr)
|
||||
return _money(getattr(profile, 'dongjie_yue', 0))
|
||||
except (ObjectDoesNotExist, AttributeError):
|
||||
return '0.00'
|
||||
|
||||
|
||||
def _profile_withdrawable(user, attr: str, field: str = 'yue') -> str:
|
||||
try:
|
||||
profile = getattr(user, attr)
|
||||
return _money(getattr(profile, field, 0))
|
||||
except (ObjectDoesNotExist, AttributeError):
|
||||
return '0.00'
|
||||
|
||||
|
||||
def _default_freeze_reason() -> str:
|
||||
return (
|
||||
'部分佣金暂存为冻结保障金,用于订单售后与纠纷保障;'
|
||||
'按俱乐部规则到期或达标后自动转入可提现余额,冻结中金额不可提现。'
|
||||
)
|
||||
|
||||
|
||||
def _freeze_display_reasons(club_id: str) -> Dict[str, str]:
|
||||
"""按角色返回冻结说明;无配置时用统一默认文案。"""
|
||||
default = _default_freeze_reason()
|
||||
out = {
|
||||
ROLE_DASHOU: default,
|
||||
ROLE_GUANSHI: default,
|
||||
ROLE_ZUZHANG: default,
|
||||
ROLE_SHENHEGUAN: default,
|
||||
}
|
||||
if not club_id:
|
||||
return out
|
||||
try:
|
||||
for row in ClubFundFreezeConfig.query.filter(club_id=club_id):
|
||||
if row.role in out and (row.remark or '').strip():
|
||||
out[row.role] = row.remark.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
class FundFreezeMineView(APIView):
|
||||
"""
|
||||
POST /yonghu/fund-freeze-mine
|
||||
当前用户冻结汇总(可提现 + 冻结中,按角色)。
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
user = request.user
|
||||
club_id = get_user_club_id(user) or ''
|
||||
roles: List[Dict[str, Any]] = [
|
||||
{
|
||||
'role': ROLE_DASHOU,
|
||||
'role_name': ROLE_LABEL[ROLE_DASHOU],
|
||||
'withdrawable': _profile_withdrawable(user, 'DashouProfile', 'yue'),
|
||||
'dongjie_yue': _profile_dongjie(user, 'DashouProfile'),
|
||||
},
|
||||
{
|
||||
'role': ROLE_GUANSHI,
|
||||
'role_name': ROLE_LABEL[ROLE_GUANSHI],
|
||||
'withdrawable': _profile_withdrawable(user, 'GuanshiProfile', 'yue'),
|
||||
'dongjie_yue': _profile_dongjie(user, 'GuanshiProfile'),
|
||||
},
|
||||
{
|
||||
'role': ROLE_ZUZHANG,
|
||||
'role_name': ROLE_LABEL[ROLE_ZUZHANG],
|
||||
'withdrawable': _profile_withdrawable(user, 'ZuzhangProfile', 'ketixian_jine'),
|
||||
'dongjie_yue': _profile_dongjie(user, 'ZuzhangProfile'),
|
||||
},
|
||||
{
|
||||
'role': ROLE_SHENHEGUAN,
|
||||
'role_name': ROLE_LABEL[ROLE_SHENHEGUAN],
|
||||
'withdrawable': _profile_withdrawable(user, 'ShenheguanProfile', 'yue'),
|
||||
'dongjie_yue': _profile_dongjie(user, 'ShenheguanProfile'),
|
||||
},
|
||||
]
|
||||
# 只展示有身份余额或冻结的角色,避免空壳干扰
|
||||
visible = [
|
||||
r for r in roles
|
||||
if Decimal(r['withdrawable']) > 0 or Decimal(r['dongjie_yue']) > 0
|
||||
]
|
||||
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'))
|
||||
|
||||
reasons = _freeze_display_reasons(club_id)
|
||||
display_reason = reasons.get(ROLE_DASHOU) or _default_freeze_reason()
|
||||
|
||||
return Response({
|
||||
'code': 200,
|
||||
'msg': '获取成功',
|
||||
'data': {
|
||||
'club_id': club_id,
|
||||
'roles': visible or roles[:1],
|
||||
'all_roles': roles,
|
||||
'total_dongjie': _money(total_dongjie),
|
||||
'total_withdrawable': _money(total_withdrawable),
|
||||
'has_freeze': total_dongjie > 0,
|
||||
'display_reason': display_reason,
|
||||
'freeze_display_reasons': reasons,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class FundFreezeMyLedgerView(APIView):
|
||||
"""
|
||||
POST /yonghu/fund-freeze-ledger
|
||||
当前用户冻结明细(只读分页)。
|
||||
body: page, page_size, role?, status?
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
user = request.user
|
||||
uid = user.UserUID
|
||||
club_id = get_user_club_id(user) or ''
|
||||
data = request.data if isinstance(request.data, dict) else {}
|
||||
page = max(1, int(data.get('page') or 1))
|
||||
page_size = min(50, max(1, int(data.get('page_size') or data.get('limit') or 20)))
|
||||
role = (data.get('role') or '').strip().lower()
|
||||
status = data.get('status')
|
||||
|
||||
qs = FundFreezeLedger.query.filter(user_id=uid)
|
||||
if club_id:
|
||||
qs = qs.filter(club_id=club_id)
|
||||
if role:
|
||||
qs = qs.filter(role=role)
|
||||
if status not in (None, ''):
|
||||
try:
|
||||
qs = qs.filter(status=int(status))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
total = qs.count()
|
||||
rows = qs.order_by('-id')[(page - 1) * page_size: page * page_size]
|
||||
items = []
|
||||
for r in rows:
|
||||
remain = Decimal(str(r.amount_total or 0)) - Decimal(str(r.amount_unfrozen or 0))
|
||||
items.append({
|
||||
'id': r.id,
|
||||
'role': r.role,
|
||||
'role_name': ROLE_LABEL.get(r.role, r.role),
|
||||
'amount_total': _money(r.amount_total),
|
||||
'amount_unfrozen': _money(r.amount_unfrozen),
|
||||
'amount_remain': _money(remain),
|
||||
'status': r.status,
|
||||
'status_label': STATUS_LABEL.get(r.status, str(r.status)),
|
||||
'frozen_at': r.frozen_at.strftime('%Y-%m-%d %H:%M') if r.frozen_at else '',
|
||||
'next_eligible_at': (
|
||||
r.next_eligible_at.strftime('%Y-%m-%d %H:%M') if r.next_eligible_at else ''
|
||||
),
|
||||
'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 (
|
||||
(r.config_snapshot or {}).get('display_reason')
|
||||
or (r.config_snapshot or {}).get('remark')
|
||||
or ''
|
||||
),
|
||||
'current_step': r.current_step,
|
||||
})
|
||||
|
||||
return Response({
|
||||
'code': 200,
|
||||
'msg': '获取成功',
|
||||
'data': {
|
||||
'club_id': club_id,
|
||||
'list': items,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'has_more': page * page_size < total,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
class FundFreezeMyLedgerDetailView(APIView):
|
||||
"""
|
||||
POST /yonghu/fund-freeze-ledger-detail
|
||||
单笔冻结单 + 解冻事件(仅本人)。
|
||||
body: ledger_id
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
user = request.user
|
||||
data = request.data if isinstance(request.data, dict) else {}
|
||||
try:
|
||||
ledger_id = int(data.get('ledger_id') or data.get('id') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'code': 400, 'msg': 'ledger_id 无效', 'data': None}, status=400)
|
||||
if ledger_id <= 0:
|
||||
return Response({'code': 400, 'msg': '缺少 ledger_id', 'data': None}, status=400)
|
||||
|
||||
ledger = FundFreezeLedger.query.filter(id=ledger_id, user_id=user.UserUID).first()
|
||||
if not ledger:
|
||||
return Response({'code': 404, 'msg': '记录不存在', 'data': None}, status=404)
|
||||
|
||||
remain = Decimal(str(ledger.amount_total or 0)) - Decimal(str(ledger.amount_unfrozen or 0))
|
||||
events = FundUnfreezeEvent.query.filter(
|
||||
ledger_id=ledger.id,
|
||||
).order_by('step_seq', 'id')[:50]
|
||||
event_list = [{
|
||||
'id': e.id,
|
||||
'step_seq': e.step_seq,
|
||||
'amount': _money(e.amount),
|
||||
'unfrozen_at': e.unfrozen_at.strftime('%Y-%m-%d %H:%M') if e.unfrozen_at else '',
|
||||
'trigger': e.trigger,
|
||||
'reason': e.reason or '',
|
||||
} for e in events]
|
||||
|
||||
return Response({
|
||||
'code': 200,
|
||||
'msg': '获取成功',
|
||||
'data': {
|
||||
'id': ledger.id,
|
||||
'role': ledger.role,
|
||||
'role_name': ROLE_LABEL.get(ledger.role, ledger.role),
|
||||
'amount_total': _money(ledger.amount_total),
|
||||
'amount_unfrozen': _money(ledger.amount_unfrozen),
|
||||
'amount_remain': _money(remain),
|
||||
'status': ledger.status,
|
||||
'status_label': STATUS_LABEL.get(ledger.status, str(ledger.status)),
|
||||
'frozen_at': ledger.frozen_at.strftime('%Y-%m-%d %H:%M') if ledger.frozen_at else '',
|
||||
'next_eligible_at': (
|
||||
ledger.next_eligible_at.strftime('%Y-%m-%d %H:%M')
|
||||
if ledger.next_eligible_at else ''
|
||||
),
|
||||
'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 (
|
||||
(ledger.config_snapshot or {}).get('display_reason')
|
||||
or (ledger.config_snapshot or {}).get('remark')
|
||||
or ''
|
||||
),
|
||||
'current_step': ledger.current_step,
|
||||
'events': event_list,
|
||||
},
|
||||
})
|
||||
@@ -1020,13 +1020,13 @@ class KefuRejectRefundView(APIView):
|
||||
try:
|
||||
dashou_user = User.query.get(UserUID=jiedan_dashou_id)
|
||||
dashou_profile = dashou_user.DashouProfile
|
||||
# 增加打手相关统计数据
|
||||
dashou_profile.chengjiaozongliang += 1
|
||||
dashou_profile.yue += dashou_fencheng
|
||||
dashou_profile.zonge += dashou_fencheng
|
||||
dashou_profile.jinrishouyi += dashou_fencheng
|
||||
dashou_profile.jinyueshouyi += dashou_fencheng
|
||||
dashou_profile.save()
|
||||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||||
credit_dashou_order_settle(
|
||||
order=order,
|
||||
dashou_profile=dashou_profile,
|
||||
user_id=jiedan_dashou_id,
|
||||
amount=dashou_fencheng,
|
||||
)
|
||||
logger.info(f"拒绝退款:打手 {jiedan_dashou_id} 获得分成 {dashou_fencheng}")
|
||||
except (User.DoesNotExist, AttributeError):
|
||||
logger.warning(f"拒绝退款:打手 {jiedan_dashou_id} 不存在或扩展表缺失,跳过结算")
|
||||
@@ -1739,16 +1739,13 @@ class KefuForceCompleteView(APIView):
|
||||
try:
|
||||
dashou_user = User.query.get(UserUID=dashou_id)
|
||||
dashou_profile = dashou_user.DashouProfile
|
||||
# 原子更新(避免并发重复加)
|
||||
dashou_profile.chengjiaozongliang = F('chengjiaozongliang') + 1
|
||||
dashou_profile.yue = F('yue') + dashou_fencheng
|
||||
dashou_profile.zonge = F('zonge') + dashou_fencheng
|
||||
dashou_profile.jinrishouyi = F('jinrishouyi') + dashou_fencheng
|
||||
dashou_profile.jinyueshouyi = F('jinyueshouyi') + dashou_fencheng
|
||||
dashou_profile.save(update_fields=[
|
||||
'chengjiaozongliang', 'yue', 'zonge',
|
||||
'jinrishouyi', 'jinyueshouyi'
|
||||
])
|
||||
from jituan.services.fund_freeze import credit_dashou_order_settle
|
||||
credit_dashou_order_settle(
|
||||
order=order,
|
||||
dashou_profile=dashou_profile,
|
||||
user_id=dashou_id,
|
||||
amount=dashou_fencheng,
|
||||
)
|
||||
except (User.DoesNotExist, AttributeError):
|
||||
return Response({'code': 400, 'msg': '打手不存在或扩展表缺失'})
|
||||
|
||||
|
||||
85
users/views/role_agreement_mp.py
Normal file
85
users/views/role_agreement_mp.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""小程序角色协议只读/签署(勾选确认,无手写)。"""
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from jituan.services.club_user import get_user_club_id
|
||||
from jituan.services import role_agreement as svc
|
||||
|
||||
|
||||
class RoleAgreementCurrentView(APIView):
|
||||
"""
|
||||
POST /yonghu/role-agreement/current
|
||||
body: { role, gate?, view? }
|
||||
gate: qiangdan|register|tixian|invite — 按卡点判断是否需签
|
||||
view: 1/true — 只读查看,不强制签署
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
role = (request.data.get('role') or '').strip().lower()
|
||||
if not role:
|
||||
return Response({'code': 400, 'msg': '缺少 role', 'data': None})
|
||||
gate = (request.data.get('gate') or '').strip().lower()
|
||||
view_raw = request.data.get('view') or request.data.get('view_only')
|
||||
view_only = view_raw in (True, 'true', '1', 1, 'True')
|
||||
club_id = get_user_club_id(request.user) or ''
|
||||
data = svc.get_current_for_user(
|
||||
club_id, request.user.UserUID, role, gate=gate, view_only=view_only,
|
||||
)
|
||||
return Response({'code': 200, 'msg': 'ok', 'data': data})
|
||||
|
||||
|
||||
class RoleAgreementMineView(APIView):
|
||||
"""
|
||||
POST /yonghu/role-agreement/mine
|
||||
body: { roles?: ['dashou','guanshi'] } — 我的协议列表(按俱乐部)
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
roles = request.data.get('roles')
|
||||
if isinstance(roles, str):
|
||||
roles = [x.strip() for x in roles.split(',') if x.strip()]
|
||||
if not isinstance(roles, list):
|
||||
roles = None
|
||||
club_id = get_user_club_id(request.user) or ''
|
||||
data = svc.list_for_user(club_id, request.user.UserUID, roles=roles)
|
||||
return Response({'code': 200, 'msg': 'ok', 'data': data})
|
||||
|
||||
|
||||
class RoleAgreementSignView(APIView):
|
||||
"""
|
||||
POST /yonghu/role-agreement/sign
|
||||
JSON: { role, agreed, client_meta? }
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||
|
||||
def post(self, request):
|
||||
role = (request.data.get('role') or '').strip().lower()
|
||||
agreed_raw = request.data.get('agreed')
|
||||
agreed = agreed_raw in (True, 'true', '1', 1, 'True')
|
||||
club_id = get_user_club_id(request.user) or ''
|
||||
|
||||
meta = request.data.get('client_meta')
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
import json
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {'raw': meta}
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
|
||||
data, msg = svc.sign_agreement(
|
||||
club_id,
|
||||
request.user.UserUID,
|
||||
role,
|
||||
client_meta=meta,
|
||||
agreed=agreed,
|
||||
)
|
||||
if msg:
|
||||
return Response({'code': 400, 'msg': msg, 'data': None})
|
||||
return Response({'code': 200, 'msg': '签署成功', 'data': data})
|
||||
@@ -402,8 +402,15 @@ class TixianShenqingView(APIView):
|
||||
|
||||
ok, elig_msg, extra = validate_withdraw_eligibility(user_main, leixing, jine)
|
||||
if not ok:
|
||||
return Response({'code': 8, 'msg': elig_msg},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
body = {'code': 8, 'msg': elig_msg}
|
||||
if (extra or {}).get('code') == 'COMPLAINT_BLOCK_WITHDRAW':
|
||||
body['data'] = {
|
||||
'tab': extra.get('tab') or 'received',
|
||||
'reason': extra.get('reason') or '',
|
||||
'count': extra.get('count') or 0,
|
||||
'code': 'COMPLAINT_BLOCK_WITHDRAW',
|
||||
}
|
||||
return Response(body, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 3. 根据提现类型扣款(资格已在 validate_withdraw_eligibility 校验)
|
||||
nicheng = extra.get('nicheng', '')
|
||||
@@ -1050,6 +1057,7 @@ class TixianAssetView(APIView):
|
||||
'DashouProfile', 'ShopProfile', 'GuanshiProfile', 'ZuzhangProfile', 'ShenheguanProfile'
|
||||
).get(UserUID=request.user.UserUID)
|
||||
# 初始化返回数据,默认全部为0
|
||||
# 冻结字段为增量兼容字段:旧前端忽略;提现仍只读 *_yue / *_fenyong
|
||||
data = {
|
||||
'dashou_yue': '0.00',
|
||||
'dashou_yajin': '0.00',
|
||||
@@ -1057,6 +1065,10 @@ class TixianAssetView(APIView):
|
||||
'guanshi_fenyong': '0.00',
|
||||
'zuzhang_fenyong': '0.00',
|
||||
'kaoheguan_fenyong': '0.00',
|
||||
'dashou_dongjie_yue': '0.00',
|
||||
'guanshi_dongjie_yue': '0.00',
|
||||
'zuzhang_dongjie_yue': '0.00',
|
||||
'kaoheguan_dongjie_yue': '0.00',
|
||||
'dashou_rate': 0.00,
|
||||
'dashou_yajin_rate': 0.00,
|
||||
'shangjia_rate': 0.00,
|
||||
@@ -1070,6 +1082,7 @@ class TixianAssetView(APIView):
|
||||
dashou = user.DashouProfile
|
||||
data['dashou_yue'] = str(dashou.yue)
|
||||
data['dashou_yajin'] = str(dashou.yajin)
|
||||
data['dashou_dongjie_yue'] = str(getattr(dashou, 'dongjie_yue', 0) or 0)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
@@ -1084,6 +1097,7 @@ class TixianAssetView(APIView):
|
||||
try:
|
||||
guanshi = user.GuanshiProfile
|
||||
data['guanshi_fenyong'] = str(guanshi.yue)
|
||||
data['guanshi_dongjie_yue'] = str(getattr(guanshi, 'dongjie_yue', 0) or 0)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
@@ -1091,6 +1105,7 @@ class TixianAssetView(APIView):
|
||||
try:
|
||||
zuzhang = user.ZuzhangProfile
|
||||
data['zuzhang_fenyong'] = str(zuzhang.ketixian_jine)
|
||||
data['zuzhang_dongjie_yue'] = str(getattr(zuzhang, 'dongjie_yue', 0) or 0)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
@@ -1098,15 +1113,21 @@ class TixianAssetView(APIView):
|
||||
try:
|
||||
kaoheguan = user.ShenheguanProfile
|
||||
data['kaoheguan_fenyong'] = str(kaoheguan.yue)
|
||||
data['kaoheguan_dongjie_yue'] = str(getattr(kaoheguan, 'dongjie_yue', 0) or 0)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
from jituan.services.club_user import get_user_club_id
|
||||
from jituan.services.withdraw_config import build_tixian_asset_rates
|
||||
from users.views.fund_freeze_mp import _freeze_display_reasons, _default_freeze_reason
|
||||
|
||||
club_id = get_user_club_id(user)
|
||||
data.update(build_tixian_asset_rates(club_id))
|
||||
|
||||
reasons = _freeze_display_reasons(club_id or '')
|
||||
data['freeze_display_reasons'] = reasons
|
||||
data['freeze_display_reason'] = reasons.get('dashou') or _default_freeze_reason()
|
||||
|
||||
return Response({
|
||||
'code': 200,
|
||||
'msg': '获取成功',
|
||||
|
||||
@@ -406,6 +406,7 @@ class DashouXinxiAPIView(APIView):
|
||||
'zaixianzhuangtai': dashou_profile.zaixianzhuangtai,
|
||||
'dashouzhuangtai': dashou_profile.zhuangtai,
|
||||
'yongjin': str(dashou_profile.yue) if dashou_profile.yue is not None else '0.00',
|
||||
'dongjie_yue': str(getattr(dashou_profile, 'dongjie_yue', 0) or 0),
|
||||
'zonge': str(dashou_profile.zonge) if dashou_profile.zonge is not None else '0.00',
|
||||
'yajin': str(dashou_profile.yajin) if dashou_profile.yajin is not None else '0.00',
|
||||
'jiedanZongliang':dashou_profile.jiedanzongliang if dashou_profile.jiedanzongliang is not None else '0',
|
||||
@@ -667,6 +668,7 @@ class GuanshiXinxiView(APIView):
|
||||
'yaoqingzongshu': guanshi_profile.yaogingshuliang, # 邀请打手总数
|
||||
'fenyongzonge': float(guanshi_profile.chongzhifenrun), # 充值分佣总额
|
||||
'fenyongtixian': float(guanshi_profile.yue), # 可提现余额
|
||||
'dongjie_yue': float(getattr(guanshi_profile, 'dongjie_yue', 0) or 0),
|
||||
'yichongzhiDashou': yichongzhi_count, # 已充值打手数量
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user