"""跨俱乐部资产迁移核心服务（独立模块，未开计划时零影响）。

P0：所有成功过户必须写 Ledger + Item；金额以锁库实读为准；令牌单次消费；占用表防双迁。
"""
from __future__ import annotations

import hashlib
import logging
import secrets
import uuid
from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
from typing import Dict, List, Optional, Tuple

from django.db import transaction
from django.db.models import F, Q
from django.utils import timezone

from jituan.models import (
    ClubMigrateConsumption,
    ClubMigrateLedger,
    ClubMigrateLedgerItem,
    ClubMigratePlan,
    ClubMigrateToken,
    ClubMigrateUserQuota,
)
from jituan.services.club_migrate_assets import (
    ASSET_CATALOG,
    ROLE_PROFILE_ATTR,
    normalize_whitelist,
    roles_from_whitelist,
)

logger = logging.getLogger(__name__)

TOKEN_TTL_HOURS = 24
PASSWORD_MAX_FAIL = 5
SCENE_PREFIX = 'MIG'
# 未完结订单：待抢/进行中/退款审/指定中/结算中/未支付
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)


def _now():
    return timezone.now()


def _hash_password(password: str, salt: str) -> str:
    raw = f'{salt}:{password}'.encode('utf-8')
    return hashlib.sha256(raw).hexdigest()


def new_plan_id() -> str:
    return 'mp' + uuid.uuid4().hex[:14]


def new_token() -> str:
    # 短令牌便于塞进微信 scene（总长 32）
    return secrets.token_urlsafe(12).replace('-', '').replace('_', '')[:20]


def scene_for_token(token: str) -> str:
    return f'{SCENE_PREFIX}{token}'


def parse_migrate_scene(scene: str) -> str:
    s = str(scene or '').strip()
    if s.upper().startswith(SCENE_PREFIX):
        return s[len(SCENE_PREFIX):]
    return ''


def plan_in_window(plan: ClubMigratePlan, now=None) -> bool:
    now = now or _now()
    if plan.start_at and now < plan.start_at:
        return False
    if plan.end_at and now > plan.end_at:
        return False
    return True


def find_active_plan_for_source(from_club_id: str) -> Optional[ClubMigratePlan]:
    cid = (from_club_id or '').strip()
    if not cid:
        return None
    qs = ClubMigratePlan.query.filter(from_club_id=cid, enabled=True).order_by('-id')
    now = _now()
    for p in qs[:20]:
        if plan_in_window(p, now):
            return p
    return None


def find_active_plan_for_target(to_club_id: str) -> Optional[ClubMigratePlan]:
    cid = (to_club_id or '').strip()
    if not cid:
        return None
    qs = ClubMigratePlan.query.filter(to_club_id=cid, enabled=True).order_by('-id')
    now = _now()
    for p in qs[:20]:
        if plan_in_window(p, now):
            return p
    return None


def max_allowed_success(plan: ClubMigratePlan, from_uid: str) -> int:
    base = 1 if plan.default_once_per_user else 999
    q = ClubMigrateUserQuota.query.filter(plan_id=plan.plan_id, from_uid=str(from_uid)).first()
    extra = int(q.max_extra_times or 0) if q else 0
    return base + extra


def success_count(plan_id: str, from_uid: str) -> int:
    row = ClubMigrateConsumption.query.filter(plan_id=plan_id, from_uid=str(from_uid)).first()
    return int(row.success_count or 0) if row else 0


def can_migrate_again(plan: ClubMigratePlan, from_uid: str) -> bool:
    """有剩余次数，或上次未迁干净（源店仍有可迁资产）时可再迁。"""
    if success_count(plan.plan_id, from_uid) < max_allowed_success(plan, from_uid):
        return True
    # 次数已用尽但仍有资产（如会员曾被 skip 留下）→ 允许补迁，不依赖二次名额
    return user_has_migratable_assets(plan, from_uid)


def _load_user(uid: str):
    from users.business_models import User
    return User.query.filter(UserUID=str(uid)).first()


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 = {
        'dashou': (UserDashou, 'DashouProfile'),
        'shangjia': (UserShangjia, 'ShopProfile'),
        'guanshi': (UserGuanshi, 'GuanshiProfile'),
        'zuzhang': (UserZuzhang, 'ZuzhangProfile'),
        'shenheguan': (UserShenheguan, 'ShenheguanProfile'),
    }
    model, attr = mapping[role]
    try:
        return getattr(user, attr)
    except Exception:
        pass
    defaults = {'user': user}
    if role == 'shangjia':
        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


def _profile_of(user, role: str):
    attr = ROLE_PROFILE_ATTR.get(role)
    if not attr:
        return None
    try:
        return getattr(user, attr)
    except Exception:
        return None


def _lock_profile(user, role: str):
    """事务内锁扩展行；无行返回 None。"""
    from users.models import UserDashou, UserShangjia, UserGuanshi, UserZuzhang, UserShenheguan

    mapping = {
        'dashou': UserDashou,
        'shangjia': UserShangjia,
        'guanshi': UserGuanshi,
        'zuzhang': UserZuzhang,
        'shenheguan': UserShenheguan,
    }
    model = mapping.get(role)
    if not model:
        return None
    return model.objects.select_for_update().filter(user=user).first()


def _read_field(profile, field: str, kind: str):
    if profile is None:
        return Decimal('0.00') if kind != 'int' else 0
    val = getattr(profile, field, None)
    if kind == 'int':
        try:
            return int(val or 0)
        except (TypeError, ValueError):
            return 0
    return _money(val)


def preview_assets(plan: ClubMigratePlan, from_uid: str) -> List[dict]:
    keys = normalize_whitelist(plan.asset_whitelist)
    user = _load_user(from_uid)
    if not user:
        return []
    items = []
    for key in keys:
        meta = ASSET_CATALOG[key]
        role, field, kind = meta['role'], meta['field'], meta['kind']
        if kind == 'huiyuan':
            items.extend(_preview_huiyuan(from_uid, plan.from_club_id, role))
            continue
        profile = _profile_of(user, role)
        amount = _read_field(profile, field, kind)
        if kind == 'money':
            display = f'¥{amount}'
        elif kind == 'int':
            display = str(int(amount) if amount is not None else 0)
        else:
            display = str(amount)
        items.append({
            'key': key,
            'role': role,
            'field': field,
            'label': meta['label'],
            'kind': kind,
            'amount': str(amount),
            'display': display,
            'has_profile': profile is not None,
        })
    return items


def asset_item_has_value(item: dict) -> bool:
    """预览项是否有可迁金额/积分/会员。"""
    kind = (item or {}).get('kind') or ''
    if kind == 'huiyuan':
        return True
    try:
        if kind == 'int':
            return int(item.get('amount') or 0) > 0
        return _money(item.get('amount')) > 0
    except (TypeError, ValueError):
        return False


def user_has_migratable_assets(plan: ClubMigratePlan, from_uid: str) -> bool:
    """白名单内是否有任意可迁资产；全无则不应强制进迁移页。"""
    try:
        items = preview_assets(plan, from_uid)
    except Exception as e:
        logger.warning('migrate asset check failed uid=%s: %s', from_uid, e)
        return True  # 检查失败时保守：仍允许进页，避免误放行有资产用户
    return any(asset_item_has_value(it) for it in items)


def _membership_core_key(huiyuan_id: str) -> str:
    from jituan.services.huiyuan_bundle import normalize_huiyuan_id

    hid = normalize_huiyuan_id(huiyuan_id)
    if not hid:
        return ''
    return hid.lstrip('0') or '0'


def _club_row_belongs_to_source(rec_cid: str, from_club: str) -> bool:
    """会员/充值单 club_id 是否归属源店（历史默认 xq/空也算）。"""
    from jituan.constants import CLUB_ID_DEFAULT

    from_club = (from_club or '').strip()
    rec_cid = (rec_cid or '').strip()
    if not rec_cid or rec_cid in (from_club, CLUB_ID_DEFAULT, ''):
        return True
    return bool(from_club) and rec_cid == from_club


def _collect_migratable_huiyuan(uid: str, from_club: str, *, for_update: bool = False) -> List[dict]:
    """
    可迁会员 = 用户端能看到的有效会员（开通表 + 未撤销充值单合成）。
    第一次迁移若只弄坏了开通表、充值单还在，第二次仍能迁。
    """
    from jituan.constants import CLUB_ID_DEFAULT
    from jituan.services.huiyuan_bundle import normalize_huiyuan_id
    from jituan.services.member_recharge import (
        MEMBER_LEIXING,
        MEMBER_PAID_STATUS,
        czjilu_entitlement_active,
        _czjilu_entitlement_end,
        _order_is_trial_like,
    )
    from products.models import Czjilu, Huiyuan, Huiyuangoumai

    now = _now_aware()
    from_club = (from_club or '').strip()
    merged = {}  # core_key -> entry

    def upsert(*, huiyuan_id, daoqi, jieshao='', is_trial=False, has_used_trial=False, goumai_row=None, source='goumai'):
        hid = normalize_huiyuan_id(huiyuan_id)
        key = _membership_core_key(hid)
        exp = _aware(daoqi)
        if not key or not exp or exp <= now:
            return
        cur = merged.get(key)
        if cur and _aware(cur['daoqi_time']) and _aware(cur['daoqi_time']) >= exp:
            # 已有更晚到期；仍补挂开通行便于失效
            if goumai_row is not None and cur.get('goumai_row') is None:
                cur['goumai_row'] = goumai_row
            return
        merged[key] = {
            'huiyuan_id': hid,
            'daoqi_time': exp,
            'jieshao': (jieshao or '').strip(),
            'is_trial': bool(is_trial),
            'has_used_trial': bool(has_used_trial),
            'goumai_row': goumai_row,
            'source': source,
            'record_club_id': (getattr(goumai_row, 'club_id', None) or '') if goumai_row else '',
        }

    qs = Huiyuangoumai.objects.filter(yonghu_id=str(uid))
    if for_update:
        qs = qs.select_for_update()
    for r in qs:
        if not _club_row_belongs_to_source(getattr(r, 'club_id', None) or '', from_club):
            continue
        # 开通表有效才直接进；失效的留给充值单补
        if int(r.huiyuan_zhuangtai or 0) != 1:
            continue
        upsert(
            huiyuan_id=r.huiyuan_id,
            daoqi=r.daoqi_time,
            jieshao=r.jieshao or '',
            is_trial=bool(r.is_trial),
            has_used_trial=bool(r.has_used_trial),
            goumai_row=r,
            source='goumai',
        )

    for order in Czjilu.query.filter(
        yonghuid=str(uid),
        leixing=MEMBER_LEIXING,
        zhuangtai=MEMBER_PAID_STATUS,
    ).order_by('-CreateTime')[:50]:
        hid = normalize_huiyuan_id(order.huiyuan_id)
        if not hid:
            continue
        order_cid = (getattr(order, 'club_id', None) or '').strip()
        if not _club_row_belongs_to_source(order_cid, from_club):
            continue
        cid = order_cid or from_club or CLUB_ID_DEFAULT
        if not czjilu_entitlement_active(order, cid):
            continue
        end = _czjilu_entitlement_end(order, cid)
        name = ''
        hy = Huiyuan.query.filter(huiyuan_id=hid).first()
        if hy:
            name = (hy.jieshao or '').strip()
        # 若开通表已有同行但状态失效，挂上便于迁完统一置到期
        goumai = None
        key = _membership_core_key(hid)
        if key not in merged or merged[key].get('goumai_row') is None:
            goumai = _find_user_huiyuangoumai(uid, hid, for_update=for_update)
            if goumai and not _club_row_belongs_to_source(getattr(goumai, 'club_id', None) or '', from_club):
                goumai = None
        upsert(
            huiyuan_id=hid,
            daoqi=end,
            jieshao=name,
            is_trial=bool(_order_is_trial_like(order, cid)),
            has_used_trial=False,
            goumai_row=goumai,
            source='czjilu',
        )

    return list(merged.values())


def _list_active_huiyuan_rows(uid: str, from_club: str, *, for_update: bool = False):
    """兼容旧调用：仅返回开通表有效行。迁移请用 _collect_migratable_huiyuan。"""
    from products.models import Huiyuangoumai

    now = _now_aware()
    from_club = (from_club or '').strip()
    qs = Huiyuangoumai.objects.filter(
        yonghu_id=str(uid),
        huiyuan_zhuangtai=1,
    )
    if for_update:
        qs = qs.select_for_update()
    out = []
    for r in qs:
        exp = _aware(r.daoqi_time)
        if not exp or exp <= now:
            continue
        if not _club_row_belongs_to_source(getattr(r, 'club_id', None) or '', from_club):
            continue
        out.append(r)
    return out


def _preview_huiyuan(uid: str, club_id: str, role: str) -> List[dict]:
    from products.models import Huiyuan

    entries = _collect_migratable_huiyuan(uid, club_id)
    if not entries:
        return []
    ids = [e['huiyuan_id'] for e in entries]
    name_map = {
        h.huiyuan_id: (h.jieshao or '').strip() or '会员'
        for h in Huiyuan.query.filter(huiyuan_id__in=ids)
    }
    out = []
    for e in entries:
        name = e.get('jieshao') or name_map.get(e['huiyuan_id']) or '会员'
        exp = _aware(e.get('daoqi_time'))
        if exp is None:
            exp_text = ''
        elif timezone.is_aware(exp):
            exp_text = timezone.localtime(exp).strftime('%Y-%m-%d %H:%M')
        else:
            exp_text = exp.strftime('%Y-%m-%d %H:%M')
        src_tag = '开通' if e.get('source') == 'goumai' else '充值权益'
        out.append({
            'key': f'{role}.huiyuan:{e["huiyuan_id"]}',
            'role': role,
            'field': 'huiyuan',
            'label': name,
            'kind': 'huiyuan',
            'amount': exp_text,
            'display': f'{name} · 到期 {exp_text}' if exp_text else name,
            'huiyuan_id': e['huiyuan_id'],
            'daoqi_time': exp.isoformat() if exp else '',
            'jieshao': name,
            'has_profile': True,
            'record_club_id': e.get('record_club_id') or '',
            'source': e.get('source') or '',
            'source_tag': src_tag,
        })
    return out


def _resolve_migrate_target_huiyuan_ids(
    *, src_huiyuan_id: str, from_club: str = '', to_club: str = '',
) -> List[str]:
    """
    普通会员：原样迁同 ID。
    总会员：按子会员格式迁出（写入各子会员开通记录），不在目标店再挂一条总会员。
    未配置子项时才退回迁总会员本身，避免丢权益。
    """
    from jituan.services.huiyuan_bundle import (
        is_bundle_huiyuan,
        normalize_huiyuan_id,
        resolve_bundle_included_ids,
    )

    src_id = normalize_huiyuan_id(src_huiyuan_id)
    if not src_id:
        return []
    if not is_bundle_huiyuan(src_id):
        return [src_id]

    kids = []
    seen = set()
    for cid in (from_club, to_club):
        for hid in resolve_bundle_included_ids(
            src_id,
            club_id=(cid or '').strip() or None,
            record_club_id=(from_club or '').strip() or None,
        ) or []:
            kid = normalize_huiyuan_id(hid)
            if not kid:
                continue
            core = kid.lstrip('0') or '0'
            if core in seen:
                continue
            seen.add(core)
            kids.append(kid)
        if kids:
            break
    if kids:
        return kids
    return [src_id]


def _revoke_source_member_czjilu(from_uid: str, huiyuan_id: str):
    """撤销源店充值单权益，避免 Huiyuangoumai 失效后仍被 clumber 从 Czjilu 合成展示。"""
    from jituan.services.huiyuan_bundle import huiyuan_ids_match
    from jituan.services.member_recharge import (
        ENTITLEMENT_REVOKED_MARKER,
        MEMBER_LEIXING,
        MEMBER_PAID_STATUS,
        _czjilu_entitlement_revoked,
        _truncate_shuoming,
    )
    from products.models import Czjilu

    for order in Czjilu.query.filter(
        yonghuid=str(from_uid),
        leixing=MEMBER_LEIXING,
        zhuangtai=MEMBER_PAID_STATUS,
    ):
        if not huiyuan_ids_match(order.huiyuan_id, huiyuan_id):
            continue
        if _czjilu_entitlement_revoked(order):
            continue
        note = (order.shuoming or '').strip()
        order.shuoming = _truncate_shuoming(
            f'{note} |跨店迁移|{ENTITLEMENT_REVOKED_MARKER}|'
            if note else f'跨店迁移|{ENTITLEMENT_REVOKED_MARKER}'
        )
        order.save(update_fields=['shuoming'])


def _expire_source_huiyuan(row, now):
    """源会员置失效。"""
    row.huiyuan_zhuangtai = 0
    row.daoqi_time = now
    row.save(update_fields=['huiyuan_zhuangtai', 'daoqi_time', 'UpdateTime'])
    try:
        _revoke_source_member_czjilu(row.yonghu_id, row.huiyuan_id)
    except Exception as e:
        logger.warning(
            'migrate revoke czjilu fail uid=%s hy=%s: %s',
            row.yonghu_id, row.huiyuan_id, e,
        )


def _expire_source_membership(*, from_uid: str, huiyuan_id: str, goumai_row=None):
    """迁完后清掉源店开通记录 + 充值单合成权益。"""
    now = _now_aware()
    row = goumai_row
    if row is None:
        row = _find_user_huiyuangoumai(from_uid, huiyuan_id, for_update=True)
    if row is not None:
        row.huiyuan_zhuangtai = 0
        row.daoqi_time = now
        row.save(update_fields=['huiyuan_zhuangtai', 'daoqi_time', 'UpdateTime'])
    try:
        _revoke_source_member_czjilu(from_uid, huiyuan_id)
    except Exception as e:
        logger.warning(
            'migrate revoke czjilu fail uid=%s hy=%s: %s',
            from_uid, huiyuan_id, e,
        )


def _find_user_huiyuangoumai(uid: str, huiyuan_id: str, *, for_update: bool = False):
    """按会员 ID 变体查找购买记录（001 / 1 等同）。"""
    from jituan.services.huiyuan_bundle import expand_huiyuan_id_variants
    from products.models import Huiyuangoumai

    variants = expand_huiyuan_id_variants(huiyuan_id) or [str(huiyuan_id)]
    qs = Huiyuangoumai.objects.filter(
        yonghu_id=str(uid),
        huiyuan_id__in=variants,
    )
    if for_update:
        qs = qs.select_for_update()
    return qs.first()


def _latest_effective_cover_until(uid: str, huiyuan_id: str, club_id: str, *, goumai_row=None):
    """
    目标店对该会员类型的有效覆盖到期：
    - 自己开通的该子会员/总会员未到期
    - 或任意未到期总会员包含该子会员
    用于迁移累加：总会员覆盖剩 10 天 + 迁入同子会员 10 天 → 子会员落到 20 天。
    """
    from jituan.services.huiyuan_bundle import (
        _included_huiyuan_id_set,
        _membership_record_active,
        expand_huiyuan_id_variants,
        huiyuan_ids_match,
        is_bundle_huiyuan,
        normalize_huiyuan_id,
        resolve_bundle_included_ids,
    )
    from products.models import Huiyuangoumai

    now = _now_aware()
    hid = normalize_huiyuan_id(huiyuan_id)
    if not hid:
        return None
    req_set = {hid, hid.lstrip('0') or '0'}
    for vid in expand_huiyuan_id_variants(hid):
        req_set.add(vid)
        req_set.add(vid.lstrip('0') or '0')

    latest = None

    def bump(dt):
        nonlocal latest
        exp = _aware(dt)
        if not exp or exp <= now:
            return
        if latest is None or exp > latest:
            latest = exp

    row = goumai_row
    if row is None:
        row = _find_user_huiyuangoumai(uid, hid)
    if row is not None and int(row.huiyuan_zhuangtai or 0) == 1:
        bump(row.daoqi_time)

    for rec in Huiyuangoumai.query.filter(yonghu_id=str(uid)):
        if not _membership_record_active(rec):
            continue
        if huiyuan_ids_match(rec.huiyuan_id, hid):
            bump(rec.daoqi_time)
            continue
        if not is_bundle_huiyuan(rec.huiyuan_id):
            continue
        included = resolve_bundle_included_ids(
            rec.huiyuan_id,
            club_id=club_id,
            yonghu_id=uid,
            record_club_id=getattr(rec, 'club_id', None),
        )
        if req_set & _included_huiyuan_id_set(included):
            bump(rec.daoqi_time)
    return latest


def _add_huiyuan_duration(*, to_uid: str, to_club: str, huiyuan_id: str, remain, jieshao: str, is_trial: bool, has_used_trial: bool):
    """给目标用户对应会员加时长。

    累加基数 = max(现在, 该会员自身到期, 覆盖它的总会员到期)。
    避免：目标已有总会员覆盖子会员 10 天，迁入同子会员 10 天却只得到约 10 天（应为 20）。
    """
    from products.models import Huiyuangoumai

    now = _now_aware()
    remain = remain if remain and remain.total_seconds() > 0 else None
    if remain is None:
        return None, {}

    hid = str(huiyuan_id).strip()
    dst = _find_user_huiyuangoumai(to_uid, hid, for_update=True)
    before_dst = {}
    if dst:
        before_dst = {
            'daoqi_time': dst.daoqi_time.isoformat() if dst.daoqi_time else '',
            'zhuangtai': dst.huiyuan_zhuangtai,
            'club_id': dst.club_id,
        }

    cover = _latest_effective_cover_until(to_uid, hid, to_club, goumai_row=dst)
    base = cover if (cover and cover > now) else now
    exp = base + remain

    if dst:
        dst.daoqi_time = exp
        dst.huiyuan_zhuangtai = 1
        dst.club_id = to_club
        if jieshao:
            dst.jieshao = jieshao
        dst.save(update_fields=['daoqi_time', 'huiyuan_zhuangtai', 'club_id', 'jieshao', 'UpdateTime'])
        return exp, before_dst

    try:
        Huiyuangoumai.query.create(
            huiyuan_id=hid,
            yonghu_id=str(to_uid),
            huiyuan_zhuangtai=1,
            jieshao=jieshao or '',
            daoqi_time=exp,
            club_id=to_club,
            is_trial=bool(is_trial),
            has_used_trial=bool(has_used_trial),
        )
    except Exception:
        # 并发/ID 变体撞唯一约束：再查一次改成累加
        dst2 = _find_user_huiyuangoumai(to_uid, hid, for_update=True)
        if not dst2:
            raise
        before_dst = {
            'daoqi_time': dst2.daoqi_time.isoformat() if dst2.daoqi_time else '',
            'zhuangtai': dst2.huiyuan_zhuangtai,
            'club_id': dst2.club_id,
        }
        cover2 = _latest_effective_cover_until(to_uid, hid, to_club, goumai_row=dst2)
        base2 = cover2 if (cover2 and cover2 > now) else now
        exp = base2 + remain
        dst2.daoqi_time = exp
        dst2.huiyuan_zhuangtai = 1
        dst2.club_id = to_club
        if jieshao:
            dst2.jieshao = jieshao
        dst2.save(update_fields=['daoqi_time', 'huiyuan_zhuangtai', 'club_id', 'jieshao', 'UpdateTime'])
        return exp, before_dst
    return exp, before_dst


def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dict]:
    """
    未到期会员：目标店加同等剩余时长；源店置已到期并撤销充值权益。
    总会员按子会员拆分写入（不用总会员 ID 在目标店再挂一条）。
    """
    from products.models import Huiyuan
    from jituan.services.huiyuan_bundle import is_bundle_huiyuan, normalize_huiyuan_id

    now = _now_aware()
    entries = _collect_migratable_huiyuan(from_uid, from_club, for_update=True)
    out = []
    # 同一迁移内按子会员去重，避免源店「总会员+子会员」重复加时长
    transferred_cores = set()

    for e in entries:
        src_exp = _aware(e.get('daoqi_time'))
        name = (e.get('jieshao') or '').strip()
        if not name:
            hy = Huiyuan.query.filter(huiyuan_id=e['huiyuan_id']).first()
            name = (hy.jieshao if hy else '') or '会员'
        before = {
            'huiyuan_id': e['huiyuan_id'],
            'daoqi_time': src_exp.isoformat() if src_exp else '',
            'zhuangtai': 1,
            'is_trial': bool(e.get('is_trial')),
            'jieshao': name,
            'record_club_id': e.get('record_club_id') or '',
            'source': e.get('source') or '',
        }
        remain = (src_exp - now) if src_exp and src_exp > now else None
        if remain is None:
            continue

        target_ids = _resolve_migrate_target_huiyuan_ids(
            src_huiyuan_id=e['huiyuan_id'],
            from_club=from_club,
            to_club=to_club,
        )
        if not target_ids:
            raise ValueError(f'会员 ID 无效，无法迁移: {e["huiyuan_id"]}')

        formal_floor = 0
        if not bool(e.get('is_trial')):
            from jituan.services.member_recharge import count_formal_paid_member_orders
            src_paid = count_formal_paid_member_orders(
                from_uid, e['huiyuan_id'], from_club,
            )
            formal_floor = max(int(src_paid or 0), 1)

        wrote_any = False
        src_is_bundle = is_bundle_huiyuan(e['huiyuan_id'])
        for tid in target_ids:
            tid = normalize_huiyuan_id(tid) or tid
            core = (tid.lstrip('0') or '0') if tid else ''
            if core and core in transferred_cores:
                continue

            child_name = name
            if src_is_bundle or tid != normalize_huiyuan_id(e['huiyuan_id']):
                chy = Huiyuan.query.filter(huiyuan_id=tid).first()
                if not chy:
                    from jituan.services.huiyuan_bundle import expand_huiyuan_id_variants
                    for vid in expand_huiyuan_id_variants(tid):
                        chy = Huiyuan.query.filter(huiyuan_id=vid).first()
                        if chy:
                            break
                child_name = ((chy.jieshao if chy else '') or '').strip() or tid

            exp, before_dst = _add_huiyuan_duration(
                to_uid=to_uid,
                to_club=to_club,
                huiyuan_id=tid,
                remain=remain,
                jieshao=child_name,
                is_trial=bool(e.get('is_trial')),
                has_used_trial=bool(e.get('has_used_trial')),
            )
            if exp is None:
                raise ValueError(f'会员过户失败（加时长）: {child_name}({tid})')

            if core:
                transferred_cores.add(core)
            wrote_any = True
            out.append({
                'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN,
                'role': role,
                'field': 'huiyuan',
                'amount': Decimal('1'),
                'before_src': before.get('daoqi_time', ''),
                'after_src': 'expired',
                'before_dst': before_dst.get('daoqi_time', ''),
                'after_dst': exp.isoformat() if exp else '',
                'huiyuan_id': tid,
                'meta_json': {
                    'before_src': before,
                    'before_dst': before_dst,
                    'src_huiyuan_id': e['huiyuan_id'],
                    'name': child_name,
                    'mapped_from_bundle': bool(src_is_bundle and tid != normalize_huiyuan_id(e['huiyuan_id'])),
                    'formal_purchase_floor': formal_floor,
                    'is_trial': bool(e.get('is_trial')),
                    'source': e.get('source') or '',
                },
            })

        if not wrote_any:
            raise ValueError(f'会员过户失败: {name}({e["huiyuan_id"]})')

        _expire_source_membership(
            from_uid=from_uid,
            huiyuan_id=e['huiyuan_id'],
            goumai_row=e.get('goumai_row'),
        )
    return out


def _aware(dt):
    """与 timezone.now() 对齐 aware/naive，避免混比炸 500。"""
    if dt is None:
        return None
    now_sample = timezone.now()
    if timezone.is_aware(now_sample):
        if timezone.is_naive(dt):
            return timezone.make_aware(dt, timezone.get_current_timezone())
        return dt
    # 项目未开 USE_TZ：一律 naive
    if timezone.is_aware(dt):
        return timezone.make_naive(dt, timezone.get_current_timezone())
    return dt


def _now_aware():
    """业务比较用的当前时间（与 _aware 同一套时区规则）。"""
    return _aware(_now()) or _now()


def check_conditions(plan: ClubMigratePlan, from_uid: str) -> Tuple[bool, str]:
    """返回 (ok, msg)。"""
    cond = plan.condition_json or {}
    user = _load_user(from_uid)
    if not user:
        return False, '源用户不存在'

    if cond.get('require_not_banned', True):
        ds = _profile_of(user, 'dashou')
        if ds is not None and int(getattr(ds, 'zhanghaozhuangtai', 1) or 1) != 1:
            return False, '账号已封禁，无法迁移'

    if cond.get('require_no_unpaid_penalty'):
        try:
            from orders.models import Penalty
            unpaid = Penalty.query.filter(
                PenalizedUserID=str(from_uid),
                ClubID=plan.from_club_id,
                Status__in=list(UNPAID_PENALTY_STATUSES),
            ).exists()
            if unpaid:
                return False, '存在未缴罚单，请先缴清'
        except Exception as e:
            logger.warning('migrate penalty check failed uid=%s: %s', from_uid, e)
            return False, '罚单门槛校验失败，请稍后重试'

    if cond.get('require_orders_closed'):
        try:
            from orders.models import Order
            open_st = Order.objects.filter(
                PlayerID=str(from_uid),
                ClubID=plan.from_club_id,
                Status__in=list(OPEN_ORDER_STATUSES),
            ).exists()
            if open_st:
                return False, '仍有未完结订单，请先结算或退款完成'
        except Exception as e:
            logger.warning('migrate order check failed uid=%s: %s', from_uid, e)
            return False, '订单门槛校验失败，请稍后重试'

    return True, ''


def gate_for_user(*, club_id: str, user_uid: str, roles: Optional[List[str]] = None) -> dict:
    """小程序启动/进打手端调用。无计划 → 全 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,
            'show_entry': False,
            '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']
    role_hit = True
    if roles:
        role_hit = any(r in force_roles for r in roles)
    has_assets = user_has_migratable_assets(plan, user_uid)
    # 关后台「启用」→ find_active 找不到计划 → 上面已全 false，前端零感知
    # 开「启用」：源店展示入口（与有无资产无关）；「强制拦截」只控制自动跳
    force = bool(plan.force_intercept and role_hit and not done and has_assets)
    show_entry = True
    return {
        'active': True,
        'force': force,
        'show_entry': show_entry,
        'completed': done,
        'has_assets': has_assets,
        'plan_id': plan.plan_id,
        'plan_name': plan.name or '',
        'from_club_id': plan.from_club_id,
        'to_club_id': plan.to_club_id,
        'force_roles': force_roles,
        'force_intercept': bool(plan.force_intercept),
        'success_count': success_count(plan.plan_id, user_uid),
        'max_allowed': max_allowed_success(plan, user_uid),
        'is_target': False,
    }


@transaction.atomic
def set_password_and_issue(
    *,
    plan: ClubMigratePlan,
    from_uid: str,
    password: str,
    qrcode_url: str = '',
) -> Tuple[Optional[ClubMigrateToken], str]:
    pwd = str(password or '').strip()
    if not (pwd.isdigit() and len(pwd) == 6):
        return None, '转移密码须为6位数字'
    if not can_migrate_again(plan, from_uid):
        return None, '本计划已迁移完成，不可再次发码'
    ok, msg = check_conditions(plan, from_uid)
    if not ok:
        return None, msg

    ClubMigrateToken.query.filter(
        plan_id=plan.plan_id,
        from_uid=str(from_uid),
        status=ClubMigrateToken.STATUS_ISSUED,
    ).update(status=ClubMigrateToken.STATUS_CANCELLED, UpdateTime=_now())

    salt = secrets.token_hex(8)
    token = new_token()
    snap = {'assets': preview_assets(plan, from_uid), 'at': _now().isoformat()}
    row = ClubMigrateToken.query.create(
        token=token,
        plan_id=plan.plan_id,
        from_club_id=plan.from_club_id,
        to_club_id=plan.to_club_id,
        from_uid=str(from_uid),
        password_hash=_hash_password(pwd, salt),
        password_salt=salt,
        status=ClubMigrateToken.STATUS_ISSUED,
        asset_snapshot=snap,
        qrcode_url=(qrcode_url or '')[:512],
        expire_at=_now() + timedelta(hours=TOKEN_TTL_HOURS),
    )
    return row, ''


def verify_token_password(token_row: ClubMigrateToken, password: str) -> Tuple[bool, str]:
    if token_row.status != ClubMigrateToken.STATUS_ISSUED:
        return False, '令牌已失效'
    if token_row.expire_at and _now() > token_row.expire_at:
        token_row.status = ClubMigrateToken.STATUS_EXPIRED
        token_row.save(update_fields=['status', 'UpdateTime'])
        return False, '令牌已过期，请回源店重新设密出码'
    if int(token_row.fail_count or 0) >= PASSWORD_MAX_FAIL:
        return False, '密码错误次数过多，请回源店重置密码'
    pwd = str(password or '').strip()
    expect = _hash_password(pwd, token_row.password_salt)
    if expect != token_row.password_hash:
        ClubMigrateToken.query.filter(pk=token_row.pk).update(fail_count=F('fail_count') + 1)
        left = PASSWORD_MAX_FAIL - int(token_row.fail_count or 0) - 1
        return False, f'转移密码错误（还可试{max(left, 0)}次）'
    return True, ''


@transaction.atomic
def confirm_migrate(
    *,
    token: str,
    password: str,
    to_uid: str,
    client_meta: Optional[dict] = None,
) -> Tuple[Optional[dict], str]:
    """目标店确认过户。成功返回 ledger 摘要。"""
    token_row = (
        ClubMigrateToken.query.select_for_update()
        .filter(token=str(token or '').strip())
        .first()
    )
    if not token_row:
        return None, '令牌无效'

    if token_row.status == ClubMigrateToken.STATUS_CONSUMED:
        led = ClubMigrateLedger.query.filter(token=token_row.token).order_by('-id').first()
        if led:
            return serialize_ledger(led), ''
        return None, '令牌已使用'

    ok, msg = verify_token_password(token_row, password)
    if not ok:
        return None, msg

    plan = ClubMigratePlan.query.filter(plan_id=token_row.plan_id).first()
    if not plan or not plan.enabled or not plan_in_window(plan):
        return None, '迁移计划未生效'

    if not can_migrate_again(plan, token_row.from_uid):
        return None, '本计划已迁移完成'

    ok, msg = check_conditions(plan, token_row.from_uid)
    if not ok:
        return None, msg

    to_user = _load_user(to_uid)
    if not to_user:
        return None, '目标用户不存在，请先登录'
    from_user = _load_user(token_row.from_uid)
    if not from_user:
        return None, '源用户不存在'

    ds = _lock_profile(to_user, 'dashou')
    if ds is not None and int(getattr(ds, 'zhanghaozhuangtai', 1) or 1) != 1:
        return None, '目标账号已封禁'

    cons, _ = ClubMigrateConsumption.query.get_or_create(
        plan_id=plan.plan_id,
        from_uid=str(token_row.from_uid),
        defaults={'success_count': 0},
    )
    cons = ClubMigrateConsumption.query.select_for_update().filter(pk=cons.pk).first()
    if not can_migrate_again(plan, token_row.from_uid):
        return None, '本计划已迁移完成'

    keys = normalize_whitelist(plan.asset_whitelist)
    # 目标店：白名单涉及角色先建空扩展行
    for role in roles_from_whitelist(keys):
        if _profile_of(to_user, role) is None:
            _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}'

    try:
        for key in keys:
            meta = ASSET_CATALOG[key]
            if meta['kind'] == 'huiyuan':
                items_payload.extend(
                    _transfer_huiyuan(
                        from_uid=token_row.from_uid,
                        to_uid=str(to_uid),
                        from_club=plan.from_club_id,
                        to_club=plan.to_club_id,
                        role=meta['role'],
                    )
                )
                continue
            items_payload.append(
                _transfer_balance_field(
                    from_user=from_user,
                    to_user=to_user,
                    role=meta['role'],
                    field=meta['field'],
                    kind=meta['kind'],
                    key=key,
                    label=meta['label'],
                    to_club_id=plan.to_club_id,
                    freeze_biz_id=f'{freeze_biz_prefix}:{key}'[:64],
                )
            )
    except ValueError as e:
        transaction.set_rollback(True)
        return None, str(e) or '会员过户失败'

    ledger = ClubMigrateLedger.query.create(
        plan_id=plan.plan_id,
        token=token_row.token,
        from_club_id=plan.from_club_id,
        to_club_id=plan.to_club_id,
        from_uid=str(token_row.from_uid),
        to_uid=str(to_uid),
        migrate_seq=migrate_seq,
        status=ClubMigrateLedger.STATUS_SUCCESS,
        summary_json={'item_count': len(items_payload), 'keys': keys},
        client_meta=client_meta or {},
    )
    for it in items_payload:
        ClubMigrateLedgerItem.query.create(
            ledger_id=ledger.id,
            plan_id=plan.plan_id,
            item_type=it['item_type'],
            role=it['role'],
            field=it.get('field', ''),
            amount=_money(it.get('amount') or 0),
            before_src=str(it.get('before_src', '')),
            after_src=str(it.get('after_src', '')),
            before_dst=str(it.get('before_dst', '')),
            after_dst=str(it.get('after_dst', '')),
            huiyuan_id=str(it.get('huiyuan_id') or ''),
            meta_json=it.get('meta_json') or {},
        )

    cons.success_count = migrate_seq
    cons.last_ledger_id = ledger.id
    cons.save(update_fields=['success_count', 'last_ledger_id', 'UpdateTime'])

    token_row.status = ClubMigrateToken.STATUS_CONSUMED
    token_row.consumed_at = _now()
    token_row.save(update_fields=['status', 'consumed_at', 'UpdateTime'])

    logger.info(
        'club_migrate ok plan=%s seq=%s from=%s to=%s ledger=%s items=%s',
        plan.plan_id, migrate_seq, token_row.from_uid, to_uid, ledger.id, len(items_payload),
    )
    return serialize_ledger(ledger), ''


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
    after_src = Decimal('0.00') if kind != 'int' else 0

    if src_prof is not None and amount:
        if kind == 'int':
            type(src_prof).objects.filter(pk=src_prof.pk).update(**{field: 0})
        else:
            type(src_prof).objects.filter(pk=src_prof.pk).update(**{field: Decimal('0.00')})

    dst_prof = _lock_profile(to_user, role)
    created = False
    if dst_prof is None:
        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:
        # 积分：目标已满 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)

    return {
        'item_type': ClubMigrateLedgerItem.ITEM_BALANCE,
        'role': role,
        'field': field,
        'amount': amount,
        'before_src': before_src,
        '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_extra},
    }


def _fmt_ledger_time(v: str) -> str:
    s = str(v or '').strip()
    if not s or s in ('skipped', 'expired') or 'T' not in s:
        return s
    try:
        dt = datetime.fromisoformat(s.replace('Z', '+00:00'))
        if timezone.is_naive(dt):
            dt = timezone.make_aware(dt, timezone.get_current_timezone())
        return timezone.localtime(dt).strftime('%Y-%m-%d %H:%M')
    except Exception:
        return s


def serialize_ledger(ledger: ClubMigrateLedger) -> dict:
    items = list(ClubMigrateLedgerItem.query.filter(ledger_id=ledger.id).order_by('id'))
    return {
        'ledger_id': ledger.id,
        'plan_id': ledger.plan_id,
        'from_club_id': ledger.from_club_id,
        'to_club_id': ledger.to_club_id,
        'from_uid': ledger.from_uid,
        'to_uid': ledger.to_uid,
        'migrate_seq': ledger.migrate_seq,
        'status': ledger.status,
        'created_at': ledger.CreateTime.isoformat() if ledger.CreateTime else '',
        'items': [
            {
                'item_type': it.item_type,
                'role': it.role,
                'field': it.field,
                'amount': str(it.amount),
                'before_src': it.before_src,
                'after_src': it.after_src,
                'before_dst': it.before_dst,
                'after_dst': _fmt_ledger_time(it.after_dst) if it.field == 'huiyuan' else it.after_dst,
                'huiyuan_id': it.huiyuan_id,
                'meta': it.meta_json or {},
                'label': (it.meta_json or {}).get('name')
                    or (it.meta_json or {}).get('label')
                    or f'{it.role}.{it.field}',
            }
            for it in items
        ],
    }


def list_user_records(uid: str, limit: int = 50) -> List[dict]:
    qs = (
        ClubMigrateLedger.query.filter(Q(from_uid=str(uid)) | Q(to_uid=str(uid)))
        .order_by('-id')[: max(1, min(int(limit or 50), 100))]
    )
    return [serialize_ledger(x) for x in qs]


def serialize_plan(plan: ClubMigratePlan) -> dict:
    return {
        'plan_id': plan.plan_id,
        'name': plan.name or '',
        'from_club_id': plan.from_club_id,
        'to_club_id': plan.to_club_id,
        'enabled': bool(plan.enabled),
        'start_at': plan.start_at.isoformat() if plan.start_at else '',
        'end_at': plan.end_at.isoformat() if plan.end_at else '',
        'asset_whitelist': normalize_whitelist(plan.asset_whitelist),
        'condition_json': plan.condition_json or {},
        'force_intercept': bool(plan.force_intercept),
        'force_roles': plan.force_roles or ['dashou'],
        'default_once_per_user': bool(plan.default_once_per_user),
        'remark': plan.remark or '',
        'updated_by': plan.updated_by or '',
        'created_at': plan.CreateTime.isoformat() if plan.CreateTime else '',
        'updated_at': plan.UpdateTime.isoformat() if plan.UpdateTime else '',
    }


def serialize_token(token_row: ClubMigrateToken) -> dict:
    qr = (token_row.qrcode_url or '').strip()
    if qr and not qr.startswith('http'):
        from django.conf import settings
        cos_domain = getattr(settings, 'COS_DOMAIN', '').rstrip('/')
        if cos_domain:
            qr = f'{cos_domain}/{qr.lstrip("/")}'
    return {
        'token': token_row.token,
        'scene': scene_for_token(token_row.token),
        'plan_id': token_row.plan_id,
        'from_club_id': token_row.from_club_id,
        'to_club_id': token_row.to_club_id,
        'status': token_row.status,
        'qrcode_url': qr,
        'expire_at': token_row.expire_at.isoformat() if token_row.expire_at else '',
        'asset_snapshot': token_row.asset_snapshot or {},
    }


def lookup_migrate_user_brief(uid: str) -> dict:
    """后台二次名额：校验用户并返回简要资料。"""
    uid = str(uid or '').strip()
    user = _load_user(uid)
    if not user:
        return {'exists': False, 'from_uid': uid, 'nicheng': '', 'phone': ''}
    nicheng = ''
    try:
        ds = getattr(user, 'DashouProfile', None)
        if ds and getattr(ds, 'nicheng', None):
            nicheng = str(ds.nicheng)
    except Exception:
        pass
    if not nicheng:
        nicheng = str(getattr(user, 'UserName', '') or getattr(user, 'Phone', '') or '')
    return {
        'exists': True,
        'from_uid': uid,
        'nicheng': nicheng,
        'phone': str(getattr(user, 'Phone', '') or ''),
        'club_id': str(getattr(user, 'ClubID', '') or ''),
    }


def serialize_user_quota(row: ClubMigrateUserQuota, plan: Optional[ClubMigratePlan] = None, user_info: Optional[dict] = None) -> dict:
    plan = plan or ClubMigratePlan.query.filter(plan_id=row.plan_id).first()
    info = user_info if user_info is not None else lookup_migrate_user_brief(row.from_uid)
    used = success_count(row.plan_id, row.from_uid)
    allowed = max_allowed_success(plan, row.from_uid) if plan else (1 + int(row.max_extra_times or 0))
    return {
        'plan_id': row.plan_id,
        'from_uid': row.from_uid,
        'max_extra_times': int(row.max_extra_times or 0),
        'reason': row.reason or '',
        'updated_by': row.updated_by or '',
        'created_at': row.CreateTime.isoformat() if row.CreateTime else '',
        'updated_at': row.UpdateTime.isoformat() if row.UpdateTime else '',
        'nicheng': info.get('nicheng') or '',
        'phone': info.get('phone') or '',
        'user_club_id': info.get('club_id') or '',
        'success_count': used,
        'max_allowed': allowed,
        'remaining': max(0, int(allowed) - int(used)),
        'can_migrate_again': used < allowed,
    }
