Files
dealerhub/backend/apps/billing/quota.py
T

210 lines
7.5 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.
"""配额校验(批次 C1):`check_and_count(tenant, kind)` 统一入口。
kind 取值与 `Plan.limits` 的键对应:
- users / products / bills_monthly → 数量型(达到即拒)
- ai_parse_order / ai_ask → 复用给 apps.ai.usage.quota_for()
- batch_managed / finance_ledger → 功能开关(False 即拒)
- storefront → 功能开关
调用方拿到 QuotaExceeded 后翻译成 403 + 升级引导(见 views / 业务拦截点)。
"""
from __future__ import annotations
from django.db.models import Count, Sum
from .models import Plan, Subscription, get_subscription
class QuotaExceeded(Exception):
"""超出套餐限制。payload 供前端弹"升级引导"(列差距 + 跳价格页)。"""
def __init__(self, kind: str, used, limit, plan_code: str, plan_name: str,
upgrade_hint: str = ""):
self.kind = kind
self.used = used
self.limit = limit
self.plan_code = plan_code
self.plan_name = plan_name
self.upgrade_hint = upgrade_hint or "升级套餐即可解除限制"
super().__init__(f"quota exceeded for {kind}: {used}/{limit} @ {plan_code}")
def as_dict(self) -> dict:
return {
"code": "quota_exceeded",
"kind": self.kind,
"detail": f"{self.kind} 已达 {self.used}/{self.limit} 上限(当前套餐:{self.plan_name}),{self.upgrade_hint}",
"used": float(self.used) if isinstance(self.used, (int, float)) else self.used,
"limit": float(self.limit) if isinstance(self.limit, (int, float)) else self.limit,
"plan_code": self.plan_code,
"plan_name": self.plan_name,
"upgrade_url": "/#/pricing",
}
def limits_for(tenant) -> dict:
return get_subscription(tenant).effective_limits()
def plan_of(tenant) -> Plan:
return get_subscription(tenant).plan
def _count_users(tenant) -> int:
"""该租户下的活跃用户数(经审计日志里出现过的操作者去重)。
注:当前用户表尚未挂 tenant 外键(阶段 1 只做了 Org/租户字段预留),
故用"在该租户留下过审计记录的用户数"作为保守口径;
接入成员表后替换此函数即可,调用方无需改动。
"""
from apps.core.models import AuditLog
return (
AuditLog.objects.filter(tenant=tenant, user__isnull=False)
.values("user_id")
.distinct()
.count()
)
def _count_products(tenant) -> int:
from apps.catalog.models import Product
return Product.objects.filter(tenant=tenant, is_deleted=False).count()
def _count_bills_monthly(tenant) -> int:
"""本月单据量 = 销售单 + 进货单(草稿与已过账都算,is_deleted 排除)。"""
from datetime import date
from apps.purchase.models import PurchaseBill
from apps.sales.models import SalesBill
today = date.today()
month_start = today.replace(day=1)
sales = SalesBill.objects.filter(
tenant=tenant, is_deleted=False, bill_date__gte=month_start
).count()
purchase = PurchaseBill.objects.filter(
tenant=tenant, is_deleted=False, bill_date__gte=month_start
).count()
return sales + purchase
COUNTERS = {
"users": _count_users,
"products": _count_products,
"bills_monthly": _count_bills_monthly,
}
FEATURE_FLAGS = {"batch_managed", "finance_ledger", "storefront"}
def usage_snapshot(tenant) -> dict:
"""当前用量 vs 限额(前端"套餐用量"页 + 升级引导弹窗的数据源)。"""
sub = get_subscription(tenant)
limits = sub.effective_limits()
snap = {
"plan": {
"code": sub.plan.code, "name": sub.plan.name,
"price_monthly": float(sub.plan.price_monthly),
},
"status": sub.status,
"trial_ends_at": sub.trial_ends_at.isoformat() if sub.trial_ends_at else None,
"trial_expired": sub.is_trial_expired,
"period_end": sub.period_end.isoformat() if sub.period_end else None,
"items": [],
}
for kind, fn in COUNTERS.items():
limit = int(limits.get(kind) or 0)
used = fn(tenant)
snap["items"].append({
"kind": kind,
"used": used,
"limit": limit,
"unlimited": limit == 0,
"percent": None if limit == 0 else round(used / limit * 100, 1),
})
# AI 次数(复用 ai.usage)
from apps.ai import usage as ai_usage
for kind in (ai_usage.KIND_PARSE_ORDER, ai_usage.KIND_ASK):
limit = int(limits.get(f"ai_{kind}") or 0)
used = ai_usage.monthly_count(tenant, kind)
snap["items"].append({
"kind": f"ai_{kind}",
"used": used,
"limit": limit,
"unlimited": limit == 0,
"percent": None if limit == 0 else round(used / limit * 100, 1),
})
snap["features"] = {k: bool(limits.get(k)) for k in FEATURE_FLAGS}
return snap
def check_and_count(tenant, kind: str, *, delta: int = 1) -> dict:
"""配额校验统一入口。
数量型:used + delta > limit → 拒(limit=0 视为不限)
功能开关:limits[kind] 为假 → 拒
AI 次数:交给 apps.ai.usage.check_quota(保持单一实现)
返回 {used, limit, remaining} 或抛 QuotaExceeded。
"""
sub = get_subscription(tenant)
limits = sub.effective_limits()
plan = sub.plan
if kind in COUNTERS:
limit = int(limits.get(kind) or 0)
used = COUNTERS[kind](tenant)
if limit > 0 and used + delta > limit:
raise QuotaExceeded(kind, used, limit, plan.code, plan.name,
upgrade_hint=_hint_for(kind))
return {
"kind": kind, "used": used, "limit": limit,
"remaining": (limit - used) if limit > 0 else None,
}
if kind in FEATURE_FLAGS:
if not limits.get(kind):
raise QuotaExceeded(kind, 0, 0, plan.code, plan.name,
upgrade_hint=f"{_label(kind)}需专业版")
return {"kind": kind, "enabled": True}
if kind.startswith("ai_"):
from apps.ai import usage as ai_usage
out = ai_usage.check_quota(tenant, kind[3:]) # ai_usage 内部已抛自己的 QuotaExceeded
return out
raise ValueError(f"unknown quota kind: {kind}")
def _label(kind: str) -> str:
return {
"users": "用户数", "products": "商品数", "bills_monthly": "月度单据量",
"ai_parse_order": "AI 录单次数", "ai_ask": "AI 问答次数",
"batch_managed": "批次效期管理", "finance_ledger": "总账核算",
"storefront": "订货商城",
}.get(kind, kind)
def _hint_for(kind: str) -> str:
return {
"users": "升级到基础版可加到 5 个用户,专业版 50 个",
"products": "升级到基础版可管 3000 个商品",
"bills_monthly": "升级到专业版不限制单据量",
"ai_parse_order": "升级套餐可获得更多 AI 录单次数",
"ai_ask": "升级套餐可获得更多 AI 问答次数",
}.get(kind, "升级套餐即可解除限制")
# ------------------------------------------------------------
# 供 apps.ai 反向调用(billing 未安装时 ai 会回落到 settings)
# ------------------------------------------------------------
def quota_for(tenant, kind: str) -> int:
"""返回某配额项的月度/总量上限(0 = 不限)。apps.ai.usage 优先调用本函数。"""
limits = limits_for(tenant)
return int(limits.get(kind) or 0)