fix: 二次迁移可迁账上仍有效的充值单合成会员
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -288,13 +288,129 @@ def user_has_migratable_assets(plan: ClubMigratePlan, from_uid: str) -> bool:
|
||||
return any(asset_item_has_value(it) for it in items)
|
||||
|
||||
|
||||
def _list_active_huiyuan_rows(uid: str, from_club: str, *, for_update: bool = False):
|
||||
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]:
|
||||
"""
|
||||
源店可迁会员:按用户取有效会员,不盲信 Huiyuangoumai.club_id。
|
||||
历史数据常把 club_id 写成默认 xq/空,严格按 from_club 过滤会漏迁。
|
||||
仅排除「明确属于其他店」的记录。
|
||||
可迁会员 = 用户端能看到的有效会员(开通表 + 未撤销充值单合成)。
|
||||
第一次迁移若只弄坏了开通表、充值单还在,第二次仍能迁。
|
||||
"""
|
||||
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()
|
||||
@@ -305,16 +421,13 @@ def _list_active_huiyuan_rows(uid: str, from_club: str, *, for_update: bool = Fa
|
||||
)
|
||||
if for_update:
|
||||
qs = qs.select_for_update()
|
||||
rows = list(qs)
|
||||
out = []
|
||||
for r in rows:
|
||||
for r in qs:
|
||||
exp = _aware(r.daoqi_time)
|
||||
if not exp or exp <= now:
|
||||
continue
|
||||
rec_cid = (getattr(r, 'club_id', None) or '').strip()
|
||||
if rec_cid and rec_cid not in (from_club, CLUB_ID_DEFAULT, ''):
|
||||
if from_club and rec_cid != from_club:
|
||||
continue
|
||||
if not _club_row_belongs_to_source(getattr(r, 'club_id', None) or '', from_club):
|
||||
continue
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
@@ -322,37 +435,40 @@ def _list_active_huiyuan_rows(uid: str, from_club: str, *, for_update: bool = Fa
|
||||
def _preview_huiyuan(uid: str, club_id: str, role: str) -> List[dict]:
|
||||
from products.models import Huiyuan
|
||||
|
||||
rows = _list_active_huiyuan_rows(uid, club_id)
|
||||
if not rows:
|
||||
entries = _collect_migratable_huiyuan(uid, club_id)
|
||||
if not entries:
|
||||
return []
|
||||
ids = [r.huiyuan_id for r in rows]
|
||||
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 r in rows:
|
||||
name = (r.jieshao or '').strip() or name_map.get(r.huiyuan_id) or '会员'
|
||||
exp = _aware(r.daoqi_time)
|
||||
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:{r.huiyuan_id}',
|
||||
'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': r.huiyuan_id,
|
||||
'huiyuan_id': e['huiyuan_id'],
|
||||
'daoqi_time': exp.isoformat() if exp else '',
|
||||
'jieshao': name,
|
||||
'has_profile': True,
|
||||
'record_club_id': (r.club_id or ''),
|
||||
'record_club_id': e.get('record_club_id') or '',
|
||||
'source': e.get('source') or '',
|
||||
'source_tag': src_tag,
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -411,6 +527,25 @@ def _expire_source_huiyuan(row, now):
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -492,59 +627,62 @@ def _add_huiyuan_duration(*, to_uid: str, to_club: str, huiyuan_id: str, remain,
|
||||
|
||||
def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dict]:
|
||||
"""
|
||||
未到期会员:目标店加同等剩余时长(同会员 ID,含总会员),源店置已到期。
|
||||
写目标失败则整笔迁移失败回滚,禁止静默 skip。
|
||||
未到期会员:目标店加同等剩余时长(同会员 ID,含总会员),源店置已到期并撤销充值权益。
|
||||
数据来源与打手端 clumber 一致(开通表 + 充值单)。
|
||||
"""
|
||||
from products.models import Huiyuan
|
||||
|
||||
now = _now_aware()
|
||||
rows = _list_active_huiyuan_rows(from_uid, from_club, for_update=True)
|
||||
entries = _collect_migratable_huiyuan(from_uid, from_club, for_update=True)
|
||||
out = []
|
||||
for r in rows:
|
||||
src_exp = _aware(r.daoqi_time)
|
||||
name = (r.jieshao or '').strip()
|
||||
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=r.huiyuan_id).first()
|
||||
hy = Huiyuan.query.filter(huiyuan_id=e['huiyuan_id']).first()
|
||||
name = (hy.jieshao if hy else '') or '会员'
|
||||
before = {
|
||||
'huiyuan_id': r.huiyuan_id,
|
||||
'huiyuan_id': e['huiyuan_id'],
|
||||
'daoqi_time': src_exp.isoformat() if src_exp else '',
|
||||
'zhuangtai': r.huiyuan_zhuangtai,
|
||||
'is_trial': bool(r.is_trial),
|
||||
'zhuangtai': 1,
|
||||
'is_trial': bool(e.get('is_trial')),
|
||||
'jieshao': name,
|
||||
'record_club_id': (r.club_id or ''),
|
||||
'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
|
||||
|
||||
tid = (_resolve_migrate_target_huiyuan_ids(src_huiyuan_id=r.huiyuan_id) or [None])[0]
|
||||
tid = (_resolve_migrate_target_huiyuan_ids(src_huiyuan_id=e['huiyuan_id']) or [None])[0]
|
||||
if not tid:
|
||||
raise ValueError(f'会员 ID 无效,无法迁移: {r.huiyuan_id}')
|
||||
raise ValueError(f'会员 ID 无效,无法迁移: {e["huiyuan_id"]}')
|
||||
|
||||
formal_floor = 0
|
||||
if not bool(r.is_trial):
|
||||
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, r.huiyuan_id, from_club,
|
||||
from_uid, e['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,
|
||||
huiyuan_id=tid,
|
||||
remain=remain,
|
||||
jieshao=name,
|
||||
is_trial=bool(r.is_trial),
|
||||
has_used_trial=bool(r.has_used_trial),
|
||||
is_trial=bool(e.get('is_trial')),
|
||||
has_used_trial=bool(e.get('has_used_trial')),
|
||||
)
|
||||
if exp is None:
|
||||
raise ValueError(f'会员过户失败(加时长): {name}({tid})')
|
||||
|
||||
_expire_source_huiyuan(r, now)
|
||||
_expire_source_membership(
|
||||
from_uid=from_uid,
|
||||
huiyuan_id=e['huiyuan_id'],
|
||||
goumai_row=e.get('goumai_row'),
|
||||
)
|
||||
out.append({
|
||||
'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN,
|
||||
'role': role,
|
||||
@@ -558,11 +696,12 @@ def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dic
|
||||
'meta_json': {
|
||||
'before_src': before,
|
||||
'before_dst': before_dst,
|
||||
'src_huiyuan_id': r.huiyuan_id,
|
||||
'src_huiyuan_id': e['huiyuan_id'],
|
||||
'name': name,
|
||||
'mapped_from_bundle': False,
|
||||
'formal_purchase_floor': formal_floor,
|
||||
'is_trial': bool(r.is_trial),
|
||||
'is_trial': bool(e.get('is_trial')),
|
||||
'source': e.get('source') or '',
|
||||
},
|
||||
})
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user