fix: 迁移积分/分红次数/冻结入账,并修正无计划误导文案
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -41,6 +41,21 @@ OPEN_ORDER_STATUSES = (1, 2, 4, 7, 8, 9)
|
||||
# 未缴清罚单:待缴纳/申诉中/平台审核中
|
||||
UNPAID_PENALTY_STATUSES = (1, 3, 5)
|
||||
|
||||
# 目标店新建打手时绑定的默认管事邀请码(与小程序 PLATFORM 默认码一致)
|
||||
CLUB_MIGRATE_DEFAULT_GUANSHI_INVITE = {
|
||||
'lxs': '0000008VfJKPu4Spmcjt',
|
||||
'xq': '0000008RffVgKHMj7kQC',
|
||||
}
|
||||
|
||||
# 可提现类字段:迁入走目标店冻结网关(押金/积分/已冻结池除外)
|
||||
_FREEZE_CREDIT_FIELDS = frozenset({
|
||||
('dashou', 'yue'),
|
||||
('guanshi', 'yue'),
|
||||
('zuzhang', 'ketixian_jine'),
|
||||
('shenheguan', 'yue'),
|
||||
})
|
||||
MEMBER_JIFEN_CAP = 10
|
||||
|
||||
|
||||
def _money(v) -> Decimal:
|
||||
return Decimal(str(v or 0)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||
@@ -129,8 +144,25 @@ def _load_user(uid: str):
|
||||
return User.query.filter(UserUID=str(uid)).first()
|
||||
|
||||
|
||||
def _get_or_create_profile(user, role: str):
|
||||
"""目标店无角色时创建空扩展行(仅迁移模块调用)。"""
|
||||
def _resolve_default_guanshi_uid(club_id: str) -> Optional[str]:
|
||||
"""按俱乐部硬编码邀请码解析默认管事 UID;解析失败返回 None。"""
|
||||
code = CLUB_MIGRATE_DEFAULT_GUANSHI_INVITE.get((club_id or '').strip())
|
||||
if not code:
|
||||
return None
|
||||
from users.models import UserGuanshi
|
||||
gs = UserGuanshi.query.filter(yaoqingma=code).select_related('user').first()
|
||||
if not gs or not getattr(gs, 'user', None):
|
||||
logger.warning('migrate default guanshi invite not found club=%s code=%s', club_id, code)
|
||||
return None
|
||||
return str(gs.user.UserUID)
|
||||
|
||||
|
||||
def _get_or_create_profile(user, role: str, *, to_club_id: str = ''):
|
||||
"""目标店无角色时创建空扩展行(仅迁移模块调用)。
|
||||
|
||||
新建打手:绑定目标店默认管事(源店邀请人在新店无效)。
|
||||
已有打手资料:不改 yaoqingren(曾在新店注册过的情况保留原绑定)。
|
||||
"""
|
||||
from users.models import UserDashou, UserShangjia, UserGuanshi, UserZuzhang, UserShenheguan
|
||||
|
||||
mapping = {
|
||||
@@ -150,6 +182,9 @@ def _get_or_create_profile(user, role: str):
|
||||
defaults['nicheng'] = f'SJ{user.UserUID}'
|
||||
elif role == 'dashou':
|
||||
defaults['nicheng'] = f'DS{user.UserUID}'
|
||||
gs_uid = _resolve_default_guanshi_uid(to_club_id)
|
||||
if gs_uid:
|
||||
defaults['yaoqingren'] = gs_uid
|
||||
obj = model.query.create(**defaults)
|
||||
return obj
|
||||
|
||||
@@ -425,6 +460,15 @@ def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dic
|
||||
if tid != r.huiyuan_id:
|
||||
chy = Huiyuan.query.filter(huiyuan_id=tid).first()
|
||||
child_name = (chy.jieshao if chy else '') or tid
|
||||
# 正式会员迁入:继承源店正式购买次数,且至少 1(再充不可能算首次分红)
|
||||
formal_floor = 0
|
||||
if not bool(r.is_trial):
|
||||
from jituan.services.member_recharge import count_formal_paid_member_orders
|
||||
src_paid = count_formal_paid_member_orders(
|
||||
from_uid, r.huiyuan_id, from_club,
|
||||
)
|
||||
formal_floor = max(int(src_paid or 0), 1)
|
||||
|
||||
exp, before_dst = _add_huiyuan_duration(
|
||||
to_uid=to_uid,
|
||||
to_club=to_club,
|
||||
@@ -450,6 +494,8 @@ def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dic
|
||||
'src_huiyuan_id': r.huiyuan_id,
|
||||
'name': child_name,
|
||||
'mapped_from_bundle': tid != r.huiyuan_id,
|
||||
'formal_purchase_floor': formal_floor,
|
||||
'is_trial': bool(r.is_trial),
|
||||
},
|
||||
})
|
||||
return out
|
||||
@@ -510,6 +556,7 @@ def gate_for_user(*, club_id: str, user_uid: str, roles: Optional[List[str]] = N
|
||||
"""小程序启动/进打手端调用。无计划 → 全 false,现网无感。"""
|
||||
plan = find_active_plan_for_source(club_id)
|
||||
if not plan:
|
||||
target = find_active_plan_for_target(club_id)
|
||||
return {
|
||||
'active': False,
|
||||
'force': False,
|
||||
@@ -517,6 +564,8 @@ def gate_for_user(*, club_id: str, user_uid: str, roles: Optional[List[str]] = N
|
||||
'completed': False,
|
||||
'plan_id': '',
|
||||
'to_club_id': '',
|
||||
'is_target': bool(target),
|
||||
'target_from_club_id': (target.from_club_id if target else ''),
|
||||
}
|
||||
done = not can_migrate_again(plan, user_uid)
|
||||
force_roles = plan.force_roles or ['dashou']
|
||||
@@ -536,6 +585,7 @@ def gate_for_user(*, club_id: str, user_uid: str, roles: Optional[List[str]] = N
|
||||
'force_roles': force_roles,
|
||||
'success_count': success_count(plan.plan_id, user_uid),
|
||||
'max_allowed': max_allowed_success(plan, user_uid),
|
||||
'is_target': False,
|
||||
}
|
||||
|
||||
|
||||
@@ -662,10 +712,11 @@ def confirm_migrate(
|
||||
# 目标店:白名单涉及角色先建空扩展行
|
||||
for role in roles_from_whitelist(keys):
|
||||
if _profile_of(to_user, role) is None:
|
||||
_get_or_create_profile(to_user, role)
|
||||
_get_or_create_profile(to_user, role, to_club_id=plan.to_club_id)
|
||||
|
||||
items_payload = []
|
||||
migrate_seq = int(cons.success_count or 0) + 1
|
||||
freeze_biz_prefix = f'cm:{token_row.token}:{migrate_seq}'
|
||||
|
||||
for key in keys:
|
||||
meta = ASSET_CATALOG[key]
|
||||
@@ -689,6 +740,8 @@ def confirm_migrate(
|
||||
kind=meta['kind'],
|
||||
key=key,
|
||||
label=meta['label'],
|
||||
to_club_id=plan.to_club_id,
|
||||
freeze_biz_id=f'{freeze_biz_prefix}:{key}'[:64],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -735,7 +788,18 @@ def confirm_migrate(
|
||||
return serialize_ledger(ledger), ''
|
||||
|
||||
|
||||
def _transfer_balance_field(*, from_user, to_user, role, field, kind, key, label) -> dict:
|
||||
def _transfer_balance_field(
|
||||
*,
|
||||
from_user,
|
||||
to_user,
|
||||
role,
|
||||
field,
|
||||
kind,
|
||||
key,
|
||||
label,
|
||||
to_club_id: str = '',
|
||||
freeze_biz_id: str = '',
|
||||
) -> dict:
|
||||
src_prof = _lock_profile(from_user, role)
|
||||
amount = _read_field(src_prof, field, kind)
|
||||
before_src = amount
|
||||
@@ -750,17 +814,53 @@ def _transfer_balance_field(*, from_user, to_user, role, field, kind, key, label
|
||||
dst_prof = _lock_profile(to_user, role)
|
||||
created = False
|
||||
if dst_prof is None:
|
||||
dst_prof = _get_or_create_profile(to_user, role)
|
||||
dst_prof = _get_or_create_profile(to_user, role, to_club_id=to_club_id)
|
||||
created = True
|
||||
dst_prof = _lock_profile(to_user, role)
|
||||
|
||||
before_dst = _read_field(dst_prof, field, kind) if dst_prof else (0 if kind == 'int' else Decimal('0.00'))
|
||||
after_dst = before_dst
|
||||
meta_extra = {}
|
||||
|
||||
if dst_prof is not None and amount:
|
||||
if kind == 'int':
|
||||
# 积分:目标已满 10 保持 10;相加后也不得超过 10
|
||||
if field == 'jifen' and kind == 'int':
|
||||
if int(before_dst) >= MEMBER_JIFEN_CAP:
|
||||
after_dst = MEMBER_JIFEN_CAP
|
||||
else:
|
||||
after_dst = min(MEMBER_JIFEN_CAP, int(before_dst) + int(amount))
|
||||
type(dst_prof).objects.filter(pk=dst_prof.pk).update(jifen=int(after_dst))
|
||||
meta_extra['jifen_capped'] = True
|
||||
meta_extra['jifen_cap'] = MEMBER_JIFEN_CAP
|
||||
elif (role, field) in _FREEZE_CREDIT_FIELDS:
|
||||
# 可提现入账:走目标店冻结网关(开了冻结则按配置拆分)
|
||||
from jituan.services.fund_freeze import credit_role_balance, SOURCE_CLUB_MIGRATE
|
||||
to_uid = str(getattr(to_user, 'UserUID', '') or '')
|
||||
credit_res = credit_role_balance(
|
||||
club_id=to_club_id,
|
||||
user_id=to_uid,
|
||||
role=role,
|
||||
amount=_money(amount),
|
||||
source_type=SOURCE_CLUB_MIGRATE,
|
||||
biz_id=(freeze_biz_id or f'cm:{to_uid}:{key}')[:64],
|
||||
profile=dst_prof,
|
||||
freeze_reason='跨店资产迁移入账',
|
||||
ignore_source_scope=True,
|
||||
)
|
||||
dst_prof = _lock_profile(to_user, role)
|
||||
after_dst = _read_field(dst_prof, field, kind) if dst_prof else before_dst
|
||||
meta_extra.update({
|
||||
'freeze_available': str(credit_res.get('available') or 0),
|
||||
'freeze_frozen': str(credit_res.get('frozen') or 0),
|
||||
'freeze_ledger_id': credit_res.get('ledger_id'),
|
||||
'freeze_skipped': bool(credit_res.get('skipped_freeze')),
|
||||
'freeze_skip_reason': credit_res.get('skip_reason'),
|
||||
})
|
||||
elif kind == 'int':
|
||||
type(dst_prof).objects.filter(pk=dst_prof.pk).update(**{field: F(field) + int(amount)})
|
||||
after_dst = int(before_dst) + int(amount)
|
||||
else:
|
||||
# 押金 / 已冻结池等:原样累加,不再二次冻结
|
||||
type(dst_prof).objects.filter(pk=dst_prof.pk).update(**{field: F(field) + _money(amount)})
|
||||
after_dst = _money(before_dst) + _money(amount)
|
||||
|
||||
@@ -773,7 +873,7 @@ def _transfer_balance_field(*, from_user, to_user, role, field, kind, key, label
|
||||
'after_src': after_src if amount else before_src,
|
||||
'before_dst': before_dst,
|
||||
'after_dst': after_dst,
|
||||
'meta_json': {'key': key, 'label': label, 'dst_created': created},
|
||||
'meta_json': {'key': key, 'label': label, 'dst_created': created, **meta_extra},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ SOURCE_ORDER_SETTLE = 'order_settle'
|
||||
SOURCE_FENHONG = 'fenhong'
|
||||
SOURCE_KAOHE_FEE = 'kaohe_fee'
|
||||
SOURCE_WITHDRAW_REJECT = 'withdraw_reject'
|
||||
SOURCE_CLUB_MIGRATE = 'club_migrate'
|
||||
|
||||
_CFG_CACHE_TTL = 60
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ def save_config(request, data: dict, operator: str = '') -> Tuple[Optional[dict]
|
||||
source_scope = data.get('source_scope')
|
||||
if source_scope is None:
|
||||
# 默认同时覆盖订单结算与分红(管事/组长主要靠分红入账)
|
||||
source_scope = ['order_settle', 'fenhong', 'withdraw_reject']
|
||||
source_scope = ['order_settle', 'fenhong', 'withdraw_reject', 'club_migrate']
|
||||
if isinstance(source_scope, str):
|
||||
try:
|
||||
source_scope = json.loads(source_scope)
|
||||
|
||||
@@ -138,13 +138,58 @@ def has_paid_trial_purchase(yonghuid, huiyuan_id, club_id, exclude_dingdan_id=No
|
||||
return False
|
||||
|
||||
|
||||
def count_formal_paid_member_orders(yonghuid, huiyuan_id, club_id, exclude_dingdan_id=None):
|
||||
"""本俱乐部该会员已成功支付的正式单数(不含体验)。"""
|
||||
club_id = club_id or CLUB_ID_DEFAULT
|
||||
n = 0
|
||||
for order in _iter_paid_member_orders(yonghuid, huiyuan_id, club_id, exclude_dingdan_id):
|
||||
if not _order_is_trial_like(order, club_id):
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def migrate_formal_purchase_floor(yonghuid, huiyuan_id, club_id):
|
||||
"""
|
||||
跨店迁入该会员时写入的正式购买次数下限。
|
||||
保证迁入后再充至少按第 2 次起算(不可能走首次管事分红),
|
||||
并能尽量继承源店正式购买次数。
|
||||
"""
|
||||
club_id = club_id or CLUB_ID_DEFAULT
|
||||
from jituan.models import ClubMigrateLedger, ClubMigrateLedgerItem
|
||||
|
||||
ledger_ids = list(
|
||||
ClubMigrateLedger.query.filter(
|
||||
to_uid=str(yonghuid),
|
||||
to_club_id=str(club_id),
|
||||
status=ClubMigrateLedger.STATUS_SUCCESS,
|
||||
).values_list('id', flat=True)
|
||||
)
|
||||
if not ledger_ids:
|
||||
return 0
|
||||
floor = 0
|
||||
for it in ClubMigrateLedgerItem.query.filter(
|
||||
ledger_id__in=ledger_ids,
|
||||
field='huiyuan',
|
||||
huiyuan_id=str(huiyuan_id),
|
||||
item_type=ClubMigrateLedgerItem.ITEM_HUIYUAN,
|
||||
):
|
||||
meta = it.meta_json or {}
|
||||
try:
|
||||
v = int(meta.get('formal_purchase_floor') or 0)
|
||||
except (TypeError, ValueError):
|
||||
v = 0
|
||||
if v > floor:
|
||||
floor = v
|
||||
return floor
|
||||
|
||||
|
||||
def has_formal_member_purchase(yonghuid, huiyuan_id, club_id, exclude_dingdan_id=None):
|
||||
"""是否已有成功支付的正式会员单(用于禁止再买体验)。"""
|
||||
club_id = club_id or CLUB_ID_DEFAULT
|
||||
for order in _iter_paid_member_orders(yonghuid, huiyuan_id, club_id, exclude_dingdan_id):
|
||||
if not _order_is_trial_like(order, club_id):
|
||||
if count_formal_paid_member_orders(yonghuid, huiyuan_id, club_id, exclude_dingdan_id) > 0:
|
||||
return True
|
||||
return False
|
||||
# 跨店迁入正式会员视同已购正式(不可再买体验)
|
||||
return migrate_formal_purchase_floor(yonghuid, huiyuan_id, club_id) >= 1
|
||||
|
||||
|
||||
def has_any_prior_paid_member_recharge(yonghuid, exclude_dingdan_id=None):
|
||||
@@ -218,13 +263,14 @@ def resolve_formal_purchase_cishu(yonghuid, huiyuan_id, current_dingdan_id, club
|
||||
"""
|
||||
打手第几次正式购买该会员(duoci_fenhong.cishu)。
|
||||
体验单(含历史脏数据)不计入。
|
||||
跨店迁入会员:按 formal_purchase_floor 抬高下限,迁入后再充不可能算首次。
|
||||
"""
|
||||
club_id = club_id or CLUB_ID_DEFAULT
|
||||
prior_paid = 0
|
||||
for order in _iter_paid_member_orders(yonghuid, huiyuan_id, club_id, current_dingdan_id):
|
||||
if not _order_is_trial_like(order, club_id):
|
||||
prior_paid += 1
|
||||
return prior_paid + 1
|
||||
prior_paid = count_formal_paid_member_orders(
|
||||
yonghuid, huiyuan_id, club_id, current_dingdan_id,
|
||||
)
|
||||
floor = migrate_formal_purchase_floor(yonghuid, huiyuan_id, club_id)
|
||||
return max(prior_paid, floor) + 1
|
||||
|
||||
|
||||
def _truncate_shuoming(text, max_len=100):
|
||||
|
||||
@@ -38,6 +38,34 @@ def _user_club(user) -> str:
|
||||
return str(getattr(user, 'ClubID', '') or '').strip()
|
||||
|
||||
|
||||
def _resolve_club_id(request) -> str:
|
||||
"""小程序 club 优先:body.club_id → X-Club-Id → 用户 ClubID。"""
|
||||
club_id = str(request.data.get('club_id') or '').strip()
|
||||
if not club_id:
|
||||
club_id = (
|
||||
request.META.get('HTTP_X_CLUB_ID')
|
||||
or request.headers.get('X-Club-Id')
|
||||
or ''
|
||||
).strip()
|
||||
if not club_id:
|
||||
club_id = _user_club(request.user)
|
||||
return club_id
|
||||
|
||||
|
||||
def _no_source_plan_response(club_id: str):
|
||||
"""无源店计划时给可区分文案(避免前端笼统「暂无迁移计划」)。"""
|
||||
cid = (club_id or '').strip() or '(空)'
|
||||
target = migrate_svc.find_active_plan_for_target(club_id) if club_id else None
|
||||
if target:
|
||||
msg = (
|
||||
f'本店({cid})是迁入目标店,请到源店小程序'
|
||||
f'({target.from_club_id})完成迁出'
|
||||
)
|
||||
else:
|
||||
msg = f'当前俱乐部({cid})无有效迁出计划,请确认后台源店/启用/时间窗'
|
||||
return Response({'code': 400, 'msg': msg, 'data': {'club_id': cid, 'is_target': bool(target)}})
|
||||
|
||||
|
||||
def _user_roles(user) -> list:
|
||||
roles = []
|
||||
for role, attr in (
|
||||
@@ -76,16 +104,13 @@ class ClubMigrateGateView(APIView):
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
club_id = (request.data.get('club_id') or '').strip()
|
||||
if not club_id:
|
||||
club_id = (request.META.get('HTTP_X_CLUB_ID') or request.headers.get('X-Club-Id') or '').strip()
|
||||
if not club_id:
|
||||
club_id = _user_club(request.user)
|
||||
club_id = _resolve_club_id(request)
|
||||
data = migrate_svc.gate_for_user(
|
||||
club_id=club_id,
|
||||
user_uid=str(request.user.UserUID),
|
||||
roles=_user_roles(request.user),
|
||||
)
|
||||
data['resolved_club_id'] = club_id or ''
|
||||
return Response({'code': 0, 'msg': 'ok', 'data': data})
|
||||
|
||||
|
||||
@@ -95,27 +120,29 @@ class ClubMigratePreviewView(APIView):
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
club_id = (request.data.get('club_id') or '').strip()
|
||||
if not club_id:
|
||||
club_id = (request.META.get('HTTP_X_CLUB_ID') or request.headers.get('X-Club-Id') or '').strip()
|
||||
if not club_id:
|
||||
club_id = _user_club(request.user)
|
||||
club_id = _resolve_club_id(request)
|
||||
plan = migrate_svc.find_active_plan_for_source(club_id)
|
||||
if not plan:
|
||||
return Response({'code': 400, 'msg': '当前俱乐部无有效迁移计划'})
|
||||
return _no_source_plan_response(club_id)
|
||||
uid = str(request.user.UserUID)
|
||||
try:
|
||||
ok, msg = migrate_svc.check_conditions(plan, uid)
|
||||
assets = migrate_svc.preview_assets(plan, uid)
|
||||
except Exception as e:
|
||||
logger.exception('migrate preview failed club=%s uid=%s: %s', club_id, uid, e)
|
||||
return Response({'code': 500, 'msg': f'预览加载失败:{e}'})
|
||||
return Response({
|
||||
'code': 0,
|
||||
'msg': 'ok',
|
||||
'data': {
|
||||
'plan': migrate_svc.serialize_plan(plan),
|
||||
'assets': migrate_svc.preview_assets(plan, uid),
|
||||
'assets': assets,
|
||||
'condition_ok': ok,
|
||||
'condition_msg': msg,
|
||||
'can_migrate': migrate_svc.can_migrate_again(plan, uid),
|
||||
'success_count': migrate_svc.success_count(plan.plan_id, uid),
|
||||
'max_allowed': migrate_svc.max_allowed_success(plan, uid),
|
||||
'resolved_club_id': club_id or '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -126,18 +153,14 @@ class ClubMigrateSetPasswordView(APIView):
|
||||
parser_classes = [JSONParser]
|
||||
|
||||
def post(self, request):
|
||||
club_id = (request.data.get('club_id') or '').strip()
|
||||
if not club_id:
|
||||
club_id = (request.META.get('HTTP_X_CLUB_ID') or request.headers.get('X-Club-Id') or '').strip()
|
||||
if not club_id:
|
||||
club_id = _user_club(request.user)
|
||||
club_id = _resolve_club_id(request)
|
||||
password = str(request.data.get('password') or '').strip()
|
||||
password2 = str(request.data.get('password2') or '').strip()
|
||||
if password != password2:
|
||||
return Response({'code': 400, 'msg': '两次密码不一致'})
|
||||
plan = migrate_svc.find_active_plan_for_source(club_id)
|
||||
if not plan:
|
||||
return Response({'code': 400, 'msg': '当前俱乐部无有效迁移计划'})
|
||||
return _no_source_plan_response(club_id)
|
||||
|
||||
token_row, err = migrate_svc.set_password_and_issue(
|
||||
plan=plan,
|
||||
@@ -171,11 +194,11 @@ class ClubMigrateIssueQrView(APIView):
|
||||
from jituan.models import ClubMigrateToken
|
||||
from jituan.services.club_migrate_qr import generate_migrate_qrcode
|
||||
|
||||
club_id = (request.data.get('club_id') or _user_club(request.user)).strip()
|
||||
club_id = _resolve_club_id(request)
|
||||
uid = str(request.user.UserUID)
|
||||
plan = migrate_svc.find_active_plan_for_source(club_id)
|
||||
if not plan:
|
||||
return Response({'code': 400, 'msg': '当前俱乐部无有效迁移计划'})
|
||||
return _no_source_plan_response(club_id)
|
||||
token_row = (
|
||||
ClubMigrateToken.query.filter(
|
||||
plan_id=plan.plan_id,
|
||||
|
||||
Reference in New Issue
Block a user