feat: add commercial invoice ledger

This commit is contained in:
2026-05-18 22:58:29 +08:00
parent 34412b26c0
commit c45479ce98
10 changed files with 988 additions and 16 deletions
+93
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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),
)
+157 -7
View File
@@ -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")
+195
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_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()
+274 -9
View File
@@ -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>
);
}
+47
View File
@@ -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;
+45
View File
@@ -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 收口
### 本轮完成项
- 已新增第二批合同高频法条清单: