diff --git a/jituan/services/club_migrate.py b/jituan/services/club_migrate.py index d79b14c..4c736c2 100644 --- a/jituan/services/club_migrate.py +++ b/jituan/services/club_migrate.py @@ -477,11 +477,43 @@ def _resolve_migrate_target_huiyuan_ids( *, src_huiyuan_id: str, from_club: str = '', to_club: str = '', ) -> List[str]: """ - 会员 ID 集团公共(含总会员):原样迁同一 ID。 - 不要求目标店 ClubHuiyuanPrice 上架,不拆成子会员。 + 普通会员:原样迁同 ID。 + 总会员:按子会员格式迁出(写入各子会员开通记录),不在目标店再挂一条总会员。 + 未配置子项时才退回迁总会员本身,避免丢权益。 """ - src_id = str(src_huiyuan_id or '').strip() - return [src_id] if src_id else [] + 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): @@ -627,14 +659,18 @@ 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,含总会员),源店置已到期并撤销充值权益。 - 数据来源与打手端 clumber 一致(开通表 + 充值单)。 + 未到期会员:目标店加同等剩余时长;源店置已到期并撤销充值权益。 + 总会员按子会员拆分写入(不用总会员 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() @@ -654,8 +690,12 @@ def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dic if remain is None: continue - tid = (_resolve_migrate_target_huiyuan_ids(src_huiyuan_id=e['huiyuan_id']) or [None])[0] - if not tid: + 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 @@ -666,44 +706,70 @@ def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dic ) 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(e.get('is_trial')), - has_used_trial=bool(e.get('has_used_trial')), - ) - if exp is None: - raise ValueError(f'会员过户失败(加时长): {name}({tid})') + 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'), ) - 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': name, - 'mapped_from_bundle': False, - 'formal_purchase_floor': formal_floor, - 'is_trial': bool(e.get('is_trial')), - 'source': e.get('source') or '', - }, - }) return out diff --git a/jituan/services/huiyuan_bundle.py b/jituan/services/huiyuan_bundle.py index 3af7512..94650d8 100644 --- a/jituan/services/huiyuan_bundle.py +++ b/jituan/services/huiyuan_bundle.py @@ -365,6 +365,7 @@ def enrich_clumber_item(record, club_id=None): 'daoqi': daoqi_str, 'huiyuan_zhuangtai': 1, 'shifou_daoqi': False, + 'display_owned': True, } if is_bundle_huiyuan(record.huiyuan_id): item['is_bundle'] = True @@ -379,16 +380,30 @@ def enrich_clumber_item(record, club_id=None): def _append_clumber_id_variants(items, seen, base_item, huiyuan_id): - """为老端 == 比较补齐多种会员 ID 写法。""" + """为老端 == 比较补齐多种会员 ID 写法。仅首条供「我的会员」展示。""" + hide_owned = ( + base_item.get('display_owned') is False + or bool(base_item.get('from_bundle')) + or bool(base_item.get('id_variant')) + ) + first = True for vid in expand_huiyuan_id_variants(huiyuan_id): if vid in seen: continue seen.add(vid) - items.append({ + row = { **base_item, 'huiyuanid': vid, 'huiyuan_id': vid, - }) + } + if hide_owned or not first: + row['display_owned'] = False + if not first: + row['id_variant'] = True + else: + row['display_owned'] = True + first = False + items.append(row) def _append_bundle_sub_clumber(items, seen, base_item): @@ -405,10 +420,23 @@ def _append_bundle_sub_clumber(items, seen, base_item): sid = str(sub_id).strip() if not sid: continue + # 子项用子会员自己的名称;不进「我的会员」主列表(仅抢单匹配) + child_name = '' + try: + hy = Huiyuan.query.filter(huiyuan_id=sid).first() + if not hy: + for vid in expand_huiyuan_id_variants(sid): + hy = Huiyuan.query.filter(huiyuan_id=vid).first() + if hy: + break + child_name = ((hy.jieshao if hy else '') or '').strip() + except Exception: + child_name = '' sub_base = { - 'huiyuanming': base_item.get('huiyuanming', ''), + 'huiyuanming': child_name or sid, 'daoqi': base_item.get('daoqi', ''), 'from_bundle': bundle_hid, + 'display_owned': False, 'huiyuan_zhuangtai': 1, 'shifou_daoqi': False, } @@ -486,6 +514,7 @@ def _build_clumber_from_paid_czjilu(yonghu_id, club_id=None): 'daoqi': format_legacy_daoqi(daoqi), 'huiyuan_zhuangtai': 1, 'shifou_daoqi': False, + 'display_owned': True, } if is_bundle_huiyuan(hid): base['is_bundle'] = True