From c45479ce98c05f3906da2b96ffd04cc76472e7f4 Mon Sep 17 00:00:00 2001 From: yuqei Date: Mon, 18 May 2026 22:58:29 +0800 Subject: [PATCH] feat: add commercial invoice ledger --- backend/app/api/tenant_settings.py | 93 ++++++ backend/app/models/__init__.py | 1 + backend/app/models/commercial_invoice.py | 39 +++ backend/app/services/tenant_service.py | 164 +++++++++- ...0260518_0026_create_commercial_invoices.py | 73 +++++ backend/tests/test_tenant_settings_api.py | 195 ++++++++++++ frontend/src/app/billing/page.tsx | 283 +++++++++++++++++- frontend/src/services/tenant-settings.ts | 47 +++ frontend/src/types/api.ts | 45 +++ ...µ·草与审查助手-当前工作进度保存-2026-05-11.md | 64 ++++ 10 files changed, 988 insertions(+), 16 deletions(-) create mode 100644 backend/app/models/commercial_invoice.py create mode 100644 backend/migrations/versions/20260518_0026_create_commercial_invoices.py diff --git a/backend/app/api/tenant_settings.py b/backend/app/api/tenant_settings.py index f191d53d..e29e295a 100644 --- a/backend/app/api/tenant_settings.py +++ b/backend/app/api/tenant_settings.py @@ -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, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index aeded7bd..1e4a26ad 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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 diff --git a/backend/app/models/commercial_invoice.py b/backend/app/models/commercial_invoice.py new file mode 100644 index 00000000..bab92904 --- /dev/null +++ b/backend/app/models/commercial_invoice.py @@ -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), + ) diff --git a/backend/app/services/tenant_service.py b/backend/app/services/tenant_service.py index 9e5486cd..0329acfa 100644 --- a/backend/app/services/tenant_service.py +++ b/backend/app/services/tenant_service.py @@ -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) diff --git a/backend/migrations/versions/20260518_0026_create_commercial_invoices.py b/backend/migrations/versions/20260518_0026_create_commercial_invoices.py new file mode 100644 index 00000000..3a9428f3 --- /dev/null +++ b/backend/migrations/versions/20260518_0026_create_commercial_invoices.py @@ -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") diff --git a/backend/tests/test_tenant_settings_api.py b/backend/tests/test_tenant_settings_api.py index e8035d16..11fed5e9 100644 --- a/backend/tests/test_tenant_settings_api.py +++ b/backend/tests/test_tenant_settings_api.py @@ -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() diff --git a/frontend/src/app/billing/page.tsx b/frontend/src/app/billing/page.tsx index 01f4dff0..d0d57775 100644 --- a/frontend/src/app/billing/page.tsx +++ b/frontend/src/app/billing/page.tsx @@ -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 = { 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(); const [planForm] = Form.useForm(); + const [invoiceForm] = Form.useForm(); + const [issueForm] = Form.useForm(); const [loading, setLoading] = useState(true); const [summary, setSummary] = useState(null); const [orders, setOrders] = useState([]); + const [invoices, setInvoices] = useState([]); const [submittingOrder, setSubmittingOrder] = useState(false); const [submittingPlan, setSubmittingPlan] = useState(false); const [activatingOrderId, setActivatingOrderId] = useState(null); + const [invoiceOrder, setInvoiceOrder] = useState(null); + const [invoiceRecord, setInvoiceRecord] = useState(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>( () => [ { @@ -273,16 +336,81 @@ export default function BillingPage() { key: "actions", fixed: "right", render: (_, order) => - order.status === "pending_payment" ? ( - - ) : ( - 已处理 + ( + + {order.status === "pending_payment" ? ( + + ) : ( + 已收款 + )} + {order.invoice_status !== "issued" ? ( + + ) : null} + ), }, ], - [activatingOrderId, handleActivateOrder], + [activatingOrderId, handleActivateOrder, handleOpenInvoiceRequest], + ); + + const invoiceColumns = useMemo>( + () => [ + { + 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 {meta.label}; + }, + }, + { + 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" ? ( + + ) : ( + 已开票 + ), + }, + ], + [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() { /> + + + columns={invoiceColumns} + dataSource={invoices} + pagination={{ pageSize: 8 }} + rowKey="id" + scroll={{ x: 980 }} + /> + + columns={columns} @@ -568,6 +752,87 @@ export default function BillingPage() { ) : null} + + { + setInvoiceModalOpen(false); + setInvoiceOrder(null); + invoiceForm.resetFields(); + }} + footer={null} + destroyOnHidden + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + { + setIssueModalOpen(false); + setInvoiceRecord(null); + issueForm.resetFields(); + }} + footer={null} + destroyOnHidden + > +
+ + + + + + + + + + +
+
); } diff --git a/frontend/src/services/tenant-settings.ts b/frontend/src/services/tenant-settings.ts index c02f6fd2..a278529f 100644 --- a/frontend/src/services/tenant-settings.ts +++ b/frontend/src/services/tenant-settings.ts @@ -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>("/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>("/tenant-settings/billing/invoices", { params: { limit } }); + return response.data; +} + export async function createCommercialOrder(payload: CommercialOrderCreatePayload) { const response = await http.post>("/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>( + `/tenant-settings/billing/orders/${orderId}/invoice`, + buildCommercialInvoiceRequestFormData(payload), + ); + return response.data; +} + +export async function issueCommercialInvoice(invoiceId: string, payload: CommercialInvoiceIssuePayload) { + const response = await http.post>( + `/tenant-settings/billing/invoices/${invoiceId}/issue`, + buildCommercialInvoiceIssueFormData(payload), + ); + return response.data; +} + export async function updateTenantBillingPlan(payload: BillingPlanUpdatePayload) { const response = await http.put>("/tenant-settings/billing/plan", buildBillingPlanFormData(payload)); return response.data; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 8170c6db..239d30c0 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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; diff --git a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md index 265bf45a..0d615a13 100644 --- a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md +++ b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md @@ -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 收口 ### 本轮完成项 - 已新增第二批合同高频法条清单: