feat: add commercial order activation flow

This commit is contained in:
2026-05-18 22:27:42 +08:00
parent c6aa566495
commit 34412b26c0
10 changed files with 1460 additions and 29 deletions
+151
View File
@@ -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
View File
@@ -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
+44
View File
@@ -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),
)
+311
View File
@@ -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")
+272
View File
@@ -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()
+389 -28
View File
@@ -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}
+80 -1
View File
@@ -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;
}
+70
View File
@@ -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 收口
### 本轮完成项
- 已新增第二批合同高频法条清单: