From 2b6d91fc4a4186d7784407698747f013665f8e82 Mon Sep 17 00:00:00 2001 From: XingQue Date: Wed, 29 Jul 2026 03:30:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E4=BC=9A=E5=91=98=E5=90=8D=E7=A7=B0=E4=B8=8E?= =?UTF-8?q?=E5=88=B0=E6=9C=9F=E6=97=A5=EF=BC=8C=E6=80=BB=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E6=8C=89=E5=AD=90=E4=BC=9A=E5=91=98=E5=8A=A0=E6=97=B6=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- jituan/services/club_migrate.py | 325 +++++++++++++++++++++++--------- 1 file changed, 238 insertions(+), 87 deletions(-) diff --git a/jituan/services/club_migrate.py b/jituan/services/club_migrate.py index fb25089..e4aab00 100644 --- a/jituan/services/club_migrate.py +++ b/jituan/services/club_migrate.py @@ -8,7 +8,7 @@ import hashlib import logging import secrets import uuid -from datetime import timedelta +from datetime import datetime, timedelta from decimal import Decimal, ROUND_HALF_UP from typing import Dict, List, Optional, Tuple @@ -207,6 +207,12 @@ def preview_assets(plan: ClubMigratePlan, from_uid: str) -> List[dict]: 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, @@ -214,13 +220,15 @@ def preview_assets(plan: ClubMigratePlan, from_uid: str) -> List[dict]: 'label': meta['label'], 'kind': kind, 'amount': str(amount), + 'display': display, 'has_profile': profile is not None, }) return items def _preview_huiyuan(uid: str, club_id: str, role: str) -> List[dict]: - from products.models import Huiyuangoumai + from products.models import Huiyuan, Huiyuangoumai + now = _now() rows = list( Huiyuangoumai.query.filter( @@ -230,23 +238,231 @@ def _preview_huiyuan(uid: str, club_id: str, role: str) -> List[dict]: daoqi_time__gt=now, ) ) + if not rows: + return [] + ids = [r.huiyuan_id for r in rows] + 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 = r.daoqi_time + exp_text = timezone.localtime(exp).strftime('%Y-%m-%d %H:%M') if exp else '' out.append({ - 'key': f'{role}.huiyuan', + 'key': f'{role}.huiyuan:{r.huiyuan_id}', 'role': role, 'field': 'huiyuan', - 'label': f'会员 {r.huiyuan_id}', + 'label': name, 'kind': 'huiyuan', - 'amount': '1', + 'amount': exp_text, + 'display': f'{name} · 到期 {exp_text}' if exp_text else name, 'huiyuan_id': r.huiyuan_id, - 'daoqi_time': r.daoqi_time.isoformat() if r.daoqi_time else '', - 'jieshao': r.jieshao or '', + 'daoqi_time': exp.isoformat() if exp else '', + 'jieshao': name, 'has_profile': True, }) return out +def _club_huiyuan_enabled(club_id: str, huiyuan_id: str) -> bool: + from jituan.models import ClubHuiyuanPrice + row = ClubHuiyuanPrice.query.filter( + club_id=str(club_id), huiyuan_id=str(huiyuan_id), is_enabled=True, + ).first() + return bool(row) + + +def _bundle_child_ids(club_id: str, bundle_huiyuan_id: str) -> List[str]: + from jituan.models import ClubHuiyuanBundleInclude + qs = ClubHuiyuanBundleInclude.query.filter( + club_id=str(club_id), bundle_huiyuan_id=str(bundle_huiyuan_id), + ) + kids = [str(x.included_huiyuan_id) for x in qs if x.included_huiyuan_id] + if kids: + return kids + # 源店未配包含关系时,回退任意俱乐部同总会员配置 + qs2 = ClubHuiyuanBundleInclude.query.filter(bundle_huiyuan_id=str(bundle_huiyuan_id)) + seen = set() + out = [] + for x in qs2: + cid = str(x.included_huiyuan_id or '') + if cid and cid not in seen: + seen.add(cid) + out.append(cid) + return out + + +def _resolve_migrate_target_huiyuan_ids( + *, src_huiyuan_id: str, from_club: str, to_club: str, +) -> List[str]: + """ + 目标店开通了同 ID → 迁该 ID; + 总会员目标店无对应 ID → 按源店子会员映射到目标店已开通的子会员。 + """ + from products.models import Huiyuan + + src_id = str(src_huiyuan_id or '').strip() + if not src_id: + return [] + if _club_huiyuan_enabled(to_club, src_id): + return [src_id] + + hy = Huiyuan.query.filter(huiyuan_id=src_id).first() + if not hy or not bool(getattr(hy, 'is_bundle', False)): + return [] + + children = _bundle_child_ids(from_club, src_id) + out = [] + for cid in children: + if _club_huiyuan_enabled(to_club, cid): + out.append(cid) + return out + + +def _add_huiyuan_duration(*, to_uid: str, to_club: str, huiyuan_id: str, remain, jieshao: str, is_trial: bool, has_used_trial: bool): + """给目标用户对应会员加时长(已有则累加剩余,否则从现在起算)。""" + from products.models import Huiyuangoumai + + now = _now() + remain = remain if remain and remain.total_seconds() > 0 else None + if remain is None: + return None, {} + + dst = Huiyuangoumai.objects.select_for_update().filter( + yonghu_id=str(to_uid), + huiyuan_id=str(huiyuan_id), + ).first() + 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, + } + dst_exp = _aware(dst.daoqi_time) + base = dst_exp if (dst_exp and dst_exp > now and int(dst.huiyuan_zhuangtai or 0) == 1) else now + exp = base + remain + 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 + + exp = now + remain + Huiyuangoumai.query.create( + huiyuan_id=str(huiyuan_id), + 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), + ) + return exp, before_dst + + +def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dict]: + from products.models import Huiyuan, Huiyuangoumai + + now = _now() + rows = list( + Huiyuangoumai.objects.select_for_update().filter( + yonghu_id=str(from_uid), + club_id=from_club, + huiyuan_zhuangtai=1, + daoqi_time__gt=now, + ) + ) + out = [] + for r in rows: + src_exp = _aware(r.daoqi_time) + name = (r.jieshao or '').strip() + if not name: + hy = Huiyuan.query.filter(huiyuan_id=r.huiyuan_id).first() + name = (hy.jieshao if hy else '') or '会员' + before = { + 'huiyuan_id': r.huiyuan_id, + 'daoqi_time': src_exp.isoformat() if src_exp else '', + 'zhuangtai': r.huiyuan_zhuangtai, + 'is_trial': bool(r.is_trial), + 'jieshao': name, + } + remain = (src_exp - now) if src_exp and src_exp > now else None + # 源失效(保留原到期进流水 before) + r.huiyuan_zhuangtai = 0 + r.daoqi_time = now + r.save(update_fields=['huiyuan_zhuangtai', 'daoqi_time', 'UpdateTime']) + + target_ids = _resolve_migrate_target_huiyuan_ids( + src_huiyuan_id=r.huiyuan_id, from_club=from_club, to_club=to_club, + ) + if not target_ids or remain is None: + out.append({ + 'item_type': ClubMigrateLedgerItem.ITEM_HUIYUAN, + 'role': role, + 'field': 'huiyuan', + 'amount': Decimal('0'), + 'before_src': before.get('daoqi_time', ''), + 'after_src': 'expired', + 'before_dst': '', + 'after_dst': 'skipped', + 'huiyuan_id': r.huiyuan_id, + 'meta_json': { + 'before_src': before, + 'skip_reason': 'target_not_enabled' if not target_ids else 'no_remain', + 'name': name, + }, + }) + continue + + for tid in target_ids: + child_name = name + if tid != r.huiyuan_id: + chy = Huiyuan.query.filter(huiyuan_id=tid).first() + child_name = (chy.jieshao if chy else '') 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(r.is_trial), + has_used_trial=bool(r.has_used_trial), + ) + 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': r.huiyuan_id, + 'name': child_name, + 'mapped_from_bundle': tid != r.huiyuan_id, + }, + }) + return out + + +def _aware(dt): + if dt is None: + return None + if timezone.is_naive(dt): + return timezone.make_aware(dt, timezone.get_current_timezone()) + return dt + + def check_conditions(plan: ClubMigratePlan, from_uid: str) -> Tuple[bool, str]: """返回 (ok, msg)。""" cond = plan.condition_json or {} @@ -561,85 +777,17 @@ def _transfer_balance_field(*, from_user, to_user, role, field, kind, key, label } -def _aware(dt): - if dt is None: - return None - if timezone.is_naive(dt): - return timezone.make_aware(dt, timezone.get_current_timezone()) - return dt - - -def _transfer_huiyuan(*, from_uid, to_uid, from_club, to_club, role) -> List[dict]: - from products.models import Huiyuangoumai - - now = _now() - rows = list( - Huiyuangoumai.objects.select_for_update().filter( - yonghu_id=str(from_uid), - club_id=from_club, - huiyuan_zhuangtai=1, - daoqi_time__gt=now, - ) - ) - out = [] - for r in rows: - src_exp = r.daoqi_time - before = { - 'huiyuan_id': r.huiyuan_id, - 'daoqi_time': src_exp.isoformat() if src_exp else '', - 'zhuangtai': r.huiyuan_zhuangtai, - 'is_trial': bool(r.is_trial), - 'jieshao': r.jieshao or '', - } - # 源失效(保留原到期进流水 before) - r.huiyuan_zhuangtai = 0 - r.daoqi_time = now - r.save(update_fields=['huiyuan_zhuangtai', 'daoqi_time', 'UpdateTime']) - - dst = Huiyuangoumai.objects.select_for_update().filter( - yonghu_id=str(to_uid), - huiyuan_id=r.huiyuan_id, - ).first() - before_dst = {} - exp = _aware(src_exp) or now - if dst: - before_dst = { - 'daoqi_time': dst.daoqi_time.isoformat() if dst.daoqi_time else '', - 'zhuangtai': dst.huiyuan_zhuangtai, - 'club_id': dst.club_id, - } - dst_exp = _aware(dst.daoqi_time) - if dst_exp and dst_exp > exp: - exp = dst_exp - dst.daoqi_time = exp - dst.huiyuan_zhuangtai = 1 - dst.club_id = to_club - dst.jieshao = r.jieshao or dst.jieshao - dst.save(update_fields=['daoqi_time', 'huiyuan_zhuangtai', 'club_id', 'jieshao', 'UpdateTime']) - else: - Huiyuangoumai.query.create( - huiyuan_id=r.huiyuan_id, - yonghu_id=str(to_uid), - huiyuan_zhuangtai=1, - jieshao=before.get('jieshao') or '', - daoqi_time=exp, - club_id=to_club, - is_trial=bool(r.is_trial), - has_used_trial=bool(r.has_used_trial), - ) - 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': r.huiyuan_id, - 'meta_json': {'before_src': before, 'before_dst': before_dst}, - }) - return out +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: @@ -663,9 +811,12 @@ def serialize_ledger(ledger: ClubMigrateLedger) -> dict: 'before_src': it.before_src, 'after_src': it.after_src, 'before_dst': it.before_dst, - 'after_dst': it.after_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 ],