"""配额校验(批次 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)