baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
"""套餐与订阅模型(批次 C1)。
|
||||
|
||||
设计要点:
|
||||
- `Plan.limits` 是**唯一事实来源**:官网价格表、配额校验、升级引导弹窗都读它,
|
||||
一处定义两处使用(计划要求)。
|
||||
- `Subscription` 每租户一条:free/basic/pro,含试用期与账期。
|
||||
- 试用到期由 Dramatiq cron 每日扫描 → 降级 free(见 tasks.py)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.base_models import TenantScopedModel
|
||||
|
||||
|
||||
# 预置套餐(定价依据:卡在金蝶 698 与用友 1500 之间偏上,凭"带总账 + AI 风控"打差异)
|
||||
DEFAULT_PLANS = [
|
||||
{
|
||||
"code": "free",
|
||||
"name": "免费版",
|
||||
"price_monthly": 0,
|
||||
"sort_order": 0,
|
||||
"description": "30 天全功能试用,适合先跑通流程",
|
||||
"limits": {
|
||||
"users": 1,
|
||||
"products": 100,
|
||||
"bills_monthly": 50,
|
||||
"ai_parse_order": 10,
|
||||
"ai_ask": 20,
|
||||
"batch_managed": True,
|
||||
"finance_ledger": True,
|
||||
"print_templates": 2,
|
||||
"storefront": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"code": "basic",
|
||||
"name": "基础版",
|
||||
"price_monthly": 998,
|
||||
"sort_order": 1,
|
||||
"description": "单店经销商:进销存 + 应收应付 + 打印",
|
||||
"limits": {
|
||||
"users": 5,
|
||||
"products": 3000,
|
||||
"bills_monthly": 2000,
|
||||
"ai_parse_order": 200,
|
||||
"ai_ask": 500,
|
||||
"batch_managed": True,
|
||||
"finance_ledger": True,
|
||||
"print_templates": 20,
|
||||
"storefront": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"code": "pro",
|
||||
"name": "专业版",
|
||||
"price_monthly": 4800,
|
||||
"sort_order": 2,
|
||||
"description": "多仓多业务员:总账 + 批次效期 + AI 风控 + 订货商城",
|
||||
"limits": {
|
||||
"users": 50,
|
||||
"products": 100000,
|
||||
"bills_monthly": 0, # 0 = 不限
|
||||
"ai_parse_order": 2000,
|
||||
"ai_ask": 5000,
|
||||
"batch_managed": True,
|
||||
"finance_ledger": True,
|
||||
"print_templates": 0, # 0 = 不限
|
||||
"storefront": True,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Plan(models.Model):
|
||||
"""套餐定义(全局,非租户级)。"""
|
||||
|
||||
code = models.CharField(max_length=32, unique=True)
|
||||
name = models.CharField(max_length=64)
|
||||
price_monthly = models.DecimalField(max_digits=10, decimal_places=2, default=0,
|
||||
help_text="月费(元);0 = 免费")
|
||||
limits = models.JSONField(default=dict, help_text="配额与功能开关(唯一事实来源)")
|
||||
description = models.CharField(max_length=255, blank=True, default="")
|
||||
sort_order = models.IntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "billing_plan"
|
||||
verbose_name_plural = "套餐"
|
||||
ordering = ["sort_order", "code"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}(¥{self.price_monthly}/月)"
|
||||
|
||||
|
||||
class Subscription(TenantScopedModel):
|
||||
"""租户订阅(每租户一条)。"""
|
||||
|
||||
STATUS_TRIAL = "trial"
|
||||
STATUS_ACTIVE = "active"
|
||||
STATUS_EXPIRED = "expired"
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_TRIAL, "试用中"),
|
||||
(STATUS_ACTIVE, "已订阅"),
|
||||
(STATUS_EXPIRED, "已过期"),
|
||||
]
|
||||
|
||||
plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name="subscriptions")
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_TRIAL)
|
||||
trial_ends_at = models.DateTimeField(null=True, blank=True)
|
||||
period_start = models.DateField(null=True, blank=True)
|
||||
period_end = models.DateField(null=True, blank=True)
|
||||
remark = models.TextField(blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
db_table = "billing_subscription"
|
||||
verbose_name_plural = "订阅"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tenant_id}:{self.plan.code}/{self.status}"
|
||||
|
||||
@property
|
||||
def is_trial_expired(self) -> bool:
|
||||
return bool(
|
||||
self.status == self.STATUS_TRIAL
|
||||
and self.trial_ends_at
|
||||
and timezone.now() > self.trial_ends_at
|
||||
)
|
||||
|
||||
def effective_limits(self) -> dict:
|
||||
"""当前生效配额:试用过期或已过期 → 回落 free 套餐限制。"""
|
||||
if self.status == self.STATUS_EXPIRED or self.is_trial_expired:
|
||||
free = Plan.objects.filter(code="free", is_active=True).first()
|
||||
return dict(free.limits) if free else {}
|
||||
return dict(self.plan.limits or {})
|
||||
|
||||
|
||||
def seed_plans() -> int:
|
||||
"""幂等写入预置套餐。返回新建数量。"""
|
||||
created = 0
|
||||
for spec in DEFAULT_PLANS:
|
||||
_, was_created = Plan.objects.update_or_create(
|
||||
code=spec["code"],
|
||||
defaults={
|
||||
"name": spec["name"],
|
||||
"price_monthly": spec["price_monthly"],
|
||||
"limits": spec["limits"],
|
||||
"description": spec["description"],
|
||||
"sort_order": spec["sort_order"],
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
created += 1 if was_created else 0
|
||||
return created
|
||||
|
||||
|
||||
def subscribe(
|
||||
tenant,
|
||||
*,
|
||||
plan_code: str = "free",
|
||||
status: str = Subscription.STATUS_TRIAL,
|
||||
trial_days: int = 30,
|
||||
) -> Subscription:
|
||||
"""为新租户开通订阅(幂等:已存在则原样返回)。"""
|
||||
existing = Subscription.objects.filter(tenant=tenant).first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
plan = Plan.objects.filter(code=plan_code, is_active=True).first()
|
||||
if plan is None:
|
||||
seed_plans()
|
||||
plan = Plan.objects.filter(code=plan_code, is_active=True).first()
|
||||
|
||||
now = timezone.now()
|
||||
return Subscription.objects.create(
|
||||
tenant=tenant,
|
||||
plan=plan,
|
||||
status=status,
|
||||
trial_ends_at=(now + timedelta(days=trial_days)) if status == Subscription.STATUS_TRIAL else None,
|
||||
period_start=now.date(),
|
||||
period_end=(now + timedelta(days=trial_days)).date(),
|
||||
)
|
||||
|
||||
|
||||
def get_subscription(tenant) -> Subscription:
|
||||
"""取订阅,没有则自动开通 free 试用(保证任何租户都有配额可查)。"""
|
||||
sub = Subscription.objects.filter(tenant=tenant).select_related("plan").first()
|
||||
if sub is None:
|
||||
sub = subscribe(tenant)
|
||||
return sub
|
||||
|
||||
|
||||
def upgrade(tenant, plan_code: str, *, months: int = 1) -> Subscription:
|
||||
"""升级/续订套餐(真实支付接入后,由支付回调调用)。"""
|
||||
sub = get_subscription(tenant)
|
||||
plan = Plan.objects.filter(code=plan_code, is_active=True).first()
|
||||
if plan is None:
|
||||
raise ValueError(f"plan {plan_code} not found")
|
||||
|
||||
today = timezone.now().date()
|
||||
base = sub.period_end if (sub.period_end and sub.period_end > today) else today
|
||||
sub.plan = plan
|
||||
sub.status = Subscription.STATUS_ACTIVE
|
||||
sub.period_start = today
|
||||
sub.period_end = base + timedelta(days=30 * months)
|
||||
sub.trial_ends_at = None
|
||||
sub.save(update_fields=["plan", "status", "period_start", "period_end",
|
||||
"trial_ends_at", "updated_at"])
|
||||
return sub
|
||||
Reference in New Issue
Block a user