feat: add commercial order activation flow
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Header
|
||||
@@ -15,6 +16,10 @@ ROUTE_PERMISSION_ACTIONS = {
|
||||
"get": "tenant_settings:get",
|
||||
"update": "tenant_settings:update",
|
||||
"billing": "tenant_settings:billing",
|
||||
"billing_orders": "tenant_settings:billing_orders",
|
||||
"create_billing_order": "tenant_settings:create_billing_order",
|
||||
"activate_billing_order": "tenant_settings:activate_billing_order",
|
||||
"update_billing_plan": "tenant_settings:update_billing_plan",
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +54,152 @@ def get_current_tenant_billing(
|
||||
return success_response(tenant_service.get_current_billing(current_user))
|
||||
|
||||
|
||||
@router.get("/billing/orders")
|
||||
def list_current_tenant_commercial_orders(
|
||||
limit: int = 50,
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: dict = Depends(require_current_user),
|
||||
):
|
||||
return success_response(tenant_service.list_commercial_orders(current_user, limit=limit))
|
||||
|
||||
|
||||
@router.post("/billing/orders")
|
||||
def create_current_tenant_commercial_order(
|
||||
order_type: str = Form(default="subscription"),
|
||||
plan_code: str = Form(...),
|
||||
plan_name: Optional[str] = Form(default=None),
|
||||
billing_cycle: str = Form(default="monthly"),
|
||||
amount_cents: int = Form(default=0),
|
||||
currency: str = Form(default="CNY"),
|
||||
payment_provider: str = Form(default="offline"),
|
||||
invoice_required: bool = Form(default=False),
|
||||
contact_name: Optional[str] = Form(default=None),
|
||||
contact_phone: Optional[str] = Form(default=None),
|
||||
contact_email: Optional[str] = Form(default=None),
|
||||
note: Optional[str] = Form(default=None),
|
||||
review_quota_monthly: Optional[int] = Form(default=None),
|
||||
generation_quota_monthly: Optional[int] = Form(default=None),
|
||||
knowledge_base_quota: Optional[int] = Form(default=None),
|
||||
storage_quota_mb: Optional[int] = Form(default=None),
|
||||
max_users: Optional[int] = Form(default=None),
|
||||
max_storage_mb: Optional[int] = Form(default=None),
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
audit_log_service: AuditLogService = Depends(get_audit_log_service),
|
||||
current_user: dict = Depends(require_current_user),
|
||||
):
|
||||
result = tenant_service.create_commercial_order(
|
||||
current_user=current_user,
|
||||
order_type=order_type,
|
||||
plan_code=plan_code,
|
||||
plan_name=plan_name,
|
||||
billing_cycle=billing_cycle,
|
||||
amount_cents=amount_cents,
|
||||
currency=currency,
|
||||
payment_provider=payment_provider,
|
||||
invoice_required=invoice_required,
|
||||
contact_name=contact_name,
|
||||
contact_phone=contact_phone,
|
||||
contact_email=contact_email,
|
||||
note=note,
|
||||
review_quota_monthly=review_quota_monthly,
|
||||
generation_quota_monthly=generation_quota_monthly,
|
||||
knowledge_base_quota=knowledge_base_quota,
|
||||
storage_quota_mb=storage_quota_mb,
|
||||
max_users=max_users,
|
||||
max_storage_mb=max_storage_mb,
|
||||
)
|
||||
audit_log_service.record_event(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
user_id=current_user.get("id"),
|
||||
action="create_commercial_order",
|
||||
resource_type="commercial_order",
|
||||
resource_id=result["id"],
|
||||
detail_json={
|
||||
"order_no": result["order_no"],
|
||||
"order_type": result["order_type"],
|
||||
"plan_code": result["plan_code"],
|
||||
"amount_cents": result["amount_cents"],
|
||||
"invoice_status": result["invoice_status"],
|
||||
},
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.post("/billing/orders/{order_id}/activate")
|
||||
def activate_current_tenant_commercial_order(
|
||||
order_id: str,
|
||||
payment_provider: str = Form(default="offline"),
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
audit_log_service: AuditLogService = Depends(get_audit_log_service),
|
||||
current_user: dict = Depends(require_current_user),
|
||||
):
|
||||
result = tenant_service.activate_commercial_order(current_user, order_id, payment_provider=payment_provider)
|
||||
audit_log_service.record_event(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
user_id=current_user.get("id"),
|
||||
action="activate_commercial_order",
|
||||
resource_type="commercial_order",
|
||||
resource_id=order_id,
|
||||
detail_json={
|
||||
"order_no": result["order"]["order_no"],
|
||||
"plan_code": result["tenant"]["plan_code"],
|
||||
"billing_status": result["tenant"]["billing_status"],
|
||||
"payment_provider": result["order"]["payment_provider"],
|
||||
},
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.put("/billing/plan")
|
||||
def update_current_tenant_billing_plan(
|
||||
plan_code: str = Form(...),
|
||||
plan_name: str = Form(...),
|
||||
billing_status: str = Form(default="active"),
|
||||
trial_ends_at: Optional[datetime] = Form(default=None),
|
||||
review_quota_monthly: Optional[int] = Form(default=None),
|
||||
generation_quota_monthly: Optional[int] = Form(default=None),
|
||||
knowledge_base_quota: Optional[int] = Form(default=None),
|
||||
storage_quota_mb: Optional[int] = Form(default=None),
|
||||
max_users: Optional[int] = Form(default=None),
|
||||
max_storage_mb: Optional[int] = Form(default=None),
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
audit_log_service: AuditLogService = Depends(get_audit_log_service),
|
||||
current_user: dict = Depends(require_current_user),
|
||||
):
|
||||
result = tenant_service.update_current_billing_plan(
|
||||
current_user=current_user,
|
||||
plan_code=plan_code,
|
||||
plan_name=plan_name,
|
||||
billing_status=billing_status,
|
||||
trial_ends_at=trial_ends_at,
|
||||
review_quota_monthly=review_quota_monthly,
|
||||
generation_quota_monthly=generation_quota_monthly,
|
||||
knowledge_base_quota=knowledge_base_quota,
|
||||
storage_quota_mb=storage_quota_mb,
|
||||
max_users=max_users,
|
||||
max_storage_mb=max_storage_mb,
|
||||
)
|
||||
audit_log_service.record_event(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
user_id=current_user.get("id"),
|
||||
action="update_commercial_billing_plan",
|
||||
resource_type="tenant",
|
||||
resource_id=result["id"],
|
||||
detail_json={
|
||||
"plan_code": result["plan_code"],
|
||||
"plan_name": result["plan_name"],
|
||||
"billing_status": result["billing_status"],
|
||||
"review_quota_monthly": result["review_quota_monthly"],
|
||||
"generation_quota_monthly": result["generation_quota_monthly"],
|
||||
"knowledge_base_quota": result["knowledge_base_quota"],
|
||||
"storage_quota_mb": result["storage_quota_mb"],
|
||||
"max_users": result["max_users"],
|
||||
"max_storage_mb": result["max_storage_mb"],
|
||||
},
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.put("/me")
|
||||
def update_current_tenant_settings(
|
||||
name: str = Form(...),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.commercial_order import CommercialOrder
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.contract_template import ContractTemplate
|
||||
from app.models.contract_template_version import ContractTemplateVersion
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class CommercialOrder(Base):
|
||||
__tablename__ = "commercial_orders"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
requested_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
||||
order_no: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
order_type: Mapped[str] = mapped_column(String(32), nullable=False, default="subscription")
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending_payment", index=True)
|
||||
plan_code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
plan_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
billing_cycle: Mapped[str] = mapped_column(String(32), nullable=False, default="monthly")
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
currency: Mapped[str] = mapped_column(String(16), nullable=False, default="CNY")
|
||||
payment_provider: Mapped[str] = mapped_column(String(32), nullable=False, default="offline")
|
||||
invoice_status: Mapped[str] = mapped_column(String(32), nullable=False, default="not_requested")
|
||||
contact_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
contact_phone: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
contact_email: Mapped[Optional[str]] = mapped_column(String(120), nullable=True)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
review_quota_monthly: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
generation_quota_monthly: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
knowledge_base_quota: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
storage_quota_mb: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
max_users: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
max_storage_mb: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
paid_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
activated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
canceled_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -1,12 +1,14 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import ceil
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.commercial_order import CommercialOrder
|
||||
from app.models.file import File
|
||||
from app.models.generated_contract import GeneratedContract
|
||||
from app.models.knowledge_base import KnowledgeBase
|
||||
@@ -64,6 +66,58 @@ BILLABLE_ACTIONS = {
|
||||
},
|
||||
}
|
||||
|
||||
VALID_BILLING_STATUSES = {"trialing", "active", "past_due", "suspended", "expired", "canceled"}
|
||||
VALID_ORDER_TYPES = {"subscription", "overage", "private_deployment", "implementation_service"}
|
||||
VALID_BILLING_CYCLES = {"monthly", "yearly", "one_time", "trial"}
|
||||
VALID_PAYMENT_PROVIDERS = {"offline", "wechat_pay", "alipay", "bank_transfer", "manual"}
|
||||
COMMERCIAL_PLAN_PRESETS = {
|
||||
"trial": {
|
||||
"plan_name": "试用版",
|
||||
"review_quota_monthly": DEFAULT_REVIEW_QUOTA_MONTHLY,
|
||||
"generation_quota_monthly": DEFAULT_GENERATION_QUOTA_MONTHLY,
|
||||
"knowledge_base_quota": DEFAULT_KNOWLEDGE_BASE_QUOTA,
|
||||
"storage_quota_mb": DEFAULT_STORAGE_QUOTA_MB,
|
||||
"max_users": 3,
|
||||
"max_storage_mb": DEFAULT_STORAGE_QUOTA_MB,
|
||||
},
|
||||
"starter": {
|
||||
"plan_name": "基础版",
|
||||
"review_quota_monthly": 100,
|
||||
"generation_quota_monthly": 80,
|
||||
"knowledge_base_quota": 1,
|
||||
"storage_quota_mb": 2048,
|
||||
"max_users": 5,
|
||||
"max_storage_mb": 2048,
|
||||
},
|
||||
"team": {
|
||||
"plan_name": "团队版",
|
||||
"review_quota_monthly": 800,
|
||||
"generation_quota_monthly": 500,
|
||||
"knowledge_base_quota": 5,
|
||||
"storage_quota_mb": 10240,
|
||||
"max_users": 20,
|
||||
"max_storage_mb": 10240,
|
||||
},
|
||||
"professional": {
|
||||
"plan_name": "专业版",
|
||||
"review_quota_monthly": 4000,
|
||||
"generation_quota_monthly": 2000,
|
||||
"knowledge_base_quota": 20,
|
||||
"storage_quota_mb": 51200,
|
||||
"max_users": 80,
|
||||
"max_storage_mb": 51200,
|
||||
},
|
||||
"enterprise": {
|
||||
"plan_name": "私有部署版",
|
||||
"review_quota_monthly": 0,
|
||||
"generation_quota_monthly": 0,
|
||||
"knowledge_base_quota": 0,
|
||||
"storage_quota_mb": 0,
|
||||
"max_users": 0,
|
||||
"max_storage_mb": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TenantService:
|
||||
def __init__(self, db: Session):
|
||||
@@ -192,6 +246,136 @@ class TenantService:
|
||||
f"{action_config['metric_label']}额度已用完:当前已使用 {used}/{quota}{action_config['unit']},请升级套餐或购买超额包后继续使用。"
|
||||
)
|
||||
|
||||
def update_current_billing_plan(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
plan_code: str,
|
||||
plan_name: str,
|
||||
billing_status: str,
|
||||
trial_ends_at: Optional[datetime] = None,
|
||||
review_quota_monthly: Optional[int] = None,
|
||||
generation_quota_monthly: Optional[int] = None,
|
||||
knowledge_base_quota: Optional[int] = None,
|
||||
storage_quota_mb: Optional[int] = None,
|
||||
max_users: Optional[int] = None,
|
||||
max_storage_mb: Optional[int] = None,
|
||||
) -> dict:
|
||||
self._require_admin(current_user)
|
||||
tenant = self._get_or_create_current_tenant(current_user["tenant_id"])
|
||||
tenant.plan_code = self._normalize_plan_code(plan_code)
|
||||
tenant.plan_name = self._normalize_required_text(plan_name, "plan_name")
|
||||
tenant.billing_status = self._normalize_billing_status(billing_status)
|
||||
if trial_ends_at is not None:
|
||||
tenant.trial_ends_at = self._normalize_datetime(trial_ends_at)
|
||||
self._apply_quota_values(
|
||||
tenant,
|
||||
review_quota_monthly=review_quota_monthly,
|
||||
generation_quota_monthly=generation_quota_monthly,
|
||||
knowledge_base_quota=knowledge_base_quota,
|
||||
storage_quota_mb=storage_quota_mb,
|
||||
max_users=max_users,
|
||||
max_storage_mb=max_storage_mb,
|
||||
)
|
||||
updated = self.repository.update(tenant)
|
||||
return self._to_item(updated)
|
||||
|
||||
def list_commercial_orders(self, current_user: Dict[str, Any], limit: int = 50) -> list[dict]:
|
||||
self._require_admin(current_user)
|
||||
tenant = self._get_or_create_current_tenant(current_user["tenant_id"])
|
||||
normalized_limit = min(max(int(limit or 50), 1), 100)
|
||||
orders = (
|
||||
self.db.query(CommercialOrder)
|
||||
.filter(CommercialOrder.tenant_id == tenant.id)
|
||||
.order_by(CommercialOrder.created_at.desc())
|
||||
.limit(normalized_limit)
|
||||
.all()
|
||||
)
|
||||
return [self._to_order_item(order) for order in orders]
|
||||
|
||||
def create_commercial_order(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
order_type: str,
|
||||
plan_code: str,
|
||||
plan_name: Optional[str] = None,
|
||||
billing_cycle: str = "monthly",
|
||||
amount_cents: int = 0,
|
||||
currency: str = "CNY",
|
||||
payment_provider: str = "offline",
|
||||
invoice_required: bool = False,
|
||||
contact_name: Optional[str] = None,
|
||||
contact_phone: Optional[str] = None,
|
||||
contact_email: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
review_quota_monthly: Optional[int] = None,
|
||||
generation_quota_monthly: Optional[int] = None,
|
||||
knowledge_base_quota: Optional[int] = None,
|
||||
storage_quota_mb: Optional[int] = None,
|
||||
max_users: Optional[int] = None,
|
||||
max_storage_mb: Optional[int] = None,
|
||||
) -> dict:
|
||||
self._require_admin(current_user)
|
||||
tenant = self._get_or_create_current_tenant(current_user["tenant_id"])
|
||||
normalized_plan_code = self._normalize_plan_code(plan_code)
|
||||
preset = COMMERCIAL_PLAN_PRESETS.get(normalized_plan_code, {})
|
||||
order = CommercialOrder(
|
||||
id=f"commercial-order-{uuid4().hex}",
|
||||
tenant_id=tenant.id,
|
||||
requested_by=current_user.get("id"),
|
||||
order_no=self._generate_order_no(),
|
||||
order_type=self._normalize_order_type(order_type),
|
||||
status="pending_payment",
|
||||
plan_code=normalized_plan_code,
|
||||
plan_name=self._normalize_text(plan_name) or str(preset.get("plan_name") or normalized_plan_code),
|
||||
billing_cycle=self._normalize_billing_cycle(billing_cycle),
|
||||
amount_cents=self._normalize_non_negative_int(amount_cents, "amount_cents"),
|
||||
currency=(currency or "CNY").strip().upper()[:16] or "CNY",
|
||||
payment_provider=self._normalize_payment_provider(payment_provider),
|
||||
invoice_status="requested" if invoice_required else "not_requested",
|
||||
contact_name=self._normalize_text(contact_name),
|
||||
contact_phone=self._normalize_text(contact_phone),
|
||||
contact_email=self._normalize_text(contact_email),
|
||||
note=self._normalize_text(note),
|
||||
review_quota_monthly=self._resolve_order_quota(preset, "review_quota_monthly", review_quota_monthly),
|
||||
generation_quota_monthly=self._resolve_order_quota(preset, "generation_quota_monthly", generation_quota_monthly),
|
||||
knowledge_base_quota=self._resolve_order_quota(preset, "knowledge_base_quota", knowledge_base_quota),
|
||||
storage_quota_mb=self._resolve_order_quota(preset, "storage_quota_mb", storage_quota_mb),
|
||||
max_users=self._resolve_order_quota(preset, "max_users", max_users),
|
||||
max_storage_mb=self._resolve_order_quota(preset, "max_storage_mb", max_storage_mb),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self.db.add(order)
|
||||
self.db.commit()
|
||||
self.db.refresh(order)
|
||||
return self._to_order_item(order)
|
||||
|
||||
def activate_commercial_order(self, current_user: Dict[str, Any], order_id: str, payment_provider: str = "offline") -> dict:
|
||||
self._require_admin(current_user)
|
||||
tenant = self._get_or_create_current_tenant(current_user["tenant_id"])
|
||||
order = (
|
||||
self.db.query(CommercialOrder)
|
||||
.filter(CommercialOrder.id == order_id, CommercialOrder.tenant_id == tenant.id)
|
||||
.first()
|
||||
)
|
||||
if order is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="commercial order not found")
|
||||
if order.status == "canceled":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="commercial order is canceled")
|
||||
now = datetime.now(timezone.utc)
|
||||
order.status = "activated"
|
||||
order.payment_provider = self._normalize_payment_provider(payment_provider)
|
||||
order.paid_at = order.paid_at or now
|
||||
order.activated_at = order.activated_at or now
|
||||
order.updated_at = now
|
||||
self._apply_commercial_order_to_tenant(tenant, order)
|
||||
self.db.add(order)
|
||||
self.db.add(tenant)
|
||||
self.db.commit()
|
||||
self.db.refresh(order)
|
||||
self.db.refresh(tenant)
|
||||
return {"order": self._to_order_item(order), "tenant": self._to_item(tenant)}
|
||||
|
||||
def update_current_settings(self, current_user: Dict[str, Any], name: str, code: str, status_value: str) -> dict:
|
||||
self._require_admin(current_user)
|
||||
tenant = self._get_or_create_current_tenant(current_user["tenant_id"])
|
||||
@@ -405,6 +589,133 @@ class TenantService:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
def _normalize_text(self, value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
def _normalize_required_text(self, value: str, field_name: str) -> str:
|
||||
normalized = self._normalize_text(value)
|
||||
if normalized is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is required")
|
||||
return normalized
|
||||
|
||||
def _normalize_plan_code(self, value: str) -> str:
|
||||
normalized = self._normalize_required_text(value, "plan_code").lower()
|
||||
if len(normalized) > 64:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="plan_code is too long")
|
||||
return normalized
|
||||
|
||||
def _normalize_billing_status(self, value: str) -> str:
|
||||
normalized = self._normalize_required_text(value, "billing_status").lower()
|
||||
if normalized not in VALID_BILLING_STATUSES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid billing_status")
|
||||
return normalized
|
||||
|
||||
def _normalize_order_type(self, value: str) -> str:
|
||||
normalized = self._normalize_required_text(value, "order_type").lower()
|
||||
if normalized not in VALID_ORDER_TYPES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid order_type")
|
||||
return normalized
|
||||
|
||||
def _normalize_billing_cycle(self, value: str) -> str:
|
||||
normalized = self._normalize_required_text(value, "billing_cycle").lower()
|
||||
if normalized not in VALID_BILLING_CYCLES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid billing_cycle")
|
||||
return normalized
|
||||
|
||||
def _normalize_payment_provider(self, value: str) -> str:
|
||||
normalized = self._normalize_required_text(value or "offline", "payment_provider").lower()
|
||||
if normalized not in VALID_PAYMENT_PROVIDERS:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid payment_provider")
|
||||
return normalized
|
||||
|
||||
def _normalize_non_negative_int(self, value: Optional[int], field_name: str) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = int(value)
|
||||
if normalized < 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} cannot be negative")
|
||||
return normalized
|
||||
|
||||
def _resolve_order_quota(self, preset: dict, field_name: str, value: Optional[int]) -> Optional[int]:
|
||||
if value is not None:
|
||||
return self._normalize_non_negative_int(value, field_name)
|
||||
preset_value = preset.get(field_name)
|
||||
return int(preset_value) if preset_value is not None else None
|
||||
|
||||
def _apply_quota_values(
|
||||
self,
|
||||
tenant: Tenant,
|
||||
review_quota_monthly: Optional[int] = None,
|
||||
generation_quota_monthly: Optional[int] = None,
|
||||
knowledge_base_quota: Optional[int] = None,
|
||||
storage_quota_mb: Optional[int] = None,
|
||||
max_users: Optional[int] = None,
|
||||
max_storage_mb: Optional[int] = None,
|
||||
) -> None:
|
||||
quota_updates = {
|
||||
"review_quota_monthly": review_quota_monthly,
|
||||
"generation_quota_monthly": generation_quota_monthly,
|
||||
"knowledge_base_quota": knowledge_base_quota,
|
||||
"storage_quota_mb": storage_quota_mb,
|
||||
"max_users": max_users,
|
||||
"max_storage_mb": max_storage_mb,
|
||||
}
|
||||
for field_name, value in quota_updates.items():
|
||||
if value is not None:
|
||||
setattr(tenant, field_name, self._normalize_non_negative_int(value, field_name))
|
||||
|
||||
def _apply_commercial_order_to_tenant(self, tenant: Tenant, order: CommercialOrder) -> None:
|
||||
tenant.plan_code = order.plan_code
|
||||
tenant.plan_name = order.plan_name
|
||||
tenant.billing_status = "active"
|
||||
self._apply_quota_values(
|
||||
tenant,
|
||||
review_quota_monthly=order.review_quota_monthly,
|
||||
generation_quota_monthly=order.generation_quota_monthly,
|
||||
knowledge_base_quota=order.knowledge_base_quota,
|
||||
storage_quota_mb=order.storage_quota_mb,
|
||||
max_users=order.max_users,
|
||||
max_storage_mb=order.max_storage_mb or order.storage_quota_mb,
|
||||
)
|
||||
|
||||
def _generate_order_no(self) -> str:
|
||||
return f"ORD-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:8].upper()}"
|
||||
|
||||
def _to_order_item(self, order: CommercialOrder) -> dict:
|
||||
return {
|
||||
"id": order.id,
|
||||
"tenant_id": order.tenant_id,
|
||||
"requested_by": order.requested_by,
|
||||
"order_no": order.order_no,
|
||||
"order_type": order.order_type,
|
||||
"status": order.status,
|
||||
"plan_code": order.plan_code,
|
||||
"plan_name": order.plan_name,
|
||||
"billing_cycle": order.billing_cycle,
|
||||
"amount_cents": order.amount_cents,
|
||||
"currency": order.currency,
|
||||
"payment_provider": order.payment_provider,
|
||||
"invoice_status": order.invoice_status,
|
||||
"contact_name": order.contact_name,
|
||||
"contact_phone": order.contact_phone,
|
||||
"contact_email": order.contact_email,
|
||||
"note": order.note,
|
||||
"review_quota_monthly": order.review_quota_monthly,
|
||||
"generation_quota_monthly": order.generation_quota_monthly,
|
||||
"knowledge_base_quota": order.knowledge_base_quota,
|
||||
"storage_quota_mb": order.storage_quota_mb,
|
||||
"max_users": order.max_users,
|
||||
"max_storage_mb": order.max_storage_mb,
|
||||
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
|
||||
"activated_at": order.activated_at.isoformat() if order.activated_at else None,
|
||||
"canceled_at": order.canceled_at.isoformat() if order.canceled_at else None,
|
||||
"created_at": order.created_at.isoformat(),
|
||||
"updated_at": order.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
def _raise_billing_block(self, message: str):
|
||||
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=message)
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""create commercial orders"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260518_0025"
|
||||
down_revision = "20260518_0024"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_names(inspector: sa.Inspector) -> set[str]:
|
||||
return set(inspector.get_table_names())
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "commercial_orders" in _table_names(inspector):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"commercial_orders",
|
||||
sa.Column("id", sa.String(length=64), primary_key=True),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("requested_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("order_no", sa.String(length=64), nullable=False),
|
||||
sa.Column("order_type", sa.String(length=32), nullable=False, server_default="subscription"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="pending_payment"),
|
||||
sa.Column("plan_code", sa.String(length=64), nullable=False),
|
||||
sa.Column("plan_name", sa.String(length=64), nullable=False),
|
||||
sa.Column("billing_cycle", sa.String(length=32), nullable=False, server_default="monthly"),
|
||||
sa.Column("amount_cents", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("currency", sa.String(length=16), nullable=False, server_default="CNY"),
|
||||
sa.Column("payment_provider", sa.String(length=32), nullable=False, server_default="offline"),
|
||||
sa.Column("invoice_status", sa.String(length=32), nullable=False, server_default="not_requested"),
|
||||
sa.Column("contact_name", sa.String(length=100), nullable=True),
|
||||
sa.Column("contact_phone", sa.String(length=64), nullable=True),
|
||||
sa.Column("contact_email", sa.String(length=120), nullable=True),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("review_quota_monthly", sa.Integer(), nullable=True),
|
||||
sa.Column("generation_quota_monthly", sa.Integer(), nullable=True),
|
||||
sa.Column("knowledge_base_quota", sa.Integer(), nullable=True),
|
||||
sa.Column("storage_quota_mb", sa.Integer(), nullable=True),
|
||||
sa.Column("max_users", sa.Integer(), nullable=True),
|
||||
sa.Column("max_storage_mb", sa.Integer(), nullable=True),
|
||||
sa.Column("paid_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("canceled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_commercial_orders_tenant_id", "commercial_orders", ["tenant_id"])
|
||||
op.create_index("ix_commercial_orders_requested_by", "commercial_orders", ["requested_by"])
|
||||
op.create_index("ix_commercial_orders_order_no", "commercial_orders", ["order_no"], unique=True)
|
||||
op.create_index("ix_commercial_orders_status", "commercial_orders", ["status"])
|
||||
op.create_index("ix_commercial_orders_created_at", "commercial_orders", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "commercial_orders" not in _table_names(inspector):
|
||||
return
|
||||
|
||||
op.drop_index("ix_commercial_orders_created_at", table_name="commercial_orders")
|
||||
op.drop_index("ix_commercial_orders_status", table_name="commercial_orders")
|
||||
op.drop_index("ix_commercial_orders_order_no", table_name="commercial_orders")
|
||||
op.drop_index("ix_commercial_orders_requested_by", table_name="commercial_orders")
|
||||
op.drop_index("ix_commercial_orders_tenant_id", table_name="commercial_orders")
|
||||
op.drop_table("commercial_orders")
|
||||
@@ -10,6 +10,7 @@ from app.api.tenant_settings import get_audit_log_service, get_tenant_service, r
|
||||
from app.db import Base
|
||||
from app.main import app
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.commercial_order import CommercialOrder
|
||||
from app.models.file import File
|
||||
from app.models.generated_contract import GeneratedContract
|
||||
from app.models.knowledge_base import KnowledgeBase
|
||||
@@ -91,6 +92,154 @@ class StubTenantService:
|
||||
"upgrade_action": {"href": "/pricing"},
|
||||
}
|
||||
|
||||
def list_commercial_orders(self, current_user, limit=50):
|
||||
if current_user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return [
|
||||
{
|
||||
"id": "commercial-order-1",
|
||||
"tenant_id": current_user["tenant_id"],
|
||||
"requested_by": current_user["id"],
|
||||
"order_no": "ORD-202605180001",
|
||||
"order_type": "subscription",
|
||||
"status": "pending_payment",
|
||||
"plan_code": "team",
|
||||
"plan_name": "团队版",
|
||||
"billing_cycle": "monthly",
|
||||
"amount_cents": 199900,
|
||||
"currency": "CNY",
|
||||
"payment_provider": "offline",
|
||||
"invoice_status": "requested",
|
||||
"contact_name": "demo",
|
||||
"contact_phone": "13800000000",
|
||||
"contact_email": "demo@example.com",
|
||||
"note": "upgrade",
|
||||
"review_quota_monthly": 800,
|
||||
"generation_quota_monthly": 500,
|
||||
"knowledge_base_quota": 5,
|
||||
"storage_quota_mb": 10240,
|
||||
"max_users": 20,
|
||||
"max_storage_mb": 10240,
|
||||
"paid_at": None,
|
||||
"activated_at": None,
|
||||
"canceled_at": None,
|
||||
"created_at": "2026-05-18T00:00:00+00:00",
|
||||
"updated_at": "2026-05-18T00:00:00+00:00",
|
||||
}
|
||||
][:limit]
|
||||
|
||||
def create_commercial_order(self, current_user, **kwargs):
|
||||
if current_user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return {
|
||||
"id": "commercial-order-created",
|
||||
"tenant_id": current_user["tenant_id"],
|
||||
"requested_by": current_user["id"],
|
||||
"order_no": "ORD-202605180002",
|
||||
"order_type": kwargs["order_type"],
|
||||
"status": "pending_payment",
|
||||
"plan_code": kwargs["plan_code"],
|
||||
"plan_name": kwargs.get("plan_name") or "团队版",
|
||||
"billing_cycle": kwargs["billing_cycle"],
|
||||
"amount_cents": kwargs["amount_cents"],
|
||||
"currency": kwargs["currency"],
|
||||
"payment_provider": kwargs["payment_provider"],
|
||||
"invoice_status": "requested" if kwargs["invoice_required"] else "not_requested",
|
||||
"contact_name": kwargs.get("contact_name"),
|
||||
"contact_phone": kwargs.get("contact_phone"),
|
||||
"contact_email": kwargs.get("contact_email"),
|
||||
"note": kwargs.get("note"),
|
||||
"review_quota_monthly": kwargs.get("review_quota_monthly"),
|
||||
"generation_quota_monthly": kwargs.get("generation_quota_monthly"),
|
||||
"knowledge_base_quota": kwargs.get("knowledge_base_quota"),
|
||||
"storage_quota_mb": kwargs.get("storage_quota_mb"),
|
||||
"max_users": kwargs.get("max_users"),
|
||||
"max_storage_mb": kwargs.get("max_storage_mb"),
|
||||
"paid_at": None,
|
||||
"activated_at": None,
|
||||
"canceled_at": None,
|
||||
"created_at": "2026-05-18T00:00:00+00:00",
|
||||
"updated_at": "2026-05-18T00:00:00+00:00",
|
||||
}
|
||||
|
||||
def activate_commercial_order(self, current_user, order_id, payment_provider="offline"):
|
||||
if current_user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return {
|
||||
"order": {
|
||||
"id": order_id,
|
||||
"tenant_id": current_user["tenant_id"],
|
||||
"requested_by": current_user["id"],
|
||||
"order_no": "ORD-202605180002",
|
||||
"order_type": "subscription",
|
||||
"status": "activated",
|
||||
"plan_code": "team",
|
||||
"plan_name": "团队版",
|
||||
"billing_cycle": "monthly",
|
||||
"amount_cents": 199900,
|
||||
"currency": "CNY",
|
||||
"payment_provider": payment_provider,
|
||||
"invoice_status": "requested",
|
||||
"contact_name": "demo",
|
||||
"contact_phone": "13800000000",
|
||||
"contact_email": "demo@example.com",
|
||||
"note": "paid",
|
||||
"review_quota_monthly": 800,
|
||||
"generation_quota_monthly": 500,
|
||||
"knowledge_base_quota": 5,
|
||||
"storage_quota_mb": 10240,
|
||||
"max_users": 20,
|
||||
"max_storage_mb": 10240,
|
||||
"paid_at": "2026-05-18T00:00:00+00:00",
|
||||
"activated_at": "2026-05-18T00:00:00+00:00",
|
||||
"canceled_at": None,
|
||||
"created_at": "2026-05-18T00:00:00+00:00",
|
||||
"updated_at": "2026-05-18T00:00:00+00:00",
|
||||
},
|
||||
"tenant": {
|
||||
"id": current_user["tenant_id"],
|
||||
"name": "demo",
|
||||
"code": "demo",
|
||||
"status": "active",
|
||||
"plan_name": "团队版",
|
||||
"plan_code": "team",
|
||||
"billing_status": "active",
|
||||
"trial_started_at": None,
|
||||
"trial_ends_at": None,
|
||||
"review_quota_monthly": 800,
|
||||
"generation_quota_monthly": 500,
|
||||
"knowledge_base_quota": 5,
|
||||
"storage_quota_mb": 10240,
|
||||
"max_users": 20,
|
||||
"max_storage_mb": 10240,
|
||||
"created_at": "2026-05-18T00:00:00+00:00",
|
||||
"updated_at": "2026-05-18T00:00:00+00:00",
|
||||
},
|
||||
}
|
||||
|
||||
def update_current_billing_plan(self, current_user, **kwargs):
|
||||
if current_user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return {
|
||||
"id": current_user["tenant_id"],
|
||||
"name": "demo",
|
||||
"code": "demo",
|
||||
"status": "active",
|
||||
"plan_name": kwargs["plan_name"],
|
||||
"plan_code": kwargs["plan_code"],
|
||||
"billing_status": kwargs["billing_status"],
|
||||
"trial_started_at": None,
|
||||
"trial_ends_at": kwargs["trial_ends_at"].isoformat() if kwargs.get("trial_ends_at") else None,
|
||||
"review_quota_monthly": kwargs.get("review_quota_monthly"),
|
||||
"generation_quota_monthly": kwargs.get("generation_quota_monthly"),
|
||||
"knowledge_base_quota": kwargs.get("knowledge_base_quota"),
|
||||
"storage_quota_mb": kwargs.get("storage_quota_mb"),
|
||||
"max_users": kwargs.get("max_users"),
|
||||
"max_storage_mb": kwargs.get("max_storage_mb"),
|
||||
"created_at": "2026-05-18T00:00:00+00:00",
|
||||
"updated_at": "2026-05-18T00:00:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
class TenantSettingsApiTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -128,6 +277,96 @@ class TenantSettingsApiTestCase(unittest.TestCase):
|
||||
self.assertEqual(body["plan"]["code"], "trial")
|
||||
self.assertEqual(body["metrics"][0]["code"], "reviews")
|
||||
|
||||
def test_admin_can_list_commercial_orders(self):
|
||||
app.dependency_overrides[require_current_user] = lambda: {
|
||||
"id": "user-admin",
|
||||
"tenant_id": "tenant-demo-001",
|
||||
"role": "admin",
|
||||
}
|
||||
|
||||
response = self.client.get("/api/v1/tenant-settings/billing/orders")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"][0]["order_no"], "ORD-202605180001")
|
||||
|
||||
def test_admin_can_create_commercial_order(self):
|
||||
app.dependency_overrides[require_current_user] = lambda: {
|
||||
"id": "user-admin",
|
||||
"tenant_id": "tenant-demo-001",
|
||||
"role": "admin",
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/tenant-settings/billing/orders",
|
||||
data={
|
||||
"order_type": "subscription",
|
||||
"plan_code": "team",
|
||||
"plan_name": "团队版",
|
||||
"billing_cycle": "monthly",
|
||||
"amount_cents": "199900",
|
||||
"payment_provider": "offline",
|
||||
"invoice_required": "true",
|
||||
"review_quota_monthly": "800",
|
||||
"generation_quota_monthly": "500",
|
||||
"knowledge_base_quota": "5",
|
||||
"storage_quota_mb": "10240",
|
||||
"max_users": "20",
|
||||
"max_storage_mb": "10240",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()["data"]
|
||||
self.assertEqual(body["status"], "pending_payment")
|
||||
self.assertEqual(body["invoice_status"], "requested")
|
||||
self.assertEqual(self.audit_log_service.events[0]["action"], "create_commercial_order")
|
||||
|
||||
def test_admin_can_activate_commercial_order(self):
|
||||
app.dependency_overrides[require_current_user] = lambda: {
|
||||
"id": "user-admin",
|
||||
"tenant_id": "tenant-demo-001",
|
||||
"role": "admin",
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
"/api/v1/tenant-settings/billing/orders/commercial-order-created/activate",
|
||||
data={"payment_provider": "bank_transfer"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()["data"]
|
||||
self.assertEqual(body["order"]["status"], "activated")
|
||||
self.assertEqual(body["tenant"]["billing_status"], "active")
|
||||
self.assertEqual(self.audit_log_service.events[0]["action"], "activate_commercial_order")
|
||||
|
||||
def test_admin_can_update_billing_plan(self):
|
||||
app.dependency_overrides[require_current_user] = lambda: {
|
||||
"id": "user-admin",
|
||||
"tenant_id": "tenant-demo-001",
|
||||
"role": "admin",
|
||||
}
|
||||
|
||||
response = self.client.put(
|
||||
"/api/v1/tenant-settings/billing/plan",
|
||||
data={
|
||||
"plan_code": "professional",
|
||||
"plan_name": "专业版",
|
||||
"billing_status": "active",
|
||||
"review_quota_monthly": "4000",
|
||||
"generation_quota_monthly": "2000",
|
||||
"knowledge_base_quota": "20",
|
||||
"storage_quota_mb": "51200",
|
||||
"max_users": "80",
|
||||
"max_storage_mb": "51200",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()["data"]
|
||||
self.assertEqual(body["plan_code"], "professional")
|
||||
self.assertEqual(body["billing_status"], "active")
|
||||
self.assertEqual(self.audit_log_service.events[0]["action"], "update_commercial_billing_plan")
|
||||
|
||||
def test_admin_can_update_tenant_settings(self):
|
||||
app.dependency_overrides[require_current_user] = lambda: {
|
||||
"id": "user-admin",
|
||||
@@ -381,6 +620,39 @@ class TenantSettingsApiTestCase(unittest.TestCase):
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
def test_commercial_order_activation_updates_tenant_plan_and_quota(self):
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine, future=True)()
|
||||
try:
|
||||
service = TenantService(session)
|
||||
service.ensure_default_tenant()
|
||||
current_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"}
|
||||
|
||||
order = service.create_commercial_order(
|
||||
current_user=current_user,
|
||||
order_type="subscription",
|
||||
plan_code="team",
|
||||
billing_cycle="monthly",
|
||||
amount_cents=199900,
|
||||
payment_provider="offline",
|
||||
invoice_required=True,
|
||||
)
|
||||
result = service.activate_commercial_order(current_user, order["id"], payment_provider="bank_transfer")
|
||||
|
||||
self.assertEqual(result["order"]["status"], "activated")
|
||||
self.assertEqual(result["order"]["payment_provider"], "bank_transfer")
|
||||
self.assertEqual(result["tenant"]["plan_code"], "team")
|
||||
self.assertEqual(result["tenant"]["billing_status"], "active")
|
||||
self.assertEqual(result["tenant"]["review_quota_monthly"], 800)
|
||||
self.assertEqual(result["tenant"]["generation_quota_monthly"], 500)
|
||||
self.assertEqual(result["tenant"]["knowledge_base_quota"], 5)
|
||||
self.assertEqual(result["tenant"]["storage_quota_mb"], 10240)
|
||||
self.assertEqual(session.query(CommercialOrder).count(), 1)
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Button, Card, Progress, Space, Statistic, Table, Tag, Typography, message } from "antd";
|
||||
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Progress, Select, Space, Statistic, Table, Tag, Typography, message } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getTenantBillingSummary } from "@/services/tenant-settings";
|
||||
import { commercialPlans } from "@/lib/commercial-plans";
|
||||
import {
|
||||
activateCommercialOrder,
|
||||
createCommercialOrder,
|
||||
getCommercialOrders,
|
||||
getTenantBillingSummary,
|
||||
updateTenantBillingPlan,
|
||||
} from "@/services/tenant-settings";
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
import type { TenantBillingMetric, TenantBillingSummary } from "@/types/api";
|
||||
import type { CommercialOrderItem, TenantBillingMetric, TenantBillingSummary } from "@/types/api";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
type OrderFormValues = {
|
||||
order_type: string;
|
||||
plan_code: string;
|
||||
plan_name?: string;
|
||||
billing_cycle: string;
|
||||
amount_yuan?: number;
|
||||
invoice_required?: boolean;
|
||||
contact_name?: string;
|
||||
contact_phone?: string;
|
||||
contact_email?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type BillingPlanFormValues = {
|
||||
plan_code: string;
|
||||
plan_name: string;
|
||||
billing_status: string;
|
||||
trial_ends_at?: string;
|
||||
review_quota_monthly?: number;
|
||||
generation_quota_monthly?: number;
|
||||
knowledge_base_quota?: number;
|
||||
storage_quota_mb?: number;
|
||||
max_users?: number;
|
||||
max_storage_mb?: number;
|
||||
};
|
||||
|
||||
const statusMeta: Record<TenantBillingMetric["status"], { color: string; label: string }> = {
|
||||
ok: { color: "green", label: "正常" },
|
||||
warning: { color: "orange", label: "接近上限" },
|
||||
@@ -18,6 +51,20 @@ const statusMeta: Record<TenantBillingMetric["status"], { color: string; label:
|
||||
info: { color: "blue", label: "统计中" },
|
||||
};
|
||||
|
||||
const orderStatusMeta: Record<string, { color: string; label: string }> = {
|
||||
pending_payment: { color: "orange", label: "待收款" },
|
||||
activated: { color: "green", label: "已生效" },
|
||||
canceled: { color: "default", label: "已取消" },
|
||||
};
|
||||
|
||||
const invoiceStatusMeta: Record<string, { color: string; label: string }> = {
|
||||
not_requested: { color: "default", label: "不开发票" },
|
||||
requested: { color: "blue", label: "待开票" },
|
||||
issued: { color: "green", label: "已开票" },
|
||||
};
|
||||
|
||||
const planOptions = commercialPlans.map((plan) => ({ value: plan.key, label: plan.name }));
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -33,6 +80,10 @@ function formatUsage(metric: TenantBillingMetric) {
|
||||
return `${used} / ${metric.quota} ${metric.unit}`;
|
||||
}
|
||||
|
||||
function formatMoney(amountCents: number, currency: string) {
|
||||
return `${currency} ${(amountCents / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function resolveProgressStatus(status: TenantBillingMetric["status"]): "normal" | "active" | "exception" {
|
||||
if (status === "exceeded") {
|
||||
return "exception";
|
||||
@@ -43,36 +94,74 @@ function resolveProgressStatus(status: TenantBillingMetric["status"]): "normal"
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
function getPlanName(planCode: string) {
|
||||
return commercialPlans.find((plan) => plan.key === planCode)?.name ?? planCode;
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
const [orderForm] = Form.useForm<OrderFormValues>();
|
||||
const [planForm] = Form.useForm<BillingPlanFormValues>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [summary, setSummary] = useState<TenantBillingSummary | null>(null);
|
||||
const [orders, setOrders] = useState<CommercialOrderItem[]>([]);
|
||||
const [submittingOrder, setSubmittingOrder] = useState(false);
|
||||
const [submittingPlan, setSubmittingPlan] = useState(false);
|
||||
const [activatingOrderId, setActivatingOrderId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSummary = async () => {
|
||||
if (currentUser?.role !== "admin") {
|
||||
setLoading(false);
|
||||
const loadBillingData = useCallback(async () => {
|
||||
if (currentUser?.role !== "admin") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [summaryResult, ordersResult] = await Promise.all([getTenantBillingSummary(), getCommercialOrders()]);
|
||||
if (summaryResult.code !== 0) {
|
||||
messageApi.error(summaryResult.message || "用量统计加载失败");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getTenantBillingSummary();
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "用量统计加载失败");
|
||||
return;
|
||||
}
|
||||
setSummary(result.data);
|
||||
} catch {
|
||||
messageApi.error("用量统计加载失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (ordersResult.code !== 0) {
|
||||
messageApi.error(ordersResult.message || "商业订单加载失败");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
void loadSummary();
|
||||
setSummary(summaryResult.data);
|
||||
setOrders(ordersResult.data);
|
||||
} catch (error: unknown) {
|
||||
messageApi.error(getErrorMessage(error, "商业化数据加载失败,请稍后重试"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentUser?.role, messageApi]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBillingData();
|
||||
}, [loadBillingData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
planForm.setFieldsValue({
|
||||
plan_code: summary.plan.code,
|
||||
plan_name: summary.plan.name,
|
||||
billing_status: summary.plan.billing_status,
|
||||
trial_ends_at: summary.plan.trial_ends_at ?? undefined,
|
||||
review_quota_monthly: summary.tenant.review_quota_monthly ?? undefined,
|
||||
generation_quota_monthly: summary.tenant.generation_quota_monthly ?? undefined,
|
||||
knowledge_base_quota: summary.tenant.knowledge_base_quota ?? undefined,
|
||||
storage_quota_mb: summary.tenant.storage_quota_mb ?? undefined,
|
||||
max_users: summary.tenant.max_users,
|
||||
max_storage_mb: summary.tenant.max_storage_mb,
|
||||
});
|
||||
}, [planForm, summary]);
|
||||
|
||||
const columns = useMemo<ColumnsType<TenantBillingMetric>>(
|
||||
() => [
|
||||
{
|
||||
@@ -107,6 +196,151 @@ export default function BillingPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleActivateOrder = useCallback(
|
||||
async (order: CommercialOrderItem) => {
|
||||
Modal.confirm({
|
||||
title: "确认收款并同步套餐?",
|
||||
content: `订单 ${order.order_no} 将被标记为已收款,并把租户套餐切换为 ${order.plan_name}。`,
|
||||
okText: "确认生效",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
setActivatingOrderId(order.id);
|
||||
try {
|
||||
const result = await activateCommercialOrder(order.id, "bank_transfer");
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "订单生效失败");
|
||||
return;
|
||||
}
|
||||
messageApi.success("订单已生效,套餐额度已同步");
|
||||
await loadBillingData();
|
||||
} catch (error: unknown) {
|
||||
messageApi.error(getErrorMessage(error, "订单生效失败,请稍后重试"));
|
||||
} finally {
|
||||
setActivatingOrderId(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[loadBillingData, messageApi],
|
||||
);
|
||||
|
||||
const orderColumns = useMemo<ColumnsType<CommercialOrderItem>>(
|
||||
() => [
|
||||
{
|
||||
title: "订单号",
|
||||
dataIndex: "order_no",
|
||||
key: "order_no",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "套餐",
|
||||
key: "plan",
|
||||
render: (_, order) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text>{order.plan_name}</Text>
|
||||
<Text type="secondary">{order.plan_code}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "金额",
|
||||
key: "amount",
|
||||
render: (_, order) => formatMoney(order.amount_cents, order.currency),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
render: (_, order) => {
|
||||
const meta = orderStatusMeta[order.status] ?? { color: "default", label: order.status };
|
||||
return <Tag color={meta.color}>{meta.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "发票",
|
||||
key: "invoice",
|
||||
render: (_, order) => {
|
||||
const meta = invoiceStatusMeta[order.invoice_status] ?? { color: "default", label: order.invoice_status };
|
||||
return <Tag color={meta.color}>{meta.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "created_at",
|
||||
render: (_, order) => formatDateTime(order.created_at),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
fixed: "right",
|
||||
render: (_, order) =>
|
||||
order.status === "pending_payment" ? (
|
||||
<Button size="small" loading={activatingOrderId === order.id} onClick={() => void handleActivateOrder(order)}>
|
||||
确认收款并生效
|
||||
</Button>
|
||||
) : (
|
||||
<Text type="secondary">已处理</Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
[activatingOrderId, handleActivateOrder],
|
||||
);
|
||||
|
||||
const handleOrderPlanChange = (planCode: string) => {
|
||||
orderForm.setFieldsValue({ plan_name: getPlanName(planCode) });
|
||||
};
|
||||
|
||||
const handlePlanCodeChange = (planCode: string) => {
|
||||
planForm.setFieldsValue({ plan_name: getPlanName(planCode) });
|
||||
};
|
||||
|
||||
const handleCreateOrder = async (values: OrderFormValues) => {
|
||||
setSubmittingOrder(true);
|
||||
try {
|
||||
const result = await createCommercialOrder({
|
||||
order_type: values.order_type,
|
||||
plan_code: values.plan_code,
|
||||
plan_name: values.plan_name || getPlanName(values.plan_code),
|
||||
billing_cycle: values.billing_cycle,
|
||||
amount_cents: Math.round((values.amount_yuan ?? 0) * 100),
|
||||
currency: "CNY",
|
||||
payment_provider: "offline",
|
||||
invoice_required: Boolean(values.invoice_required),
|
||||
contact_name: values.contact_name,
|
||||
contact_phone: values.contact_phone,
|
||||
contact_email: values.contact_email,
|
||||
note: values.note,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "订单创建失败");
|
||||
return;
|
||||
}
|
||||
messageApi.success("购买意向/订单已创建");
|
||||
orderForm.resetFields();
|
||||
await loadBillingData();
|
||||
} catch (error: unknown) {
|
||||
messageApi.error(getErrorMessage(error, "订单创建失败,请稍后重试"));
|
||||
} finally {
|
||||
setSubmittingOrder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePlan = async (values: BillingPlanFormValues) => {
|
||||
setSubmittingPlan(true);
|
||||
try {
|
||||
const result = await updateTenantBillingPlan(values);
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "套餐额度调整失败");
|
||||
return;
|
||||
}
|
||||
messageApi.success("套餐额度已更新");
|
||||
await loadBillingData();
|
||||
} catch (error: unknown) {
|
||||
messageApi.error(getErrorMessage(error, "套餐额度调整失败,请稍后重试"));
|
||||
} finally {
|
||||
setSubmittingPlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (currentUser?.role !== "admin") {
|
||||
return (
|
||||
<main className="page-shell review-history-page">
|
||||
@@ -131,12 +365,12 @@ export default function BillingPage() {
|
||||
return (
|
||||
<main className="page-shell review-page-grid">
|
||||
{contextHolder}
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
<section className="review-page-hero">
|
||||
<div className="review-page-hero-copy">
|
||||
<span className="landing-badge">Usage & Billing</span>
|
||||
<Title level={2}>用量统计、试用到期与超额提示</Title>
|
||||
<Text type="secondary">管理员可以核对本月真实使用量、试用剩余时间和当前套餐额度。</Text>
|
||||
<Title level={2}>用量统计、试用到期、订单和套餐生效</Title>
|
||||
<Text type="secondary">先把购买意向、线下收款确认、发票状态和套餐额度同步到系统,后续真实支付回调沿用同一条订单链路。</Text>
|
||||
</div>
|
||||
<Card className="review-side-card" variant="borderless" loading={loading}>
|
||||
<span className="landing-panel-eyebrow">Billing Gate</span>
|
||||
@@ -147,7 +381,9 @@ export default function BillingPage() {
|
||||
<ul className="review-page-tip-list">
|
||||
<li>套餐:{summary.plan.name}</li>
|
||||
<li>计费状态:{summary.plan.billing_status}</li>
|
||||
<li>统计周期:{formatDateTime(summary.period.start_at)} 至 {formatDateTime(summary.period.end_at)}</li>
|
||||
<li>
|
||||
统计周期:{formatDateTime(summary.period.start_at)} 至 {formatDateTime(summary.period.end_at)}
|
||||
</li>
|
||||
</ul>
|
||||
) : null}
|
||||
{summary ? (
|
||||
@@ -183,10 +419,135 @@ export default function BillingPage() {
|
||||
<Statistic title="超额指标" value={summary?.summary.exceeded_count ?? 0} suffix="项" />
|
||||
</Card>
|
||||
<Card className="landing-stat-card" loading={loading}>
|
||||
<Statistic title="提醒总数" value={summary?.summary.warning_count ?? 0} suffix="条" />
|
||||
<Statistic title="待处理订单" value={orders.filter((order) => order.status === "pending_payment").length} suffix="笔" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="chat-layout-grid chat-layout-grid-history">
|
||||
<Card className="review-surface-card" title="创建购买意向/订单" loading={loading}>
|
||||
<Form
|
||||
form={orderForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
order_type: "subscription",
|
||||
plan_code: "team",
|
||||
plan_name: getPlanName("team"),
|
||||
billing_cycle: "monthly",
|
||||
amount_yuan: 1999,
|
||||
invoice_required: true,
|
||||
}}
|
||||
onFinish={handleCreateOrder}
|
||||
>
|
||||
<Form.Item label="订单类型" name="order_type" rules={[{ required: true, message: "请选择订单类型" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "subscription", label: "套餐订阅" },
|
||||
{ value: "overage", label: "超额包" },
|
||||
{ value: "private_deployment", label: "私有部署" },
|
||||
{ value: "implementation_service", label: "实施服务" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="目标套餐" name="plan_code" rules={[{ required: true, message: "请选择目标套餐" }]}>
|
||||
<Select options={planOptions} onChange={handleOrderPlanChange} />
|
||||
</Form.Item>
|
||||
<Form.Item name="plan_name" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="计费周期" name="billing_cycle" rules={[{ required: true, message: "请选择计费周期" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "monthly", label: "月付" },
|
||||
{ value: "yearly", label: "年付" },
|
||||
{ value: "one_time", label: "一次性" },
|
||||
{ value: "trial", label: "试点期" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="金额(元)" name="amount_yuan" rules={[{ required: true, message: "请输入订单金额" }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="是否需要发票" name="invoice_required">
|
||||
<Select
|
||||
options={[
|
||||
{ value: true, label: "需要发票" },
|
||||
{ value: false, label: "不需要发票" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="联系人" name="contact_name">
|
||||
<Input placeholder="客户联系人" />
|
||||
</Form.Item>
|
||||
<Form.Item label="联系电话" name="contact_phone">
|
||||
<Input placeholder="手机或座机" />
|
||||
</Form.Item>
|
||||
<Form.Item label="联系邮箱" name="contact_email">
|
||||
<Input placeholder="用于发票或订单确认" />
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="note">
|
||||
<Input.TextArea rows={3} placeholder="例如线下合同编号、付款说明、销售备注" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={submittingOrder}>
|
||||
创建订单
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="review-surface-card" title="管理员套餐/额度调整" loading={loading}>
|
||||
<Form form={planForm} layout="vertical" onFinish={handleUpdatePlan}>
|
||||
<Form.Item label="套餐编码" name="plan_code" rules={[{ required: true, message: "请选择套餐" }]}>
|
||||
<Select options={planOptions} onChange={handlePlanCodeChange} />
|
||||
</Form.Item>
|
||||
<Form.Item label="套餐名称" name="plan_name" rules={[{ required: true, message: "请输入套餐名称" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="计费状态" name="billing_status" rules={[{ required: true, message: "请选择计费状态" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "trialing", label: "试用中" },
|
||||
{ value: "active", label: "已付费生效" },
|
||||
{ value: "past_due", label: "逾期未付" },
|
||||
{ value: "suspended", label: "已暂停" },
|
||||
{ value: "expired", label: "已到期" },
|
||||
{ value: "canceled", label: "已取消" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="审查额度/月" name="review_quota_monthly">
|
||||
<InputNumber min={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="生成额度/月" name="generation_quota_monthly">
|
||||
<InputNumber min={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="知识库数量" name="knowledge_base_quota">
|
||||
<InputNumber min={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="存储额度(MB)" name="storage_quota_mb">
|
||||
<InputNumber min={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="用户上限" name="max_users">
|
||||
<InputNumber min={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="总存储上限(MB)" name="max_storage_mb">
|
||||
<InputNumber min={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={submittingPlan}>
|
||||
保存套餐额度
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="review-surface-card" title="商业订单台账" loading={loading}>
|
||||
<Table<CommercialOrderItem>
|
||||
columns={orderColumns}
|
||||
dataSource={orders}
|
||||
pagination={{ pageSize: 8 }}
|
||||
rowKey="id"
|
||||
scroll={{ x: 980 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className="review-surface-card" title="本月用量明细" loading={loading}>
|
||||
<Table<TenantBillingMetric>
|
||||
columns={columns}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { http } from "@/services/http";
|
||||
import type { ApiResponse, TenantBillingSummary, TenantSettingsItem, TenantSettingsUpdatePayload } from "@/types/api";
|
||||
import type {
|
||||
ApiResponse,
|
||||
BillingPlanUpdatePayload,
|
||||
CommercialOrderActivationResponse,
|
||||
CommercialOrderCreatePayload,
|
||||
CommercialOrderItem,
|
||||
TenantBillingSummary,
|
||||
TenantSettingsItem,
|
||||
TenantSettingsUpdatePayload,
|
||||
} from "@/types/api";
|
||||
|
||||
function buildTenantSettingsFormData(payload: TenantSettingsUpdatePayload) {
|
||||
const formData = new FormData();
|
||||
@@ -19,7 +28,77 @@ export async function updateTenantSettings(payload: TenantSettingsUpdatePayload)
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function appendOptionalFormValue(formData: FormData, key: string, value: unknown) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return;
|
||||
}
|
||||
formData.append(key, String(value));
|
||||
}
|
||||
|
||||
function buildCommercialOrderFormData(payload: CommercialOrderCreatePayload) {
|
||||
const formData = new FormData();
|
||||
formData.append("order_type", payload.order_type);
|
||||
formData.append("plan_code", payload.plan_code);
|
||||
formData.append("billing_cycle", payload.billing_cycle);
|
||||
formData.append("amount_cents", String(payload.amount_cents));
|
||||
formData.append("currency", payload.currency ?? "CNY");
|
||||
formData.append("payment_provider", payload.payment_provider ?? "offline");
|
||||
formData.append("invoice_required", payload.invoice_required ? "true" : "false");
|
||||
appendOptionalFormValue(formData, "plan_name", payload.plan_name);
|
||||
appendOptionalFormValue(formData, "contact_name", payload.contact_name);
|
||||
appendOptionalFormValue(formData, "contact_phone", payload.contact_phone);
|
||||
appendOptionalFormValue(formData, "contact_email", payload.contact_email);
|
||||
appendOptionalFormValue(formData, "note", payload.note);
|
||||
appendOptionalFormValue(formData, "review_quota_monthly", payload.review_quota_monthly);
|
||||
appendOptionalFormValue(formData, "generation_quota_monthly", payload.generation_quota_monthly);
|
||||
appendOptionalFormValue(formData, "knowledge_base_quota", payload.knowledge_base_quota);
|
||||
appendOptionalFormValue(formData, "storage_quota_mb", payload.storage_quota_mb);
|
||||
appendOptionalFormValue(formData, "max_users", payload.max_users);
|
||||
appendOptionalFormValue(formData, "max_storage_mb", payload.max_storage_mb);
|
||||
return formData;
|
||||
}
|
||||
|
||||
function buildBillingPlanFormData(payload: BillingPlanUpdatePayload) {
|
||||
const formData = new FormData();
|
||||
formData.append("plan_code", payload.plan_code);
|
||||
formData.append("plan_name", payload.plan_name);
|
||||
formData.append("billing_status", payload.billing_status);
|
||||
appendOptionalFormValue(formData, "trial_ends_at", payload.trial_ends_at);
|
||||
appendOptionalFormValue(formData, "review_quota_monthly", payload.review_quota_monthly);
|
||||
appendOptionalFormValue(formData, "generation_quota_monthly", payload.generation_quota_monthly);
|
||||
appendOptionalFormValue(formData, "knowledge_base_quota", payload.knowledge_base_quota);
|
||||
appendOptionalFormValue(formData, "storage_quota_mb", payload.storage_quota_mb);
|
||||
appendOptionalFormValue(formData, "max_users", payload.max_users);
|
||||
appendOptionalFormValue(formData, "max_storage_mb", payload.max_storage_mb);
|
||||
return formData;
|
||||
}
|
||||
|
||||
export async function getTenantBillingSummary() {
|
||||
const response = await http.get<ApiResponse<TenantBillingSummary>>("/tenant-settings/billing");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getCommercialOrders(limit = 50) {
|
||||
const response = await http.get<ApiResponse<CommercialOrderItem[]>>("/tenant-settings/billing/orders", { params: { limit } });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createCommercialOrder(payload: CommercialOrderCreatePayload) {
|
||||
const response = await http.post<ApiResponse<CommercialOrderItem>>("/tenant-settings/billing/orders", buildCommercialOrderFormData(payload));
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function activateCommercialOrder(orderId: string, paymentProvider = "offline") {
|
||||
const formData = new FormData();
|
||||
formData.append("payment_provider", paymentProvider);
|
||||
const response = await http.post<ApiResponse<CommercialOrderActivationResponse>>(
|
||||
`/tenant-settings/billing/orders/${orderId}/activate`,
|
||||
formData,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateTenantBillingPlan(payload: BillingPlanUpdatePayload) {
|
||||
const response = await http.put<ApiResponse<TenantSettingsItem>>("/tenant-settings/billing/plan", buildBillingPlanFormData(payload));
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -500,6 +500,76 @@ export type TenantBillingSummary = {
|
||||
};
|
||||
};
|
||||
|
||||
export type CommercialOrderItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
requested_by: string | null;
|
||||
order_no: string;
|
||||
order_type: "subscription" | "overage" | "private_deployment" | "implementation_service";
|
||||
status: "pending_payment" | "activated" | "canceled" | string;
|
||||
plan_code: string;
|
||||
plan_name: string;
|
||||
billing_cycle: "monthly" | "yearly" | "one_time" | "trial" | string;
|
||||
amount_cents: number;
|
||||
currency: string;
|
||||
payment_provider: string;
|
||||
invoice_status: "not_requested" | "requested" | "issued" | string;
|
||||
contact_name: string | null;
|
||||
contact_phone: string | null;
|
||||
contact_email: string | null;
|
||||
note: string | null;
|
||||
review_quota_monthly: number | null;
|
||||
generation_quota_monthly: number | null;
|
||||
knowledge_base_quota: number | null;
|
||||
storage_quota_mb: number | null;
|
||||
max_users: number | null;
|
||||
max_storage_mb: number | null;
|
||||
paid_at: string | null;
|
||||
activated_at: string | null;
|
||||
canceled_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CommercialOrderCreatePayload = {
|
||||
order_type: string;
|
||||
plan_code: string;
|
||||
plan_name?: string;
|
||||
billing_cycle: string;
|
||||
amount_cents: number;
|
||||
currency?: string;
|
||||
payment_provider?: string;
|
||||
invoice_required?: boolean;
|
||||
contact_name?: string;
|
||||
contact_phone?: string;
|
||||
contact_email?: string;
|
||||
note?: string;
|
||||
review_quota_monthly?: number;
|
||||
generation_quota_monthly?: number;
|
||||
knowledge_base_quota?: number;
|
||||
storage_quota_mb?: number;
|
||||
max_users?: number;
|
||||
max_storage_mb?: number;
|
||||
};
|
||||
|
||||
export type CommercialOrderActivationResponse = {
|
||||
order: CommercialOrderItem;
|
||||
tenant: TenantSettingsItem;
|
||||
};
|
||||
|
||||
export type BillingPlanUpdatePayload = {
|
||||
plan_code: string;
|
||||
plan_name: string;
|
||||
billing_status: string;
|
||||
trial_ends_at?: string;
|
||||
review_quota_monthly?: number;
|
||||
generation_quota_monthly?: number;
|
||||
knowledge_base_quota?: number;
|
||||
storage_quota_mb?: number;
|
||||
max_users?: number;
|
||||
max_storage_mb?: number;
|
||||
};
|
||||
|
||||
export type TemplateVariableItem = {
|
||||
name: string;
|
||||
label?: string;
|
||||
|
||||
@@ -2138,6 +2138,74 @@
|
||||
- 本轮开发无 required 阻塞项。
|
||||
- 下一步如果继续商业化主线,建议进入“管理员套餐调整 + 支付前订单/购买意向记录”,然后再接真实支付。
|
||||
|
||||
## 2026-05-18 商业化 Phase2 支付前订单、收款生效与套餐调整闭环
|
||||
### 本轮完成项
|
||||
- 已把上轮剩余的“订单、套餐调整、赠送额度/人工调整”推进为支付前闭环。
|
||||
- 新增商业订单真相源:
|
||||
- 数据表:`commercial_orders`
|
||||
- 模型:`backend/app/models/commercial_order.py`
|
||||
- 迁移:`backend/migrations/versions/20260518_0025_create_commercial_orders.py`
|
||||
- 订单可记录套餐、金额、计费周期、发票状态、联系人、备注、额度快照和生效时间。
|
||||
- 新增后端接口:
|
||||
- `GET /api/v1/tenant-settings/billing/orders`
|
||||
- `POST /api/v1/tenant-settings/billing/orders`
|
||||
- `POST /api/v1/tenant-settings/billing/orders/{order_id}/activate`
|
||||
- `PUT /api/v1/tenant-settings/billing/plan`
|
||||
- 新增后台能力:
|
||||
- 管理员可创建购买意向/商业订单。
|
||||
- 管理员可确认线下收款并让订单生效。
|
||||
- 订单生效后会把租户套餐、计费状态和额度同步到 `tenants` 真相源。
|
||||
- 管理员可直接调整当前租户套餐、计费状态、审查额度、生成额度、知识库额度、存储额度和用户上限。
|
||||
- 前端 `/billing` 已从“用量看板”升级为商业运营台账:
|
||||
- 保留用量统计、试用状态、超额预警。
|
||||
- 新增购买意向/订单创建表单。
|
||||
- 新增商业订单台账。
|
||||
- 新增“确认收款并生效”操作。
|
||||
- 新增管理员套餐/额度调整表单。
|
||||
|
||||
### 本轮验证
|
||||
- gstack 检查:
|
||||
- `GSTACK_OK`
|
||||
- Python 编译检查:
|
||||
- `backend\.venv\Scripts\python.exe -m py_compile backend\app\models\commercial_order.py backend\app\models\__init__.py backend\app\services\tenant_service.py backend\app\api\tenant_settings.py backend\migrations\versions\20260518_0025_create_commercial_orders.py`
|
||||
- 结果:通过。
|
||||
- 后端聚焦测试:
|
||||
- `backend\.venv\Scripts\python.exe -m pytest backend\tests\test_tenant_settings_api.py backend\tests\test_permission_metadata.py`
|
||||
- 结果:`16 passed`。
|
||||
- 前端聚焦 lint:
|
||||
- `npm --prefix frontend run lint -- src/app/billing/page.tsx src/services/tenant-settings.ts src/types/api.ts`
|
||||
- 结果:通过。
|
||||
- 前端生产构建:
|
||||
- `npm --prefix frontend run build`
|
||||
- 结果:通过,`/billing` 已进入构建路由。
|
||||
- `git diff --check`
|
||||
- 结果:通过,仅有 Git CRLF 工作区提示。
|
||||
|
||||
### 当前状态
|
||||
- 商业化 Phase2 已具备支付前经营闭环:
|
||||
- 可统计用量。
|
||||
- 可在到期或超额时硬拦截。
|
||||
- 可创建订单。
|
||||
- 可记录发票需求。
|
||||
- 可确认线下收款。
|
||||
- 可让套餐和额度自动同步生效。
|
||||
- 这里的“自动升级”是指订单被管理员确认收款后,系统自动把租户升级到订单套餐;还不是第三方支付回调后的全自动升级。
|
||||
|
||||
### 未完成项
|
||||
- 真实在线支付仍未接入:
|
||||
- 微信支付或支付宝商户号配置。
|
||||
- 支付二维码/收银台。
|
||||
- 支付回调验签。
|
||||
- 回调后自动生效。
|
||||
- 发票当前只是订单级状态记录,尚未做发票抬头、税号、开票文件和发票流转管理。
|
||||
- 销售线索跟进面板仍未做;当前只完成订单台账,不是完整 CRM。
|
||||
- 本轮未执行远程 Docker 部署,也未对远程 PostgreSQL 执行迁移。
|
||||
|
||||
### 阻塞项
|
||||
- 本轮开发无 required 阻塞项。
|
||||
- 若要进入真实在线支付,需要先确定支付通道:微信支付、支付宝,或先只做银行转账/线下收款。
|
||||
- 若要在开发测试服务器验证本轮功能,需要部署后端和前端,并在远程 PostgreSQL 执行 Alembic 到 `20260518_0025`。
|
||||
|
||||
## 2026-05-16 Phase14 第二批法条全文扩容与告警 dry-run 收口
|
||||
### 本轮完成项
|
||||
- 已新增第二批合同高频法条清单:
|
||||
|
||||
Reference in New Issue
Block a user