feat: add commercial invoice ledger
This commit is contained in:
@@ -20,6 +20,9 @@ ROUTE_PERMISSION_ACTIONS = {
|
||||
"create_billing_order": "tenant_settings:create_billing_order",
|
||||
"activate_billing_order": "tenant_settings:activate_billing_order",
|
||||
"update_billing_plan": "tenant_settings:update_billing_plan",
|
||||
"billing_invoices": "tenant_settings:billing_invoices",
|
||||
"request_billing_invoice": "tenant_settings:request_billing_invoice",
|
||||
"issue_billing_invoice": "tenant_settings:issue_billing_invoice",
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +66,15 @@ def list_current_tenant_commercial_orders(
|
||||
return success_response(tenant_service.list_commercial_orders(current_user, limit=limit))
|
||||
|
||||
|
||||
@router.get("/billing/invoices")
|
||||
def list_current_tenant_commercial_invoices(
|
||||
limit: int = 50,
|
||||
tenant_service: TenantService = Depends(get_tenant_service),
|
||||
current_user: dict = Depends(require_current_user),
|
||||
):
|
||||
return success_response(tenant_service.list_commercial_invoices(current_user, limit=limit))
|
||||
|
||||
|
||||
@router.post("/billing/orders")
|
||||
def create_current_tenant_commercial_order(
|
||||
order_type: str = Form(default="subscription"),
|
||||
@@ -125,6 +137,87 @@ def create_current_tenant_commercial_order(
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.post("/billing/orders/{order_id}/invoice")
|
||||
def request_current_tenant_commercial_invoice(
|
||||
order_id: str,
|
||||
invoice_type: str = Form(default="vat_general"),
|
||||
invoice_title: str = Form(...),
|
||||
tax_number: str = Form(...),
|
||||
registered_address: Optional[str] = Form(default=None),
|
||||
registered_phone: Optional[str] = Form(default=None),
|
||||
bank_name: Optional[str] = Form(default=None),
|
||||
bank_account: Optional[str] = Form(default=None),
|
||||
receiver_email: Optional[str] = Form(default=None),
|
||||
receiver_phone: Optional[str] = Form(default=None),
|
||||
note: Optional[str] = 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.request_commercial_invoice(
|
||||
current_user=current_user,
|
||||
order_id=order_id,
|
||||
invoice_type=invoice_type,
|
||||
invoice_title=invoice_title,
|
||||
tax_number=tax_number,
|
||||
registered_address=registered_address,
|
||||
registered_phone=registered_phone,
|
||||
bank_name=bank_name,
|
||||
bank_account=bank_account,
|
||||
receiver_email=receiver_email,
|
||||
receiver_phone=receiver_phone,
|
||||
note=note,
|
||||
)
|
||||
audit_log_service.record_event(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
user_id=current_user.get("id"),
|
||||
action="request_commercial_invoice",
|
||||
resource_type="commercial_invoice",
|
||||
resource_id=result["id"],
|
||||
detail_json={
|
||||
"order_id": result["order_id"],
|
||||
"invoice_type": result["invoice_type"],
|
||||
"invoice_title": result["invoice_title"],
|
||||
"amount_cents": result["amount_cents"],
|
||||
"status": result["status"],
|
||||
},
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.post("/billing/invoices/{invoice_id}/issue")
|
||||
def issue_current_tenant_commercial_invoice(
|
||||
invoice_id: str,
|
||||
invoice_no: str = Form(...),
|
||||
invoice_file_id: Optional[str] = Form(default=None),
|
||||
note: Optional[str] = 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.issue_commercial_invoice(
|
||||
current_user=current_user,
|
||||
invoice_id=invoice_id,
|
||||
invoice_no=invoice_no,
|
||||
invoice_file_id=invoice_file_id,
|
||||
note=note,
|
||||
)
|
||||
audit_log_service.record_event(
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
user_id=current_user.get("id"),
|
||||
action="issue_commercial_invoice",
|
||||
resource_type="commercial_invoice",
|
||||
resource_id=result["id"],
|
||||
detail_json={
|
||||
"order_id": result["order_id"],
|
||||
"invoice_no": result["invoice_no"],
|
||||
"invoice_file_id": result["invoice_file_id"],
|
||||
"status": result["status"],
|
||||
},
|
||||
)
|
||||
return success_response(result)
|
||||
|
||||
|
||||
@router.post("/billing/orders/{order_id}/activate")
|
||||
def activate_current_tenant_commercial_order(
|
||||
order_id: str,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.commercial_invoice import CommercialInvoice
|
||||
from app.models.commercial_order import CommercialOrder
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.contract_template import ContractTemplate
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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 CommercialInvoice(Base):
|
||||
__tablename__ = "commercial_invoices"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
order_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
requested_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
||||
issued_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
|
||||
invoice_type: Mapped[str] = mapped_column(String(32), nullable=False, default="vat_general")
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="requested", index=True)
|
||||
invoice_title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
tax_number: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
registered_address: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
|
||||
registered_phone: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
bank_name: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
|
||||
bank_account: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
receiver_email: Mapped[Optional[str]] = mapped_column(String(120), nullable=True)
|
||||
receiver_phone: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
currency: Mapped[str] = mapped_column(String(16), nullable=False, default="CNY")
|
||||
invoice_no: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||
invoice_file_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
issued_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),
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.commercial_invoice import CommercialInvoice
|
||||
from app.models.commercial_order import CommercialOrder
|
||||
from app.models.file import File
|
||||
from app.models.generated_contract import GeneratedContract
|
||||
@@ -70,6 +71,7 @@ VALID_BILLING_STATUSES = {"trialing", "active", "past_due", "suspended", "expire
|
||||
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"}
|
||||
VALID_INVOICE_TYPES = {"vat_general", "vat_special", "electronic_general"}
|
||||
COMMERCIAL_PLAN_PRESETS = {
|
||||
"trial": {
|
||||
"plan_name": "试用版",
|
||||
@@ -353,13 +355,7 @@ class TenantService:
|
||||
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")
|
||||
order = self._get_commercial_order(tenant.id, order_id)
|
||||
if order.status == "canceled":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="commercial order is canceled")
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -376,6 +372,117 @@ class TenantService:
|
||||
self.db.refresh(tenant)
|
||||
return {"order": self._to_order_item(order), "tenant": self._to_item(tenant)}
|
||||
|
||||
def list_commercial_invoices(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)
|
||||
invoices = (
|
||||
self.db.query(CommercialInvoice)
|
||||
.filter(CommercialInvoice.tenant_id == tenant.id)
|
||||
.order_by(CommercialInvoice.created_at.desc())
|
||||
.limit(normalized_limit)
|
||||
.all()
|
||||
)
|
||||
return [self._to_invoice_item(invoice) for invoice in invoices]
|
||||
|
||||
def request_commercial_invoice(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
order_id: str,
|
||||
invoice_type: str,
|
||||
invoice_title: str,
|
||||
tax_number: str,
|
||||
registered_address: Optional[str] = None,
|
||||
registered_phone: Optional[str] = None,
|
||||
bank_name: Optional[str] = None,
|
||||
bank_account: Optional[str] = None,
|
||||
receiver_email: Optional[str] = None,
|
||||
receiver_phone: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> dict:
|
||||
self._require_admin(current_user)
|
||||
tenant = self._get_or_create_current_tenant(current_user["tenant_id"])
|
||||
order = self._get_commercial_order(tenant.id, order_id)
|
||||
existing = (
|
||||
self.db.query(CommercialInvoice)
|
||||
.filter(
|
||||
CommercialInvoice.tenant_id == tenant.id,
|
||||
CommercialInvoice.order_id == order.id,
|
||||
CommercialInvoice.status.in_(["requested", "issued"]),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="commercial invoice already exists for this order")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
invoice = CommercialInvoice(
|
||||
id=f"commercial-invoice-{uuid4().hex}",
|
||||
tenant_id=tenant.id,
|
||||
order_id=order.id,
|
||||
requested_by=current_user.get("id"),
|
||||
invoice_type=self._normalize_invoice_type(invoice_type),
|
||||
status="requested",
|
||||
invoice_title=self._normalize_required_text(invoice_title, "invoice_title"),
|
||||
tax_number=self._normalize_required_text(tax_number, "tax_number"),
|
||||
registered_address=self._normalize_text(registered_address),
|
||||
registered_phone=self._normalize_text(registered_phone),
|
||||
bank_name=self._normalize_text(bank_name),
|
||||
bank_account=self._normalize_text(bank_account),
|
||||
receiver_email=self._normalize_text(receiver_email),
|
||||
receiver_phone=self._normalize_text(receiver_phone),
|
||||
amount_cents=order.amount_cents,
|
||||
currency=order.currency,
|
||||
note=self._normalize_text(note),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
order.invoice_status = "requested"
|
||||
order.updated_at = now
|
||||
self.db.add(invoice)
|
||||
self.db.add(order)
|
||||
self.db.commit()
|
||||
self.db.refresh(invoice)
|
||||
return self._to_invoice_item(invoice)
|
||||
|
||||
def issue_commercial_invoice(
|
||||
self,
|
||||
current_user: Dict[str, Any],
|
||||
invoice_id: str,
|
||||
invoice_no: str,
|
||||
invoice_file_id: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> dict:
|
||||
self._require_admin(current_user)
|
||||
tenant = self._get_or_create_current_tenant(current_user["tenant_id"])
|
||||
invoice = (
|
||||
self.db.query(CommercialInvoice)
|
||||
.filter(CommercialInvoice.id == invoice_id, CommercialInvoice.tenant_id == tenant.id)
|
||||
.first()
|
||||
)
|
||||
if invoice is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="commercial invoice not found")
|
||||
order = self._get_commercial_order(tenant.id, invoice.order_id)
|
||||
if order.status != "activated":
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="commercial order is not activated")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
invoice.status = "issued"
|
||||
invoice.issued_by = current_user.get("id")
|
||||
invoice.invoice_no = self._normalize_required_text(invoice_no, "invoice_no")
|
||||
invoice.invoice_file_id = self._normalize_text(invoice_file_id)
|
||||
if note is not None:
|
||||
invoice.note = self._normalize_text(note)
|
||||
invoice.issued_at = invoice.issued_at or now
|
||||
invoice.updated_at = now
|
||||
order.invoice_status = "issued"
|
||||
order.updated_at = now
|
||||
self.db.add(invoice)
|
||||
self.db.add(order)
|
||||
self.db.commit()
|
||||
self.db.refresh(invoice)
|
||||
return self._to_invoice_item(invoice)
|
||||
|
||||
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"])
|
||||
@@ -631,6 +738,12 @@ class TenantService:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid payment_provider")
|
||||
return normalized
|
||||
|
||||
def _normalize_invoice_type(self, value: str) -> str:
|
||||
normalized = self._normalize_required_text(value, "invoice_type").lower()
|
||||
if normalized not in VALID_INVOICE_TYPES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid invoice_type")
|
||||
return normalized
|
||||
|
||||
def _normalize_non_negative_int(self, value: Optional[int], field_name: str) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -684,6 +797,16 @@ class TenantService:
|
||||
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 _get_commercial_order(self, tenant_id: str, order_id: str) -> CommercialOrder:
|
||||
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")
|
||||
return order
|
||||
|
||||
def _to_order_item(self, order: CommercialOrder) -> dict:
|
||||
return {
|
||||
"id": order.id,
|
||||
@@ -716,6 +839,33 @@ class TenantService:
|
||||
"updated_at": order.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
def _to_invoice_item(self, invoice: CommercialInvoice) -> dict:
|
||||
return {
|
||||
"id": invoice.id,
|
||||
"tenant_id": invoice.tenant_id,
|
||||
"order_id": invoice.order_id,
|
||||
"requested_by": invoice.requested_by,
|
||||
"issued_by": invoice.issued_by,
|
||||
"invoice_type": invoice.invoice_type,
|
||||
"status": invoice.status,
|
||||
"invoice_title": invoice.invoice_title,
|
||||
"tax_number": invoice.tax_number,
|
||||
"registered_address": invoice.registered_address,
|
||||
"registered_phone": invoice.registered_phone,
|
||||
"bank_name": invoice.bank_name,
|
||||
"bank_account": invoice.bank_account,
|
||||
"receiver_email": invoice.receiver_email,
|
||||
"receiver_phone": invoice.receiver_phone,
|
||||
"amount_cents": invoice.amount_cents,
|
||||
"currency": invoice.currency,
|
||||
"invoice_no": invoice.invoice_no,
|
||||
"invoice_file_id": invoice.invoice_file_id,
|
||||
"note": invoice.note,
|
||||
"issued_at": invoice.issued_at.isoformat() if invoice.issued_at else None,
|
||||
"created_at": invoice.created_at.isoformat(),
|
||||
"updated_at": invoice.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
def _raise_billing_block(self, message: str):
|
||||
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=message)
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""create commercial invoices"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260518_0026"
|
||||
down_revision = "20260518_0025"
|
||||
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_invoices" in _table_names(inspector):
|
||||
return
|
||||
|
||||
op.create_table(
|
||||
"commercial_invoices",
|
||||
sa.Column("id", sa.String(length=64), primary_key=True),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("order_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("requested_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("issued_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("invoice_type", sa.String(length=32), nullable=False, server_default="vat_general"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="requested"),
|
||||
sa.Column("invoice_title", sa.String(length=200), nullable=False),
|
||||
sa.Column("tax_number", sa.String(length=64), nullable=False),
|
||||
sa.Column("registered_address", sa.String(length=300), nullable=True),
|
||||
sa.Column("registered_phone", sa.String(length=64), nullable=True),
|
||||
sa.Column("bank_name", sa.String(length=160), nullable=True),
|
||||
sa.Column("bank_account", sa.String(length=100), nullable=True),
|
||||
sa.Column("receiver_email", sa.String(length=120), nullable=True),
|
||||
sa.Column("receiver_phone", sa.String(length=64), nullable=True),
|
||||
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("invoice_no", sa.String(length=100), nullable=True),
|
||||
sa.Column("invoice_file_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("issued_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_invoices_tenant_id", "commercial_invoices", ["tenant_id"])
|
||||
op.create_index("ix_commercial_invoices_order_id", "commercial_invoices", ["order_id"])
|
||||
op.create_index("ix_commercial_invoices_requested_by", "commercial_invoices", ["requested_by"])
|
||||
op.create_index("ix_commercial_invoices_issued_by", "commercial_invoices", ["issued_by"])
|
||||
op.create_index("ix_commercial_invoices_status", "commercial_invoices", ["status"])
|
||||
op.create_index("ix_commercial_invoices_invoice_no", "commercial_invoices", ["invoice_no"])
|
||||
op.create_index("ix_commercial_invoices_created_at", "commercial_invoices", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "commercial_invoices" not in _table_names(inspector):
|
||||
return
|
||||
|
||||
op.drop_index("ix_commercial_invoices_created_at", table_name="commercial_invoices")
|
||||
op.drop_index("ix_commercial_invoices_invoice_no", table_name="commercial_invoices")
|
||||
op.drop_index("ix_commercial_invoices_status", table_name="commercial_invoices")
|
||||
op.drop_index("ix_commercial_invoices_issued_by", table_name="commercial_invoices")
|
||||
op.drop_index("ix_commercial_invoices_requested_by", table_name="commercial_invoices")
|
||||
op.drop_index("ix_commercial_invoices_order_id", table_name="commercial_invoices")
|
||||
op.drop_index("ix_commercial_invoices_tenant_id", table_name="commercial_invoices")
|
||||
op.drop_table("commercial_invoices")
|
||||
@@ -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_invoice import CommercialInvoice
|
||||
from app.models.commercial_order import CommercialOrder
|
||||
from app.models.file import File
|
||||
from app.models.generated_contract import GeneratedContract
|
||||
@@ -128,6 +129,37 @@ class StubTenantService:
|
||||
}
|
||||
][:limit]
|
||||
|
||||
def list_commercial_invoices(self, current_user, limit=50):
|
||||
if current_user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return [
|
||||
{
|
||||
"id": "commercial-invoice-1",
|
||||
"tenant_id": current_user["tenant_id"],
|
||||
"order_id": "commercial-order-1",
|
||||
"requested_by": current_user["id"],
|
||||
"issued_by": None,
|
||||
"invoice_type": "vat_general",
|
||||
"status": "requested",
|
||||
"invoice_title": "示例企业",
|
||||
"tax_number": "91310000TEST000001",
|
||||
"registered_address": "上海市",
|
||||
"registered_phone": "021-00000000",
|
||||
"bank_name": "测试银行",
|
||||
"bank_account": "6222000000000000",
|
||||
"receiver_email": "finance@example.com",
|
||||
"receiver_phone": "13800000000",
|
||||
"amount_cents": 199900,
|
||||
"currency": "CNY",
|
||||
"invoice_no": None,
|
||||
"invoice_file_id": None,
|
||||
"note": "待开票",
|
||||
"issued_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")
|
||||
@@ -217,6 +249,64 @@ class StubTenantService:
|
||||
},
|
||||
}
|
||||
|
||||
def request_commercial_invoice(self, current_user, order_id, **kwargs):
|
||||
if current_user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return {
|
||||
"id": "commercial-invoice-created",
|
||||
"tenant_id": current_user["tenant_id"],
|
||||
"order_id": order_id,
|
||||
"requested_by": current_user["id"],
|
||||
"issued_by": None,
|
||||
"invoice_type": kwargs["invoice_type"],
|
||||
"status": "requested",
|
||||
"invoice_title": kwargs["invoice_title"],
|
||||
"tax_number": kwargs["tax_number"],
|
||||
"registered_address": kwargs.get("registered_address"),
|
||||
"registered_phone": kwargs.get("registered_phone"),
|
||||
"bank_name": kwargs.get("bank_name"),
|
||||
"bank_account": kwargs.get("bank_account"),
|
||||
"receiver_email": kwargs.get("receiver_email"),
|
||||
"receiver_phone": kwargs.get("receiver_phone"),
|
||||
"amount_cents": 199900,
|
||||
"currency": "CNY",
|
||||
"invoice_no": None,
|
||||
"invoice_file_id": None,
|
||||
"note": kwargs.get("note"),
|
||||
"issued_at": None,
|
||||
"created_at": "2026-05-18T00:00:00+00:00",
|
||||
"updated_at": "2026-05-18T00:00:00+00:00",
|
||||
}
|
||||
|
||||
def issue_commercial_invoice(self, current_user, invoice_id, invoice_no, invoice_file_id=None, note=None):
|
||||
if current_user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return {
|
||||
"id": invoice_id,
|
||||
"tenant_id": current_user["tenant_id"],
|
||||
"order_id": "commercial-order-1",
|
||||
"requested_by": current_user["id"],
|
||||
"issued_by": current_user["id"],
|
||||
"invoice_type": "vat_general",
|
||||
"status": "issued",
|
||||
"invoice_title": "示例企业",
|
||||
"tax_number": "91310000TEST000001",
|
||||
"registered_address": "上海市",
|
||||
"registered_phone": "021-00000000",
|
||||
"bank_name": "测试银行",
|
||||
"bank_account": "6222000000000000",
|
||||
"receiver_email": "finance@example.com",
|
||||
"receiver_phone": "13800000000",
|
||||
"amount_cents": 199900,
|
||||
"currency": "CNY",
|
||||
"invoice_no": invoice_no,
|
||||
"invoice_file_id": invoice_file_id,
|
||||
"note": note,
|
||||
"issued_at": "2026-05-18T00:00:00+00:00",
|
||||
"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")
|
||||
@@ -289,6 +379,18 @@ class TenantSettingsApiTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"][0]["order_no"], "ORD-202605180001")
|
||||
|
||||
def test_admin_can_list_commercial_invoices(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/invoices")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["data"][0]["invoice_title"], "示例企业")
|
||||
|
||||
def test_admin_can_create_commercial_order(self):
|
||||
app.dependency_overrides[require_current_user] = lambda: {
|
||||
"id": "user-admin",
|
||||
@@ -339,6 +441,53 @@ class TenantSettingsApiTestCase(unittest.TestCase):
|
||||
self.assertEqual(body["tenant"]["billing_status"], "active")
|
||||
self.assertEqual(self.audit_log_service.events[0]["action"], "activate_commercial_order")
|
||||
|
||||
def test_admin_can_request_commercial_invoice(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/invoice",
|
||||
data={
|
||||
"invoice_type": "vat_general",
|
||||
"invoice_title": "示例企业",
|
||||
"tax_number": "91310000TEST000001",
|
||||
"registered_address": "上海市",
|
||||
"registered_phone": "021-00000000",
|
||||
"bank_name": "测试银行",
|
||||
"bank_account": "6222000000000000",
|
||||
"receiver_email": "finance@example.com",
|
||||
"receiver_phone": "13800000000",
|
||||
"note": "请开电子普票",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()["data"]
|
||||
self.assertEqual(body["status"], "requested")
|
||||
self.assertEqual(body["tax_number"], "91310000TEST000001")
|
||||
self.assertEqual(self.audit_log_service.events[0]["action"], "request_commercial_invoice")
|
||||
|
||||
def test_admin_can_issue_commercial_invoice(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/invoices/commercial-invoice-created/issue",
|
||||
data={"invoice_no": "INV-20260518-001", "invoice_file_id": "file-invoice-1", "note": "已开票"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()["data"]
|
||||
self.assertEqual(body["status"], "issued")
|
||||
self.assertEqual(body["invoice_no"], "INV-20260518-001")
|
||||
self.assertEqual(self.audit_log_service.events[0]["action"], "issue_commercial_invoice")
|
||||
|
||||
def test_admin_can_update_billing_plan(self):
|
||||
app.dependency_overrides[require_current_user] = lambda: {
|
||||
"id": "user-admin",
|
||||
@@ -653,6 +802,52 @@ class TenantSettingsApiTestCase(unittest.TestCase):
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
def test_commercial_invoice_request_and_issue_updates_order_invoice_status(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,
|
||||
)
|
||||
invoice = service.request_commercial_invoice(
|
||||
current_user=current_user,
|
||||
order_id=order["id"],
|
||||
invoice_type="vat_general",
|
||||
invoice_title="示例企业",
|
||||
tax_number="91310000TEST000001",
|
||||
receiver_email="finance@example.com",
|
||||
)
|
||||
stored_order = session.query(CommercialOrder).filter(CommercialOrder.id == order["id"]).first()
|
||||
self.assertEqual(invoice["status"], "requested")
|
||||
self.assertEqual(stored_order.invoice_status, "requested")
|
||||
|
||||
service.activate_commercial_order(current_user, order["id"], payment_provider="bank_transfer")
|
||||
issued = service.issue_commercial_invoice(
|
||||
current_user=current_user,
|
||||
invoice_id=invoice["id"],
|
||||
invoice_no="INV-20260518-001",
|
||||
invoice_file_id="file-invoice-1",
|
||||
)
|
||||
stored_order = session.query(CommercialOrder).filter(CommercialOrder.id == order["id"]).first()
|
||||
|
||||
self.assertEqual(issued["status"], "issued")
|
||||
self.assertEqual(issued["invoice_no"], "INV-20260518-001")
|
||||
self.assertEqual(stored_order.invoice_status, "issued")
|
||||
self.assertEqual(session.query(CommercialInvoice).count(), 1)
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -9,12 +9,15 @@ import { commercialPlans } from "@/lib/commercial-plans";
|
||||
import {
|
||||
activateCommercialOrder,
|
||||
createCommercialOrder,
|
||||
getCommercialInvoices,
|
||||
getCommercialOrders,
|
||||
getTenantBillingSummary,
|
||||
issueCommercialInvoice,
|
||||
requestCommercialInvoice,
|
||||
updateTenantBillingPlan,
|
||||
} from "@/services/tenant-settings";
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
import type { CommercialOrderItem, TenantBillingMetric, TenantBillingSummary } from "@/types/api";
|
||||
import type { CommercialInvoiceItem, CommercialOrderItem, TenantBillingMetric, TenantBillingSummary } from "@/types/api";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -44,6 +47,25 @@ type BillingPlanFormValues = {
|
||||
max_storage_mb?: number;
|
||||
};
|
||||
|
||||
type InvoiceRequestFormValues = {
|
||||
invoice_type: string;
|
||||
invoice_title: string;
|
||||
tax_number: string;
|
||||
registered_address?: string;
|
||||
registered_phone?: string;
|
||||
bank_name?: string;
|
||||
bank_account?: string;
|
||||
receiver_email?: string;
|
||||
receiver_phone?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type InvoiceIssueFormValues = {
|
||||
invoice_no: string;
|
||||
invoice_file_id?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
const statusMeta: Record<TenantBillingMetric["status"], { color: string; label: string }> = {
|
||||
ok: { color: "green", label: "正常" },
|
||||
warning: { color: "orange", label: "接近上限" },
|
||||
@@ -107,12 +129,21 @@ export default function BillingPage() {
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
const [orderForm] = Form.useForm<OrderFormValues>();
|
||||
const [planForm] = Form.useForm<BillingPlanFormValues>();
|
||||
const [invoiceForm] = Form.useForm<InvoiceRequestFormValues>();
|
||||
const [issueForm] = Form.useForm<InvoiceIssueFormValues>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [summary, setSummary] = useState<TenantBillingSummary | null>(null);
|
||||
const [orders, setOrders] = useState<CommercialOrderItem[]>([]);
|
||||
const [invoices, setInvoices] = useState<CommercialInvoiceItem[]>([]);
|
||||
const [submittingOrder, setSubmittingOrder] = useState(false);
|
||||
const [submittingPlan, setSubmittingPlan] = useState(false);
|
||||
const [activatingOrderId, setActivatingOrderId] = useState<string | null>(null);
|
||||
const [invoiceOrder, setInvoiceOrder] = useState<CommercialOrderItem | null>(null);
|
||||
const [invoiceRecord, setInvoiceRecord] = useState<CommercialInvoiceItem | null>(null);
|
||||
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
|
||||
const [issueModalOpen, setIssueModalOpen] = useState(false);
|
||||
const [submittingInvoice, setSubmittingInvoice] = useState(false);
|
||||
const [issuingInvoice, setIssuingInvoice] = useState(false);
|
||||
|
||||
const loadBillingData = useCallback(async () => {
|
||||
if (currentUser?.role !== "admin") {
|
||||
@@ -122,7 +153,11 @@ export default function BillingPage() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [summaryResult, ordersResult] = await Promise.all([getTenantBillingSummary(), getCommercialOrders()]);
|
||||
const [summaryResult, ordersResult, invoicesResult] = await Promise.all([
|
||||
getTenantBillingSummary(),
|
||||
getCommercialOrders(),
|
||||
getCommercialInvoices(),
|
||||
]);
|
||||
if (summaryResult.code !== 0) {
|
||||
messageApi.error(summaryResult.message || "用量统计加载失败");
|
||||
return;
|
||||
@@ -131,8 +166,13 @@ export default function BillingPage() {
|
||||
messageApi.error(ordersResult.message || "商业订单加载失败");
|
||||
return;
|
||||
}
|
||||
if (invoicesResult.code !== 0) {
|
||||
messageApi.error(invoicesResult.message || "发票台账加载失败");
|
||||
return;
|
||||
}
|
||||
setSummary(summaryResult.data);
|
||||
setOrders(ordersResult.data);
|
||||
setInvoices(invoicesResult.data);
|
||||
} catch (error: unknown) {
|
||||
messageApi.error(getErrorMessage(error, "商业化数据加载失败,请稍后重试"));
|
||||
} finally {
|
||||
@@ -224,6 +264,29 @@ export default function BillingPage() {
|
||||
[loadBillingData, messageApi],
|
||||
);
|
||||
|
||||
const handleOpenInvoiceRequest = useCallback(
|
||||
(order: CommercialOrderItem) => {
|
||||
setInvoiceOrder(order);
|
||||
invoiceForm.setFieldsValue({
|
||||
invoice_type: "vat_general",
|
||||
invoice_title: summary?.tenant.name ?? "",
|
||||
receiver_email: order.contact_email ?? undefined,
|
||||
receiver_phone: order.contact_phone ?? undefined,
|
||||
});
|
||||
setInvoiceModalOpen(true);
|
||||
},
|
||||
[invoiceForm, summary?.tenant.name],
|
||||
);
|
||||
|
||||
const handleOpenIssueInvoice = useCallback(
|
||||
(invoice: CommercialInvoiceItem) => {
|
||||
setInvoiceRecord(invoice);
|
||||
issueForm.resetFields();
|
||||
setIssueModalOpen(true);
|
||||
},
|
||||
[issueForm],
|
||||
);
|
||||
|
||||
const orderColumns = useMemo<ColumnsType<CommercialOrderItem>>(
|
||||
() => [
|
||||
{
|
||||
@@ -273,16 +336,81 @@ export default function BillingPage() {
|
||||
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>
|
||||
(
|
||||
<Space size={8} wrap>
|
||||
{order.status === "pending_payment" ? (
|
||||
<Button size="small" loading={activatingOrderId === order.id} onClick={() => void handleActivateOrder(order)}>
|
||||
确认收款并生效
|
||||
</Button>
|
||||
) : (
|
||||
<Text type="secondary">已收款</Text>
|
||||
)}
|
||||
{order.invoice_status !== "issued" ? (
|
||||
<Button size="small" onClick={() => handleOpenInvoiceRequest(order)}>
|
||||
登记发票资料
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[activatingOrderId, handleActivateOrder],
|
||||
[activatingOrderId, handleActivateOrder, handleOpenInvoiceRequest],
|
||||
);
|
||||
|
||||
const invoiceColumns = useMemo<ColumnsType<CommercialInvoiceItem>>(
|
||||
() => [
|
||||
{
|
||||
title: "发票抬头",
|
||||
dataIndex: "invoice_title",
|
||||
key: "invoice_title",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "税号",
|
||||
dataIndex: "tax_number",
|
||||
key: "tax_number",
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: "金额",
|
||||
key: "amount",
|
||||
render: (_, invoice) => formatMoney(invoice.amount_cents, invoice.currency),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
render: (_, invoice) => {
|
||||
const meta = invoiceStatusMeta[invoice.status] ?? { color: "default", label: invoice.status };
|
||||
return <Tag color={meta.color}>{meta.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "发票号码",
|
||||
dataIndex: "invoice_no",
|
||||
key: "invoice_no",
|
||||
render: (value: string | null) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "接收邮箱",
|
||||
dataIndex: "receiver_email",
|
||||
key: "receiver_email",
|
||||
render: (value: string | null) => value || "-",
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
fixed: "right",
|
||||
render: (_, invoice) =>
|
||||
invoice.status === "requested" ? (
|
||||
<Button size="small" onClick={() => handleOpenIssueInvoice(invoice)}>
|
||||
登记已开票
|
||||
</Button>
|
||||
) : (
|
||||
<Text type="secondary">已开票</Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
[handleOpenIssueInvoice],
|
||||
);
|
||||
|
||||
const handleOrderPlanChange = (planCode: string) => {
|
||||
@@ -324,6 +452,52 @@ export default function BillingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestInvoice = async (values: InvoiceRequestFormValues) => {
|
||||
if (!invoiceOrder) {
|
||||
return;
|
||||
}
|
||||
setSubmittingInvoice(true);
|
||||
try {
|
||||
const result = await requestCommercialInvoice(invoiceOrder.id, values);
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "发票资料登记失败");
|
||||
return;
|
||||
}
|
||||
messageApi.success("发票资料已登记");
|
||||
setInvoiceModalOpen(false);
|
||||
setInvoiceOrder(null);
|
||||
invoiceForm.resetFields();
|
||||
await loadBillingData();
|
||||
} catch (error: unknown) {
|
||||
messageApi.error(getErrorMessage(error, "发票资料登记失败,请稍后重试"));
|
||||
} finally {
|
||||
setSubmittingInvoice(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIssueInvoice = async (values: InvoiceIssueFormValues) => {
|
||||
if (!invoiceRecord) {
|
||||
return;
|
||||
}
|
||||
setIssuingInvoice(true);
|
||||
try {
|
||||
const result = await issueCommercialInvoice(invoiceRecord.id, values);
|
||||
if (result.code !== 0) {
|
||||
messageApi.error(result.message || "开票登记失败");
|
||||
return;
|
||||
}
|
||||
messageApi.success("发票已登记为已开票");
|
||||
setIssueModalOpen(false);
|
||||
setInvoiceRecord(null);
|
||||
issueForm.resetFields();
|
||||
await loadBillingData();
|
||||
} catch (error: unknown) {
|
||||
messageApi.error(getErrorMessage(error, "开票登记失败,请确认订单已收款生效"));
|
||||
} finally {
|
||||
setIssuingInvoice(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePlan = async (values: BillingPlanFormValues) => {
|
||||
setSubmittingPlan(true);
|
||||
try {
|
||||
@@ -548,6 +722,16 @@ export default function BillingPage() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className="review-surface-card" title="发票资料与开票台账" loading={loading}>
|
||||
<Table<CommercialInvoiceItem>
|
||||
columns={invoiceColumns}
|
||||
dataSource={invoices}
|
||||
pagination={{ pageSize: 8 }}
|
||||
rowKey="id"
|
||||
scroll={{ x: 980 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className="review-surface-card" title="本月用量明细" loading={loading}>
|
||||
<Table<TenantBillingMetric>
|
||||
columns={columns}
|
||||
@@ -568,6 +752,87 @@ export default function BillingPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={invoiceOrder ? `登记发票资料:${invoiceOrder.order_no}` : "登记发票资料"}
|
||||
open={invoiceModalOpen}
|
||||
onCancel={() => {
|
||||
setInvoiceModalOpen(false);
|
||||
setInvoiceOrder(null);
|
||||
invoiceForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={invoiceForm} layout="vertical" onFinish={handleRequestInvoice}>
|
||||
<Form.Item label="发票类型" name="invoice_type" rules={[{ required: true, message: "请选择发票类型" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "vat_general", label: "增值税普通发票" },
|
||||
{ value: "vat_special", label: "增值税专用发票" },
|
||||
{ value: "electronic_general", label: "电子普通发票" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="发票抬头" name="invoice_title" rules={[{ required: true, message: "请输入发票抬头" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="纳税人识别号" name="tax_number" rules={[{ required: true, message: "请输入纳税人识别号" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="注册地址" name="registered_address">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="注册电话" name="registered_phone">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="开户行" name="bank_name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="银行账号" name="bank_account">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="接收邮箱" name="receiver_email">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="接收手机" name="receiver_phone">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="开票备注" name="note">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={submittingInvoice}>
|
||||
保存发票资料
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={invoiceRecord ? `登记已开票:${invoiceRecord.invoice_title}` : "登记已开票"}
|
||||
open={issueModalOpen}
|
||||
onCancel={() => {
|
||||
setIssueModalOpen(false);
|
||||
setInvoiceRecord(null);
|
||||
issueForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={issueForm} layout="vertical" onFinish={handleIssueInvoice}>
|
||||
<Form.Item label="发票号码" name="invoice_no" rules={[{ required: true, message: "请输入发票号码" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="发票文件编号" name="invoice_file_id">
|
||||
<Input placeholder="可填写文件中心 file_id 或外部发票文件编号" />
|
||||
</Form.Item>
|
||||
<Form.Item label="开票备注" name="note">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={issuingInvoice}>
|
||||
确认已开票
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { http } from "@/services/http";
|
||||
import type {
|
||||
ApiResponse,
|
||||
BillingPlanUpdatePayload,
|
||||
CommercialInvoiceIssuePayload,
|
||||
CommercialInvoiceItem,
|
||||
CommercialInvoiceRequestPayload,
|
||||
CommercialOrderActivationResponse,
|
||||
CommercialOrderCreatePayload,
|
||||
CommercialOrderItem,
|
||||
@@ -73,6 +76,29 @@ function buildBillingPlanFormData(payload: BillingPlanUpdatePayload) {
|
||||
return formData;
|
||||
}
|
||||
|
||||
function buildCommercialInvoiceRequestFormData(payload: CommercialInvoiceRequestPayload) {
|
||||
const formData = new FormData();
|
||||
formData.append("invoice_type", payload.invoice_type);
|
||||
formData.append("invoice_title", payload.invoice_title);
|
||||
formData.append("tax_number", payload.tax_number);
|
||||
appendOptionalFormValue(formData, "registered_address", payload.registered_address);
|
||||
appendOptionalFormValue(formData, "registered_phone", payload.registered_phone);
|
||||
appendOptionalFormValue(formData, "bank_name", payload.bank_name);
|
||||
appendOptionalFormValue(formData, "bank_account", payload.bank_account);
|
||||
appendOptionalFormValue(formData, "receiver_email", payload.receiver_email);
|
||||
appendOptionalFormValue(formData, "receiver_phone", payload.receiver_phone);
|
||||
appendOptionalFormValue(formData, "note", payload.note);
|
||||
return formData;
|
||||
}
|
||||
|
||||
function buildCommercialInvoiceIssueFormData(payload: CommercialInvoiceIssuePayload) {
|
||||
const formData = new FormData();
|
||||
formData.append("invoice_no", payload.invoice_no);
|
||||
appendOptionalFormValue(formData, "invoice_file_id", payload.invoice_file_id);
|
||||
appendOptionalFormValue(formData, "note", payload.note);
|
||||
return formData;
|
||||
}
|
||||
|
||||
export async function getTenantBillingSummary() {
|
||||
const response = await http.get<ApiResponse<TenantBillingSummary>>("/tenant-settings/billing");
|
||||
return response.data;
|
||||
@@ -83,6 +109,11 @@ export async function getCommercialOrders(limit = 50) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getCommercialInvoices(limit = 50) {
|
||||
const response = await http.get<ApiResponse<CommercialInvoiceItem[]>>("/tenant-settings/billing/invoices", { 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;
|
||||
@@ -98,6 +129,22 @@ export async function activateCommercialOrder(orderId: string, paymentProvider =
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function requestCommercialInvoice(orderId: string, payload: CommercialInvoiceRequestPayload) {
|
||||
const response = await http.post<ApiResponse<CommercialInvoiceItem>>(
|
||||
`/tenant-settings/billing/orders/${orderId}/invoice`,
|
||||
buildCommercialInvoiceRequestFormData(payload),
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function issueCommercialInvoice(invoiceId: string, payload: CommercialInvoiceIssuePayload) {
|
||||
const response = await http.post<ApiResponse<CommercialInvoiceItem>>(
|
||||
`/tenant-settings/billing/invoices/${invoiceId}/issue`,
|
||||
buildCommercialInvoiceIssueFormData(payload),
|
||||
);
|
||||
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;
|
||||
|
||||
@@ -557,6 +557,51 @@ export type CommercialOrderActivationResponse = {
|
||||
tenant: TenantSettingsItem;
|
||||
};
|
||||
|
||||
export type CommercialInvoiceItem = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
order_id: string;
|
||||
requested_by: string | null;
|
||||
issued_by: string | null;
|
||||
invoice_type: "vat_general" | "vat_special" | "electronic_general" | string;
|
||||
status: "requested" | "issued" | string;
|
||||
invoice_title: string;
|
||||
tax_number: string;
|
||||
registered_address: string | null;
|
||||
registered_phone: string | null;
|
||||
bank_name: string | null;
|
||||
bank_account: string | null;
|
||||
receiver_email: string | null;
|
||||
receiver_phone: string | null;
|
||||
amount_cents: number;
|
||||
currency: string;
|
||||
invoice_no: string | null;
|
||||
invoice_file_id: string | null;
|
||||
note: string | null;
|
||||
issued_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CommercialInvoiceRequestPayload = {
|
||||
invoice_type: string;
|
||||
invoice_title: string;
|
||||
tax_number: string;
|
||||
registered_address?: string;
|
||||
registered_phone?: string;
|
||||
bank_name?: string;
|
||||
bank_account?: string;
|
||||
receiver_email?: string;
|
||||
receiver_phone?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type CommercialInvoiceIssuePayload = {
|
||||
invoice_no: string;
|
||||
invoice_file_id?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type BillingPlanUpdatePayload = {
|
||||
plan_code: string;
|
||||
plan_name: string;
|
||||
|
||||
@@ -2206,6 +2206,70 @@
|
||||
- 若要进入真实在线支付,需要先确定支付通道:微信支付、支付宝,或先只做银行转账/线下收款。
|
||||
- 若要在开发测试服务器验证本轮功能,需要部署后端和前端,并在远程 PostgreSQL 执行 Alembic 到 `20260518_0025`。
|
||||
|
||||
## 2026-05-18 商业化 Phase2 发票资料与开票台账闭环
|
||||
### 本轮完成项
|
||||
- 已把上轮剩余的“发票只是订单状态”补成独立发票台账。
|
||||
- 新增商业发票真相源:
|
||||
- 数据表:`commercial_invoices`
|
||||
- 模型:`backend/app/models/commercial_invoice.py`
|
||||
- 迁移:`backend/migrations/versions/20260518_0026_create_commercial_invoices.py`
|
||||
- 发票可记录抬头、税号、注册地址、注册电话、开户行、银行账号、接收邮箱、接收手机、发票号码、发票文件编号、开票人和开票时间。
|
||||
- 新增后端接口:
|
||||
- `GET /api/v1/tenant-settings/billing/invoices`
|
||||
- `POST /api/v1/tenant-settings/billing/orders/{order_id}/invoice`
|
||||
- `POST /api/v1/tenant-settings/billing/invoices/{invoice_id}/issue`
|
||||
- 新增后台能力:
|
||||
- 管理员可对商业订单登记发票资料。
|
||||
- 系统会把订单 `invoice_status` 从 `not_requested/requested` 同步到 `requested`。
|
||||
- 管理员在订单确认收款生效后,可登记发票号码和发票文件编号。
|
||||
- 开票完成后,系统会把发票状态和订单发票状态同步为 `issued`。
|
||||
- 前端 `/billing` 已新增:
|
||||
- “登记发票资料”弹窗。
|
||||
- “发票资料与开票台账”表格。
|
||||
- “登记已开票”弹窗。
|
||||
|
||||
### 本轮验证
|
||||
- gstack 检查:
|
||||
- `GSTACK_OK`
|
||||
- Python 编译检查:
|
||||
- `backend\.venv\Scripts\python.exe -m py_compile backend\app\models\commercial_invoice.py 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_0026_create_commercial_invoices.py`
|
||||
- 结果:通过。
|
||||
- 后端聚焦测试:
|
||||
- `backend\.venv\Scripts\python.exe -m pytest backend\tests\test_tenant_settings_api.py backend\tests\test_permission_metadata.py`
|
||||
- 结果:`20 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` 已进入构建路由。
|
||||
|
||||
### 当前状态
|
||||
- 发票链路已从“订单上的一个状态字段”推进到“可登记、可追踪、可开票留痕”的第一版闭环。
|
||||
- 商业化当前已具备:
|
||||
- 用量统计。
|
||||
- 到期/超额硬拦截。
|
||||
- 商业订单。
|
||||
- 线下收款确认。
|
||||
- 套餐自动生效。
|
||||
- 发票资料登记。
|
||||
- 开票状态同步。
|
||||
|
||||
### 未完成项
|
||||
- 真实在线支付仍未接入:
|
||||
- 微信支付或支付宝商户号配置。
|
||||
- 支付二维码/收银台。
|
||||
- 支付回调验签。
|
||||
- 回调后自动生效。
|
||||
- 发票文件当前只记录 `invoice_file_id`,尚未做发票文件上传和下载预览的完整文件中心联动。
|
||||
- 销售线索跟进面板仍未做;当前是订单和发票台账,不是完整 CRM。
|
||||
- 本轮未执行远程 Docker 部署,也未对远程 PostgreSQL 执行迁移。
|
||||
|
||||
### 阻塞项
|
||||
- 本轮开发无 required 阻塞项。
|
||||
- 若要在开发测试服务器验证本轮功能,需要部署后端和前端,并在远程 PostgreSQL 执行 Alembic 到 `20260518_0026`。
|
||||
- 若要做真实在线支付,需要先准备微信支付或支付宝商户资料、回调域名/内网穿透方案、证书/密钥和收款主体。
|
||||
|
||||
## 2026-05-16 Phase14 第二批法条全文扩容与告警 dry-run 收口
|
||||
### 本轮完成项
|
||||
- 已新增第二批合同高频法条清单:
|
||||
|
||||
Reference in New Issue
Block a user