Files
Django/jituan/services/huiyuan_bundle.py
XingQue cec7d69199 fix: 停止每次拉单补回已删会员;后台移除标记充值单撤销
ddhq 不再全量 sync 充值单;jian_huiyuan 标记权益已撤销防复活;clumber 只读 huiyuangoumai。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 15:49:07 +08:00

437 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""总会员(包含式会员):购买一个会员,可接多个子会员类型订单。"""
from datetime import timedelta
from django.utils import timezone
from products.models import Huiyuan, Huiyuangoumai
def normalize_huiyuan_id(huiyuan_id):
if huiyuan_id is None:
return ''
return str(huiyuan_id).strip()
def huiyuan_ids_match(a, b):
"""会员 ID 等价比较忽略首尾空格001 与 1 视为相同)。"""
left = normalize_huiyuan_id(a)
right = normalize_huiyuan_id(b)
if not left or not right:
return False
if left == right:
return True
left_core = left.lstrip('0') or '0'
right_core = right.lstrip('0') or '0'
return left_core == right_core
def expand_huiyuan_id_variants(huiyuan_id):
"""老小程序用 == 比较会员 ID补齐 1 / 01 / 001 等写法。"""
hid = normalize_huiyuan_id(huiyuan_id)
if not hid:
return []
variants = []
for candidate in (hid, hid.lstrip('0') or '0'):
if candidate and candidate not in variants:
variants.append(candidate)
core = hid.lstrip('0') or '0'
if core.isdigit():
for width in (3, 4, 6):
padded = core.zfill(width)
if padded not in variants:
variants.append(padded)
return variants
def format_legacy_daoqi(dt):
if not dt:
return ''
if timezone.is_aware(dt):
dt = timezone.localtime(dt)
return dt.strftime('%Y-%m-%d %H:%M:%S')
def _normalize_datetime_for_compare(dt):
"""统一 naive/aware避免 daoqi 与 now 比较时报 TypeError。"""
from django.conf import settings
if dt is None:
return timezone.now()
if settings.USE_TZ:
if timezone.is_naive(dt):
return timezone.make_aware(dt, timezone.get_current_timezone())
return dt
if timezone.is_aware(dt):
return timezone.make_naive(dt, timezone.get_current_timezone())
return dt
def get_bundle_included_ids(club_id, bundle_huiyuan_id):
from jituan.models import ClubHuiyuanBundleInclude
if not club_id or not bundle_huiyuan_id:
return []
return list(
ClubHuiyuanBundleInclude.query.filter(
club_id=club_id,
bundle_huiyuan_id=bundle_huiyuan_id,
).values_list('included_huiyuan_id', flat=True)
)
def is_bundle_huiyuan(huiyuan_id):
try:
return bool(Huiyuan.query.get(huiyuan_id=huiyuan_id).is_bundle)
except Huiyuan.DoesNotExist:
return False
def _membership_record_active(record):
"""会员记录是否未过期(体验=正式;时区安全,不依赖 jiance 副作用)。"""
if not record or int(record.huiyuan_zhuangtai or 0) != 1:
return False
if not record.daoqi_time:
return False
now = _normalize_datetime_for_compare(timezone.now())
daoqi = _normalize_datetime_for_compare(record.daoqi_time)
return daoqi > now
def user_has_huiyuan_access(yonghu_id, required_huiyuan_id, club_id=None):
"""
抢单 /qiangdan 校验:有无对应会员且未过期(体验会员可抢对应类型单)。
总会员按包含子会员判断。提现不走此函数。
"""
req = normalize_huiyuan_id(required_huiyuan_id)
if not req:
return True
def _record_valid(rec):
return _membership_record_active(rec)
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghu_id):
if _record_valid(rec) and huiyuan_ids_match(rec.huiyuan_id, req):
return True
bundle_ids = set(
Huiyuan.query.filter(is_bundle=True).values_list('huiyuan_id', flat=True)
)
if bundle_ids:
req_set = {req, req.lstrip('0') or '0'}
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghu_id, huiyuan_id__in=bundle_ids):
if not _record_valid(rec):
continue
cid = club_id or rec.club_id or ''
included = get_bundle_included_ids(cid, rec.huiyuan_id)
included_set = set()
for x in included:
sx = normalize_huiyuan_id(x)
if sx:
included_set.add(sx)
included_set.add(sx.lstrip('0') or '0')
if req_set & included_set:
return True
if _czjilu_grants_huiyuan_access(yonghu_id, req, club_id):
return True
return False
def _czjilu_grants_huiyuan_access(yonghu_id, required_huiyuan_id, club_id=None):
"""已支付会员充值单(含体验)在有效期内,视同已开通。"""
from jituan.constants import CLUB_ID_DEFAULT
from jituan.services.member_recharge import (
MEMBER_LEIXING,
MEMBER_PAID_STATUS,
_order_is_trial_like,
club_huiyuan_sellable,
)
from products.models import Czjilu
req = normalize_huiyuan_id(required_huiyuan_id)
if not req:
return False
now = _normalize_datetime_for_compare(timezone.now())
for order in Czjilu.query.filter(
yonghuid=yonghu_id,
leixing=MEMBER_LEIXING,
zhuangtai=MEMBER_PAID_STATUS,
).order_by('-CreateTime')[:30]:
if _czjilu_entitlement_revoked(order):
continue
if not huiyuan_ids_match(order.huiyuan_id, req):
continue
cid = getattr(order, 'club_id', None) or club_id or CLUB_ID_DEFAULT
is_trial = _order_is_trial_like(order, cid)
days = int(getattr(order, 'purchase_days', None) or 0)
if days <= 0:
_, _, days = club_huiyuan_sellable(cid, order.huiyuan_id, is_trial=is_trial)
days = max(int(days or 0), 1)
start = _normalize_datetime_for_compare(order.CreateTime or now)
if start + timedelta(days=days) > now:
return True
return False
def user_has_huiyuan_access_any(yonghu_id, required_ids, club_id=None):
for rid in required_ids or []:
if user_has_huiyuan_access(yonghu_id, rid, club_id=club_id):
return True
return False
def resolve_order_membership_ids(order_dict):
"""订单可能关联的会员 ID订单字段 + 商品类型,兼容老数据)。"""
ids = []
mid = normalize_huiyuan_id(
order_dict.get('MembershipID') or order_dict.get('huiyuan_id')
)
if mid:
ids.append(mid)
grab_req = int(order_dict.get('GrabRequirement') or order_dict.get('yaoqiuleixing') or 0)
if grab_req != 1:
return ids
leixing_id = order_dict.get('ProductTypeID') or order_dict.get('leixing_id')
if leixing_id:
try:
from products.models import ShangpinLeixing
row = ShangpinLeixing.query.filter(id=leixing_id).values('huiyuan_id').first()
if row:
lx_hid = normalize_huiyuan_id(row.get('huiyuan_id'))
if lx_hid and not any(huiyuan_ids_match(lx_hid, x) for x in ids):
ids.append(lx_hid)
except Exception:
pass
return ids
def resolve_order_membership_id(order_dict):
ids = resolve_order_membership_ids(order_dict)
return ids[0] if ids else ''
def pool_display_has_valid_member(yonghu_id, required_huiyuan_id, club_id=None):
"""
【仅 dddhq 抢单池展示】有无对应会员且未过期。
体验会员与正式会员完全同等;不读 is_trial不拦截体验。
总会员按包含子会员判断。
"""
return user_has_huiyuan_access(yonghu_id, required_huiyuan_id, club_id=club_id)
def pool_display_has_valid_member_for_order(order_dict, yonghu_id, club_id=None):
"""抢单池:本单所需会员类型是否有效(只看类型+过期,体验=正式)。"""
grab_req = int(order_dict.get('GrabRequirement') or order_dict.get('yaoqiuleixing') or 0)
if grab_req != 1:
return True
required_ids = resolve_order_membership_ids(order_dict)
if not required_ids:
return False
for rid in required_ids:
if pool_display_has_valid_member(yonghu_id, rid, club_id=club_id):
return True
return False
def resolve_grab_pool_member_display(order_dict, yonghu_id, club_id=None, user_clumber=None):
"""
dddhq原样返回抢单要求类型 1/2 + 对应 huiyuan_id不改写成 0
体验会员与正式会员同一套 clumber老端用 clumber.huiyuanid 对订单 huiyuan_id。
"""
grab_req = int(order_dict.get('GrabRequirement') or order_dict.get('yaoqiuleixing') or 0)
membership_id = resolve_order_membership_id(order_dict)
if grab_req != 1:
return grab_req, membership_id or '', True
has_valid = pool_display_has_valid_member_for_order(order_dict, yonghu_id, club_id=club_id)
return 1, membership_id or '', has_valid
def enrich_clumber_item(record):
"""老端 clumber有效期内体验会员与正式会员返回格式完全一致。"""
daoqi_str = format_legacy_daoqi(record.daoqi_time)
item = {
'huiyuanid': record.huiyuan_id,
'huiyuan_id': record.huiyuan_id,
'huiyuanming': record.jieshao or f'会员{record.huiyuan_id}',
'daoqi': daoqi_str,
'huiyuan_zhuangtai': 1,
'shifou_daoqi': False,
}
if is_bundle_huiyuan(record.huiyuan_id):
item['is_bundle'] = True
item['included_huiyuan_ids'] = get_bundle_included_ids(
record.club_id or '', record.huiyuan_id,
)
return item
def _append_clumber_id_variants(items, seen, base_item, huiyuan_id):
"""为老端 == 比较补齐多种会员 ID 写法。"""
for vid in expand_huiyuan_id_variants(huiyuan_id):
if vid in seen:
continue
seen.add(vid)
items.append({
**base_item,
'huiyuanid': vid,
'huiyuan_id': vid,
})
def _append_bundle_sub_clumber(items, seen, record, base_item):
"""总会员有效时,为抢单页展开子会员虚拟条目。"""
if not base_item.get('is_bundle'):
return
bundle_hid = str(record.huiyuan_id)
for sub_id in base_item.get('included_huiyuan_ids') or []:
sid = str(sub_id).strip()
if not sid:
continue
sub_base = {
'huiyuanming': base_item.get('huiyuanming', ''),
'daoqi': base_item.get('daoqi', ''),
'from_bundle': bundle_hid,
'huiyuan_zhuangtai': 1,
'shifou_daoqi': False,
}
_append_clumber_id_variants(items, seen, sub_base, sid)
def _build_user_clumber_core(yonghu_id):
items = []
seen = set()
for record in Huiyuangoumai.query.filter(yonghu_id=yonghu_id):
if not _membership_record_active(record):
continue
base = enrich_clumber_item(record)
_append_clumber_id_variants(items, seen, base, record.huiyuan_id)
_append_bundle_sub_clumber(items, seen, record, base)
return items
def _build_clumber_from_paid_czjilu(yonghu_id):
"""huiyuangoumai 缺失时,用已支付会员充值单合成 clumber体验=正式)。"""
from jituan.constants import CLUB_ID_DEFAULT
from jituan.services.member_recharge import (
MEMBER_LEIXING,
MEMBER_PAID_STATUS,
_order_is_trial_like,
club_huiyuan_sellable,
)
from products.models import Czjilu, Huiyuan
items = []
seen = set()
now = _normalize_datetime_for_compare(timezone.now())
for order in Czjilu.query.filter(
yonghuid=yonghu_id,
leixing=MEMBER_LEIXING,
zhuangtai=MEMBER_PAID_STATUS,
).order_by('-CreateTime')[:30]:
hid = normalize_huiyuan_id(order.huiyuan_id)
if not hid:
continue
cid = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
is_trial = _order_is_trial_like(order, cid)
days = int(getattr(order, 'purchase_days', None) or 0)
if days <= 0:
_, _, days = club_huiyuan_sellable(cid, hid, is_trial=is_trial)
days = max(int(days or 0), 1)
start = _normalize_datetime_for_compare(order.CreateTime or now)
daoqi = start + timedelta(days=days)
if daoqi <= now:
continue
try:
hy = Huiyuan.query.get(huiyuan_id=hid)
ming = hy.jieshao or f'会员{hid}'
except Huiyuan.DoesNotExist:
ming = f'会员{hid}'
base = {
'huiyuanming': ming,
'daoqi': format_legacy_daoqi(daoqi),
'huiyuan_zhuangtai': 1,
'shifou_daoqi': False,
}
_append_clumber_id_variants(items, seen, base, hid)
return items
def ensure_grab_pool_clumber(yonghu_id, club_id=None):
"""
dddhq / dashouxinxi只读 huiyuangoumai 生成 clumber体验=正式)。
不在每次请求全量 sync 充值单(后台移除后会被补回)。
"""
from jituan.services.member_recharge import repair_recent_paid_entitlements_if_missing
items = _build_user_clumber_core(yonghu_id)
if not items:
repair_recent_paid_entitlements_if_missing(yonghu_id, limit=3)
items = _build_user_clumber_core(yonghu_id)
return bool(items), items
def refresh_grab_pool_membership_state(yonghu_id, club_id=None):
"""
抢单池/打手资料:请求入口统一刷新会员状态(同步权益 + clumber
供 /dingdan/ddhq、/yonghu/dashouxinxi、登录 clumber 调用。
"""
return ensure_grab_pool_clumber(yonghu_id, club_id=club_id)
def build_user_clumber(yonghu_id, club_id=None):
"""
老小程序抢单页 clumber体验=正式格式)。
仅展示用;只含未过期会员。
"""
_, items = ensure_grab_pool_clumber(yonghu_id, club_id=club_id)
return items
def build_auth_clumber(yonghu_id, club_id=None):
"""登录 clumber与 dddhq / dashouxinxi 同源,每次刷新会员状态。"""
return refresh_grab_pool_membership_state(yonghu_id, club_id=club_id)[1]
def save_bundle_includes(club_id, bundle_huiyuan_id, included_ids):
"""保存俱乐部总会员包含的子会员(须为本俱乐部已上架的普通会员)。"""
from jituan.models import ClubHuiyuanBundleInclude, ClubHuiyuanPrice
if not is_bundle_huiyuan(bundle_huiyuan_id):
return None, '该会员不是总会员类型'
enabled = set(
ClubHuiyuanPrice.query.filter(club_id=club_id, is_enabled=True).values_list(
'huiyuan_id', flat=True,
)
)
bundle_ids = set(
Huiyuan.query.filter(is_bundle=True).values_list('huiyuan_id', flat=True)
)
cleaned = []
for hid in included_ids or []:
hid = str(hid).strip()
if not hid or hid == bundle_huiyuan_id:
continue
if hid not in enabled:
return None, f'子会员 {hid} 未在本俱乐部上架'
if hid in bundle_ids:
return None, '总会员不能包含另一个总会员'
if not Huiyuan.query.filter(huiyuan_id=hid).exists():
return None, f'子会员 {hid} 不存在'
cleaned.append(hid)
if not cleaned:
return None, '请至少选择一个包含的子会员'
ClubHuiyuanBundleInclude.query.filter(
club_id=club_id, bundle_huiyuan_id=bundle_huiyuan_id,
).delete()
for hid in cleaned:
ClubHuiyuanBundleInclude.query.create(
club_id=club_id,
bundle_huiyuan_id=bundle_huiyuan_id,
included_huiyuan_id=hid,
)
return {'included_huiyuan_ids': cleaned}, None