459 lines
17 KiB
Python
459 lines
17 KiB
Python
"""后台会员管理:全局 Huiyuan 定义 + 各俱乐部 ClubHuiyuanPrice 售价/分成/体验配置。"""
|
||
import logging
|
||
import random
|
||
import string
|
||
from decimal import Decimal
|
||
|
||
from django.db import transaction, IntegrityError
|
||
|
||
from jituan.constants import CLUB_ID_DEFAULT, DATA_SCOPE_ALL
|
||
from jituan.models import ClubHuiyuanPrice
|
||
from jituan.services.club_context import club_id_for_write, resolve_club_id_from_request, resolve_club_scope
|
||
from jituan.services.club_user_access import list_response_meta
|
||
from products.models import Huiyuan
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
CLUB_WRITE_SCOPE_MSG = '当前为集团汇总视图,请先在顶栏切换到具体俱乐部后再操作'
|
||
|
||
|
||
def club_write_blocked(request):
|
||
return resolve_club_scope(request) == DATA_SCOPE_ALL
|
||
|
||
|
||
def _price_row_to_dict(row):
|
||
if not row:
|
||
return {}
|
||
return {
|
||
'formal_days': int(row.formal_days or 30),
|
||
'trial_enabled': bool(row.trial_enabled),
|
||
'trial_price': str(row.trial_price or 0),
|
||
'trial_days': int(row.trial_days or 0),
|
||
'trial_guanshifc': str(row.trial_guanshifc or 0),
|
||
'trial_zuzhangfc': str(row.trial_zuzhangfc or 0),
|
||
'card_image': (row.card_image or '').strip(),
|
||
'card_bg': (row.card_bg or '').strip(),
|
||
}
|
||
|
||
|
||
def _format_member(m, price_row=None, club_id=None):
|
||
global_jiage = str(m.jiage)
|
||
global_guanshifc = str(m.guanshifc)
|
||
global_zuzhangfc = str(m.zuzhangfc)
|
||
item = {
|
||
'huiyuan_id': m.huiyuan_id,
|
||
'jieshao': m.jieshao,
|
||
'jtjieshao': m.jtjieshao,
|
||
'jiage': global_jiage,
|
||
'guanshifc': global_guanshifc,
|
||
'zuzhangfc': global_zuzhangfc,
|
||
'global_jiage': global_jiage,
|
||
'global_guanshifc': global_guanshifc,
|
||
'global_zuzhangfc': global_zuzhangfc,
|
||
'goumai_cishu': m.goumai_cishu,
|
||
'CreateTime': m.CreateTime.isoformat() if m.CreateTime else None,
|
||
'UpdateTime': m.UpdateTime.isoformat() if m.UpdateTime else None,
|
||
'bankuai_id': m.bankuai_id,
|
||
'club_enabled': True,
|
||
'price_source': 'global',
|
||
'formal_days': 30,
|
||
'trial_enabled': False,
|
||
'trial_price': '0.00',
|
||
'trial_days': 0,
|
||
'trial_guanshifc': '0.00',
|
||
'trial_zuzhangfc': '0.00',
|
||
'is_bundle': bool(getattr(m, 'is_bundle', False)),
|
||
'included_huiyuan_ids': [],
|
||
}
|
||
if price_row is not None:
|
||
item['jiage'] = str(price_row.jiage)
|
||
item['guanshifc'] = str(price_row.guanshifc)
|
||
item['zuzhangfc'] = str(price_row.zuzhangfc)
|
||
item['club_enabled'] = bool(price_row.is_enabled)
|
||
item['price_source'] = 'club'
|
||
item.update(_price_row_to_dict(price_row))
|
||
if item.get('is_bundle') and club_id:
|
||
from jituan.services.huiyuan_bundle import get_bundle_included_ids
|
||
item['included_huiyuan_ids'] = get_bundle_included_ids(club_id, m.huiyuan_id)
|
||
return item
|
||
|
||
|
||
def count_club_member_sales(club_id, huiyuan_id, formal_only=True):
|
||
"""本俱乐部已支付会员单数(用于后台展示,非全局 goumai_cishu)。"""
|
||
from products.models import Czjilu
|
||
qs = Czjilu.query.filter(
|
||
club_id=club_id,
|
||
huiyuan_id=huiyuan_id,
|
||
leixing=1,
|
||
zhuangtai=3,
|
||
)
|
||
if formal_only:
|
||
qs = qs.filter(is_trial=False)
|
||
return qs.count()
|
||
|
||
|
||
def build_member_list_payload(request):
|
||
scope = resolve_club_scope(request)
|
||
club_id = resolve_club_id_from_request(request)
|
||
# P1 优化:限制查询字段,避免加载不必要的大字段
|
||
members = list(Huiyuan.query.all().only(
|
||
'huiyuan_id', 'jieshao', 'jtjieshao', 'jiage',
|
||
'guanshifc', 'zuzhangfc', 'goumai_cishu', 'bankuai_id',
|
||
'CreateTime', 'is_bundle',
|
||
).order_by('-CreateTime'))
|
||
member_list = []
|
||
|
||
price_map = {}
|
||
if scope != DATA_SCOPE_ALL:
|
||
price_map = {
|
||
r.huiyuan_id: r
|
||
for r in ClubHuiyuanPrice.query.filter(club_id=club_id)
|
||
}
|
||
|
||
# P1 优化:批量预取本俱乐部各会员的已支付单数,避免循环内 N+1
|
||
_sales_map = {}
|
||
if scope != DATA_SCOPE_ALL and members:
|
||
from products.models import Czjilu
|
||
from django.db.models import Count
|
||
_sales_rows = Czjilu.query.filter(
|
||
club_id=club_id,
|
||
huiyuan_id__in=[m.huiyuan_id for m in members],
|
||
leixing=1,
|
||
zhuangtai=3,
|
||
is_trial=False,
|
||
).values('huiyuan_id').annotate(cnt=Count('id'))
|
||
_sales_map = {r['huiyuan_id']: r['cnt'] for r in _sales_rows}
|
||
|
||
if scope == DATA_SCOPE_ALL:
|
||
for m in members:
|
||
member_list.append(_format_member(m))
|
||
else:
|
||
# 有本俱乐部专属价:只展示本店上架项;尚无专属价:回落集团默认会员定义。
|
||
# 绝不混入其他俱乐部的 ClubHuiyuanPrice。
|
||
has_club_prices = bool(price_map)
|
||
for m in members:
|
||
row = price_map.get(m.huiyuan_id)
|
||
if has_club_prices:
|
||
if row is None or not row.is_enabled:
|
||
continue
|
||
item = _format_member(m, row, club_id=club_id)
|
||
else:
|
||
item = _format_member(m, club_id=club_id)
|
||
item['goumai_cishu'] = _sales_map.get(m.huiyuan_id, 0)
|
||
member_list.append(item)
|
||
|
||
return {
|
||
'list': member_list,
|
||
'club_id': club_id,
|
||
'scope': scope,
|
||
'price_note': (
|
||
f'当前俱乐部 {club_id} 专属售价;未配置专属价时展示集团默认会员。'
|
||
if scope != DATA_SCOPE_ALL else
|
||
'集团汇总:展示全局默认价;切换具体俱乐部可配置售价与体验会员。'
|
||
),
|
||
**list_response_meta(request),
|
||
}
|
||
|
||
|
||
def build_member_catalog_payload(request):
|
||
"""集团会员类型 + 本俱乐部未上架的可选列表。"""
|
||
club_id = resolve_club_id_from_request(request)
|
||
enabled_ids = set(
|
||
ClubHuiyuanPrice.query.filter(club_id=club_id, is_enabled=True).values_list('huiyuan_id', flat=True)
|
||
)
|
||
# P1 优化:DB 端排除已上架会员,限制字段,避免全表加载 + Python 过滤
|
||
catalog_qs = Huiyuan.query.all()
|
||
if enabled_ids:
|
||
catalog_qs = catalog_qs.exclude(huiyuan_id__in=list(enabled_ids))
|
||
catalog = list(
|
||
catalog_qs.only('huiyuan_id', 'jieshao', 'jtjieshao', 'is_bundle')
|
||
.order_by('-CreateTime')
|
||
.values('huiyuan_id', 'jieshao', 'jtjieshao', 'is_bundle')
|
||
)
|
||
# values 返回 dict 列表,需补 is_bundle bool 化
|
||
for row in catalog:
|
||
row['is_bundle'] = bool(row.get('is_bundle'))
|
||
return {'club_id': club_id, 'catalog': catalog}
|
||
|
||
|
||
def _parse_trial_fields(data, jiage, guanshifc, zuzhangfc):
|
||
formal_days = int(data.get('formal_days') or 30)
|
||
trial_enabled = bool(data.get('trial_enabled'))
|
||
trial_price = Decimal(str(data.get('trial_price') or 0))
|
||
trial_days = int(data.get('trial_days') or 0)
|
||
trial_guanshifc = Decimal(str(data.get('trial_guanshifc') or 0))
|
||
trial_zuzhangfc = Decimal(str(data.get('trial_zuzhangfc') or 0))
|
||
if formal_days < 1 or formal_days > 3650:
|
||
raise ValueError('正式会员天数无效')
|
||
if trial_enabled:
|
||
if trial_price <= 0 or trial_days <= 0:
|
||
raise ValueError('开启体验会员须配置体验价格与天数')
|
||
if trial_guanshifc + trial_zuzhangfc > trial_price:
|
||
raise ValueError('体验管事+组长分成不能大于体验价格')
|
||
if guanshifc + zuzhangfc > jiage:
|
||
raise ValueError('管事分成+组长分成不能大于会员价格')
|
||
return {
|
||
'formal_days': formal_days,
|
||
'trial_enabled': trial_enabled,
|
||
'trial_price': trial_price,
|
||
'trial_days': trial_days,
|
||
'trial_guanshifc': trial_guanshifc,
|
||
'trial_zuzhangfc': trial_zuzhangfc,
|
||
}
|
||
|
||
|
||
def update_member_for_club(request, huiyuan_id, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, extra=None):
|
||
if club_write_blocked(request):
|
||
return None, CLUB_WRITE_SCOPE_MSG
|
||
|
||
club_id = club_id_for_write(request)
|
||
extra = extra or {}
|
||
|
||
try:
|
||
member_preview = Huiyuan.objects.get(huiyuan_id=huiyuan_id)
|
||
if getattr(member_preview, 'is_bundle', False):
|
||
extra = dict(extra)
|
||
extra['trial_enabled'] = False
|
||
except Huiyuan.DoesNotExist:
|
||
pass
|
||
|
||
try:
|
||
trial_fields = _parse_trial_fields(extra, jiage, guanshifc, zuzhangfc)
|
||
except ValueError as e:
|
||
return None, str(e)
|
||
|
||
with transaction.atomic():
|
||
try:
|
||
member = Huiyuan.objects.select_for_update().get(huiyuan_id=huiyuan_id)
|
||
except Huiyuan.DoesNotExist:
|
||
return None, '会员不存在'
|
||
|
||
member.jieshao = jieshao
|
||
member.jtjieshao = jtjieshao
|
||
member.save(update_fields=['jieshao', 'jtjieshao', 'UpdateTime'])
|
||
|
||
row, created = ClubHuiyuanPrice.query.get_or_create(
|
||
club_id=club_id,
|
||
huiyuan_id=huiyuan_id,
|
||
defaults={
|
||
'jiage': jiage,
|
||
'guanshifc': guanshifc,
|
||
'zuzhangfc': zuzhangfc,
|
||
'is_enabled': True,
|
||
'bankuai_id': member.bankuai_id,
|
||
**trial_fields,
|
||
},
|
||
)
|
||
if not created:
|
||
row.jiage = jiage
|
||
row.guanshifc = guanshifc
|
||
row.zuzhangfc = zuzhangfc
|
||
row.is_enabled = True
|
||
for k, v in trial_fields.items():
|
||
setattr(row, k, v)
|
||
if 'card_image' in extra:
|
||
row.card_image = (extra.get('card_image') or '').strip()
|
||
if 'card_bg' in extra:
|
||
row.card_bg = (extra.get('card_bg') or '').strip()
|
||
row.save()
|
||
|
||
if 'included_huiyuan_ids' in extra and getattr(member, 'is_bundle', False):
|
||
from jituan.services.huiyuan_bundle import save_bundle_includes
|
||
_, err = save_bundle_includes(club_id, huiyuan_id, extra.get('included_huiyuan_ids'))
|
||
if err:
|
||
return None, err
|
||
|
||
if club_id == CLUB_ID_DEFAULT:
|
||
member.jiage = jiage
|
||
member.guanshifc = guanshifc
|
||
member.zuzhangfc = zuzhangfc
|
||
member.save(update_fields=['jiage', 'guanshifc', 'zuzhangfc', 'UpdateTime'])
|
||
|
||
return {'huiyuan_id': huiyuan_id}, None
|
||
|
||
|
||
MEMBER_CARD_FOLDER = 'beijing/huiyuan'
|
||
|
||
|
||
def upload_member_card_image(request, huiyuan_id, field, file_obj):
|
||
"""上传会员充值页卡片图,写入 club_huiyuan_price.card_image / card_bg。"""
|
||
import os
|
||
from utils.oss_utils import validate_image, upload_to_oss
|
||
|
||
if club_write_blocked(request):
|
||
return None, CLUB_WRITE_SCOPE_MSG
|
||
if field not in ('card_image', 'card_bg'):
|
||
return None, '无效的图字段'
|
||
ok, msg = validate_image(file_obj)
|
||
if not ok:
|
||
return None, msg
|
||
|
||
club_id = club_id_for_write(request)
|
||
ext = os.path.splitext(getattr(file_obj, 'name', '') or '')[1].lower()
|
||
if ext not in ('.png', '.jpg', '.jpeg', '.webp', '.gif'):
|
||
ext = '.png'
|
||
rel_path = f'{MEMBER_CARD_FOLDER}/{club_id}/{huiyuan_id}_{field}{ext}'
|
||
if not upload_to_oss(file_obj, rel_path):
|
||
return None, '上传 OSS 失败'
|
||
|
||
row = ClubHuiyuanPrice.query.filter(club_id=club_id, huiyuan_id=huiyuan_id).first()
|
||
if not row:
|
||
return None, '请先在当前俱乐部上架该会员'
|
||
setattr(row, field, rel_path)
|
||
row.save(update_fields=[field, 'UpdateTime'])
|
||
return {field: rel_path, 'huiyuan_id': huiyuan_id}, None
|
||
|
||
|
||
def enable_member_for_club(request, huiyuan_id, jiage, guanshifc, zuzhangfc, extra=None):
|
||
"""俱乐部上架集团会员类型。"""
|
||
if club_write_blocked(request):
|
||
return None, CLUB_WRITE_SCOPE_MSG
|
||
club_id = club_id_for_write(request)
|
||
extra = extra or {}
|
||
|
||
try:
|
||
member = Huiyuan.query.get(huiyuan_id=huiyuan_id)
|
||
except Huiyuan.DoesNotExist:
|
||
return None, '集团会员类型不存在'
|
||
|
||
if getattr(member, 'is_bundle', False):
|
||
extra = dict(extra or {})
|
||
extra['trial_enabled'] = False
|
||
|
||
try:
|
||
jiage = Decimal(str(jiage))
|
||
guanshifc = Decimal(str(guanshifc))
|
||
zuzhangfc = Decimal(str(zuzhangfc))
|
||
if jiage <= 0:
|
||
raise ValueError
|
||
trial_fields = _parse_trial_fields(extra, jiage, guanshifc, zuzhangfc)
|
||
except ValueError as e:
|
||
return None, str(e) if str(e) else '金额或体验配置错误'
|
||
|
||
row, created = ClubHuiyuanPrice.query.get_or_create(
|
||
club_id=club_id,
|
||
huiyuan_id=huiyuan_id,
|
||
defaults={
|
||
'jiage': jiage,
|
||
'guanshifc': guanshifc,
|
||
'zuzhangfc': zuzhangfc,
|
||
'is_enabled': True,
|
||
'bankuai_id': member.bankuai_id,
|
||
**trial_fields,
|
||
},
|
||
)
|
||
if not created:
|
||
row.jiage = jiage
|
||
row.guanshifc = guanshifc
|
||
row.zuzhangfc = zuzhangfc
|
||
row.is_enabled = True
|
||
for k, v in trial_fields.items():
|
||
setattr(row, k, v)
|
||
row.save()
|
||
if getattr(member, 'is_bundle', False) and extra.get('included_huiyuan_ids') is not None:
|
||
from jituan.services.huiyuan_bundle import save_bundle_includes
|
||
_, err = save_bundle_includes(club_id, huiyuan_id, extra.get('included_huiyuan_ids'))
|
||
if err:
|
||
return None, err
|
||
return {'huiyuan_id': huiyuan_id}, None
|
||
|
||
|
||
def disable_member_for_club(request, huiyuan_id):
|
||
"""俱乐部软下架。"""
|
||
if club_write_blocked(request):
|
||
return None, CLUB_WRITE_SCOPE_MSG
|
||
club_id = club_id_for_write(request)
|
||
updated = ClubHuiyuanPrice.objects.filter(
|
||
club_id=club_id, huiyuan_id=huiyuan_id, is_enabled=True,
|
||
).update(is_enabled=False)
|
||
if not updated:
|
||
return None, '该会员在本俱乐部未上架或已下架'
|
||
return {'huiyuan_id': huiyuan_id}, None
|
||
|
||
|
||
def add_global_member(jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, bankuai_id=None, is_bundle=False):
|
||
"""集团创建全局会员类型(仅集团后台)。"""
|
||
|
||
def generate_huiyuan_id():
|
||
while True:
|
||
new_id = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||
if not Huiyuan.query.filter(huiyuan_id=new_id).exists():
|
||
return new_id
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
new_id = generate_huiyuan_id()
|
||
create_kwargs = dict(
|
||
huiyuan_id=new_id,
|
||
jieshao=jieshao,
|
||
jiage=jiage,
|
||
guanshifc=guanshifc,
|
||
zuzhangfc=zuzhangfc,
|
||
jtjieshao=jtjieshao or '',
|
||
goumai_cishu=0,
|
||
is_bundle=bool(is_bundle),
|
||
)
|
||
if bankuai_id is not None:
|
||
create_kwargs['bankuai_id'] = bankuai_id
|
||
member = Huiyuan.query.create(**create_kwargs)
|
||
except IntegrityError as e:
|
||
logger.exception('创建全局会员失败')
|
||
return None, f'创建失败:{e}'
|
||
except Exception as e:
|
||
logger.exception('创建全局会员失败')
|
||
return None, f'创建失败:{e}'
|
||
|
||
return {'huiyuan_id': member.huiyuan_id}, None
|
||
|
||
|
||
def create_bundle_member_for_club(
|
||
request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao,
|
||
formal_days=30, included_huiyuan_ids=None,
|
||
):
|
||
"""俱乐部一键创建总会员:新建全局 is_bundle 类型 + 本俱乐部上架 + 配置包含子会员。"""
|
||
if club_write_blocked(request):
|
||
return None, CLUB_WRITE_SCOPE_MSG
|
||
|
||
included_huiyuan_ids = included_huiyuan_ids or []
|
||
if not included_huiyuan_ids:
|
||
return None, '总会员请至少选择一个包含的子会员'
|
||
|
||
try:
|
||
jiage = Decimal(str(jiage))
|
||
guanshifc = Decimal(str(guanshifc))
|
||
zuzhangfc = Decimal(str(zuzhangfc))
|
||
if jiage <= 0:
|
||
raise ValueError
|
||
trial_fields = _parse_trial_fields(
|
||
{'formal_days': formal_days, 'trial_enabled': False},
|
||
jiage, guanshifc, zuzhangfc,
|
||
)
|
||
except ValueError as e:
|
||
return None, str(e) if str(e) else '金额或天数配置错误'
|
||
|
||
with transaction.atomic():
|
||
data, err = add_global_member(
|
||
jieshao, jiage, guanshifc, zuzhangfc, jtjieshao or '', is_bundle=True,
|
||
)
|
||
if err:
|
||
return None, err
|
||
|
||
extra = {
|
||
**trial_fields,
|
||
'included_huiyuan_ids': included_huiyuan_ids,
|
||
}
|
||
_, err = enable_member_for_club(
|
||
request, data['huiyuan_id'], jiage, guanshifc, zuzhangfc, extra=extra,
|
||
)
|
||
if err:
|
||
return None, err
|
||
|
||
return data, None
|
||
|
||
|
||
# 兼容旧名:俱乐部不应再调用
|
||
def add_member_for_club(request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, bankuai_id=None):
|
||
if club_write_blocked(request):
|
||
return None, CLUB_WRITE_SCOPE_MSG
|
||
return None, '俱乐部不可新建会员类型,请从集团会员 catalog 上架'
|