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)
|
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.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
|
from products.models import Huiyuangoumai
|
||||||
|
|
||||||
now = _now_aware()
|
now = _now_aware()
|
||||||
@@ -305,16 +421,13 @@ def _list_active_huiyuan_rows(uid: str, from_club: str, *, for_update: bool = Fa
|
|||||||
)
|
)
|
||||||
if for_update:
|
if for_update:
|
||||||
qs = qs.select_for_update()
|
qs = qs.select_for_update()
|
||||||
rows = list(qs)
|
|
||||||
out = []
|
out = []
|
||||||
for r in rows:
|
for r in qs:
|
||||||
exp = _aware(r.daoqi_time)
|
exp = _aware(r.daoqi_time)
|
||||||
if not exp or exp <= now:
|
if not exp or exp <= now:
|
||||||
continue
|
continue
|
||||||
rec_cid = (getattr(r, 'club_id', None) or '').strip()
|
if not _club_row_belongs_to_source(getattr(r, 'club_id', None) or '', from_club):
|
||||||
if rec_cid and rec_cid not in (from_club, CLUB_ID_DEFAULT, ''):
|
continue
|
||||||
if from_club and rec_cid != from_club:
|
|
||||||
continue
|
|
||||||
out.append(r)
|
out.append(r)
|
||||||
return out
|
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]:
|
def _preview_huiyuan(uid: str, club_id: str, role: str) -> List[dict]:
|
||||||
from products.models import Huiyuan
|
from products.models import Huiyuan
|
||||||
|
|
||||||
rows = _list_active_huiyuan_rows(uid, club_id)
|
entries = _collect_migratable_huiyuan(uid, club_id)
|
||||||
if not rows:
|
if not entries:
|
||||||
return []
|
return []
|
||||||
ids = [r.huiyuan_id for r in rows]
|
ids = [e['huiyuan_id'] for e in entries]
|
||||||
name_map = {
|
name_map = {
|
||||||
h.huiyuan_id: (h.jieshao or '').strip() or '会员'
|
h.huiyuan_id: (h.jieshao or '').strip() or '会员'
|
||||||
for h in Huiyuan.query.filter(huiyuan_id__in=ids)
|
for h in Huiyuan.query.filter(huiyuan_id__in=ids)
|
||||||
}
|
}
|
||||||
out = []
|
out = []
|
||||||
for r in rows:
|
for e in entries:
|
||||||
name = (r.jieshao or '').strip() or name_map.get(r.huiyuan_id) or '会员'
|
name = e.get('jieshao') or name_map.get(e['huiyuan_id']) or '会员'
|
||||||
exp = _aware(r.daoqi_time)
|
exp = _aware(e.get('daoqi_time'))
|
||||||
if exp is None:
|
if exp is None:
|
||||||
exp_text = ''
|
exp_text = ''
|
||||||
elif timezone.is_aware(exp):
|
elif timezone.is_aware(exp):
|
||||||
exp_text = timezone.localtime(exp).strftime('%Y-%m-%d %H:%M')
|
exp_text = timezone.localtime(exp).strftime('%Y-%m-%d %H:%M')
|
||||||
else:
|
else:
|
||||||
exp_text = exp.strftime('%Y-%m-%d %H:%M')
|
exp_text = exp.strftime('%Y-%m-%d %H:%M')
|
||||||
|
src_tag = '开通' if e.get('source') == 'goumai' else '充值权益'
|
||||||
out.append({
|
out.append({
|
||||||
'key': f'{role}.huiyuan:{r.huiyuan_id}',
|
'key': f'{role}.huiyuan:{e["huiyuan_id"]}',
|
||||||
'role': role,
|
'role': role,
|
||||||
'field': 'huiyuan',
|
'field': 'huiyuan',
|
||||||
'label': name,
|
'label': name,
|
||||||
'kind': 'huiyuan',
|
'kind': 'huiyuan',
|
||||||
'amount': exp_text,
|
'amount': exp_text,
|
||||||
'display': f'{name} · 到期 {exp_text}' if exp_text else name,
|
'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 '',
|
'daoqi_time': exp.isoformat() if exp else '',
|
||||||
'jieshao': name,
|
'jieshao': name,
|
||||||
'has_profile': True,
|
'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
|
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):
|
def _find_user_huiyuangoumai(uid: str, huiyuan_id: str, *, for_update: bool = False):
|
||||||
"""按会员 ID 变体查找购买记录(001 / 1 等同)。"""
|
"""按会员 ID 变体查找购买记录(001 / 1 等同)。"""
|
||||||
from jituan.services.huiyuan_bundle import expand_huiyuan_id_variants
|
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]:
|
def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
未到期会员:目标店加同等剩余时长(同会员 ID,含总会员),源店置已到期。
|
未到期会员:目标店加同等剩余时长(同会员 ID,含总会员),源店置已到期并撤销充值权益。
|
||||||
写目标失败则整笔迁移失败回滚,禁止静默 skip。
|
数据来源与打手端 clumber 一致(开通表 + 充值单)。
|
||||||
"""
|
"""
|
||||||
from products.models import Huiyuan
|
from products.models import Huiyuan
|
||||||
|
|
||||||
now = _now_aware()
|
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 = []
|
out = []
|
||||||
for r in rows:
|
for e in entries:
|
||||||
src_exp = _aware(r.daoqi_time)
|
src_exp = _aware(e.get('daoqi_time'))
|
||||||
name = (r.jieshao or '').strip()
|
name = (e.get('jieshao') or '').strip()
|
||||||
if not name:
|
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 '会员'
|
name = (hy.jieshao if hy else '') or '会员'
|
||||||
before = {
|
before = {
|
||||||
'huiyuan_id': r.huiyuan_id,
|
'huiyuan_id': e['huiyuan_id'],
|
||||||
'daoqi_time': src_exp.isoformat() if src_exp else '',
|
'daoqi_time': src_exp.isoformat() if src_exp else '',
|
||||||
'zhuangtai': r.huiyuan_zhuangtai,
|
'zhuangtai': 1,
|
||||||
'is_trial': bool(r.is_trial),
|
'is_trial': bool(e.get('is_trial')),
|
||||||
'jieshao': name,
|
'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
|
remain = (src_exp - now) if src_exp and src_exp > now else None
|
||||||
if remain is None:
|
if remain is None:
|
||||||
# 列表时未过期、过户瞬间过期:跳过该条即可
|
|
||||||
continue
|
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:
|
if not tid:
|
||||||
raise ValueError(f'会员 ID 无效,无法迁移: {r.huiyuan_id}')
|
raise ValueError(f'会员 ID 无效,无法迁移: {e["huiyuan_id"]}')
|
||||||
|
|
||||||
formal_floor = 0
|
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
|
from jituan.services.member_recharge import count_formal_paid_member_orders
|
||||||
src_paid = 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)
|
formal_floor = max(int(src_paid or 0), 1)
|
||||||
|
|
||||||
# 先写目标,再失效源(目标失败则事务回滚,源不变)
|
|
||||||
exp, before_dst = _add_huiyuan_duration(
|
exp, before_dst = _add_huiyuan_duration(
|
||||||
to_uid=to_uid,
|
to_uid=to_uid,
|
||||||
to_club=to_club,
|
to_club=to_club,
|
||||||
huiyuan_id=tid,
|
huiyuan_id=tid,
|
||||||
remain=remain,
|
remain=remain,
|
||||||
jieshao=name,
|
jieshao=name,
|
||||||
is_trial=bool(r.is_trial),
|
is_trial=bool(e.get('is_trial')),
|
||||||
has_used_trial=bool(r.has_used_trial),
|
has_used_trial=bool(e.get('has_used_trial')),
|
||||||
)
|
)
|
||||||
if exp is None:
|
if exp is None:
|
||||||
raise ValueError(f'会员过户失败(加时长): {name}({tid})')
|
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({
|
out.append({
|
||||||
'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN,
|
'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN,
|
||||||
'role': role,
|
'role': role,
|
||||||
@@ -558,11 +696,12 @@ def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dic
|
|||||||
'meta_json': {
|
'meta_json': {
|
||||||
'before_src': before,
|
'before_src': before,
|
||||||
'before_dst': before_dst,
|
'before_dst': before_dst,
|
||||||
'src_huiyuan_id': r.huiyuan_id,
|
'src_huiyuan_id': e['huiyuan_id'],
|
||||||
'name': name,
|
'name': name,
|
||||||
'mapped_from_bundle': False,
|
'mapped_from_bundle': False,
|
||||||
'formal_purchase_floor': formal_floor,
|
'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
|
return out
|
||||||
|
|||||||
Reference in New Issue
Block a user