1
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""发票抬头表迁移
|
||||
|
||||
创建 invoice_headers(发票抬头表),用于用户管理常用发票抬头。
|
||||
|
||||
Revision ID: 20260811_20260811
|
||||
Revises: 20260810_20260810
|
||||
Create Date: 2026-08-11 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '20260811_20260811'
|
||||
down_revision: Union[str, None] = '20260810_20260810'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _comment_table(table_name: str, comment: str) -> None:
|
||||
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
|
||||
|
||||
|
||||
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
|
||||
escaped = comment.replace("'", "''")
|
||||
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'invoice_headers',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('user_id', sa.String(32), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('type', sa.String(16), nullable=False),
|
||||
sa.Column('name', sa.String(128), nullable=False),
|
||||
sa.Column('tax_no', sa.String(32), nullable=True),
|
||||
sa.Column('register_address', sa.String(256), nullable=True),
|
||||
sa.Column('register_phone', sa.String(32), nullable=True),
|
||||
sa.Column('bank_name', sa.String(128), nullable=True),
|
||||
sa.Column('bank_account', sa.String(64), nullable=True),
|
||||
sa.Column('email', sa.String(128), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean, nullable=False, server_default='false'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('idx_invoice_headers_user', 'invoice_headers', ['user_id'])
|
||||
|
||||
# 表注释和字段注释
|
||||
_comment_table('invoice_headers', '发票抬头表')
|
||||
_comment_column('invoice_headers', 'id', '主键')
|
||||
_comment_column('invoice_headers', 'user_id', '用户ID')
|
||||
_comment_column('invoice_headers', 'type', '抬头类型: personal/company')
|
||||
_comment_column('invoice_headers', 'name', '抬头名称')
|
||||
_comment_column('invoice_headers', 'tax_no', '税号')
|
||||
_comment_column('invoice_headers', 'register_address', '注册地址')
|
||||
_comment_column('invoice_headers', 'register_phone', '注册电话')
|
||||
_comment_column('invoice_headers', 'bank_name', '开户行')
|
||||
_comment_column('invoice_headers', 'bank_account', '银行账号')
|
||||
_comment_column('invoice_headers', 'email', '接收邮箱')
|
||||
_comment_column('invoice_headers', 'is_default', '是否默认')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('invoice_headers')
|
||||
@@ -36,6 +36,7 @@ from app.api.v1.private_portrait import router as private_portrait_router
|
||||
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router
|
||||
from app.api.v1.upload_resource import router as upload_resource_router
|
||||
from app.api.v1.invoices import router as invoices_router
|
||||
from app.api.v1.invoice_headers import router as invoice_headers_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth_router)
|
||||
@@ -74,3 +75,4 @@ api_router.include_router(private_portrait_router)
|
||||
api_router.include_router(private_portrait_virtual_router)
|
||||
api_router.include_router(upload_resource_router)
|
||||
api_router.include_router(invoices_router)
|
||||
api_router.include_router(invoice_headers_router)
|
||||
|
||||
@@ -2210,6 +2210,8 @@ async def admin_list_generation_records(
|
||||
status: str | None = Query(None),
|
||||
engine_id: str | None = Query(None),
|
||||
include_media_references: bool | None = Query(None),
|
||||
start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"),
|
||||
end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=500),
|
||||
admin: User = Depends(get_admin_user),
|
||||
@@ -2232,6 +2234,10 @@ async def admin_list_generation_records(
|
||||
query = query.where(GenerationRecord.engine_id == engine_id)
|
||||
if include_media_references is not None:
|
||||
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||
if start_date:
|
||||
query = query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
|
||||
if end_date:
|
||||
query = query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
|
||||
|
||||
# Count total
|
||||
count_query = (
|
||||
@@ -2247,6 +2253,10 @@ async def admin_list_generation_records(
|
||||
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
|
||||
if include_media_references is not None:
|
||||
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||
if start_date:
|
||||
count_query = count_query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
|
||||
if end_date:
|
||||
count_query = count_query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.invoice import InvoiceHeaderCreate, InvoiceHeaderOut, InvoiceHeaderUpdate
|
||||
from app.services.invoice_header import (
|
||||
create_header,
|
||||
delete_header,
|
||||
get_user_headers,
|
||||
set_default_header,
|
||||
update_header,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
router = APIRouter(prefix="/invoice-headers", tags=["invoice-headers"])
|
||||
|
||||
|
||||
def _header_to_out(header) -> dict:
|
||||
return {
|
||||
"id": header.id,
|
||||
"userId": header.user_id,
|
||||
"type": header.type,
|
||||
"name": header.name,
|
||||
"taxNo": header.tax_no,
|
||||
"registerAddress": header.register_address,
|
||||
"registerPhone": header.register_phone,
|
||||
"bankName": header.bank_name,
|
||||
"bankAccount": header.bank_account,
|
||||
"email": header.email,
|
||||
"isDefault": header.is_default,
|
||||
"createdAt": header.created_at.isoformat() if header.created_at else None,
|
||||
"updatedAt": header.updated_at.isoformat() if header.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=InvoiceHeaderOut)
|
||||
async def create_invoice_header(
|
||||
req: InvoiceHeaderCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建发票抬头。"""
|
||||
header = await create_header(db, current_user.id, req)
|
||||
await db.commit()
|
||||
return _header_to_out(header)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_invoice_headers(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前用户的所有发票抬头。"""
|
||||
headers = await get_user_headers(db, current_user.id)
|
||||
return {"items": [_header_to_out(h) for h in headers]}
|
||||
|
||||
|
||||
@router.put("/{header_id}", response_model=InvoiceHeaderOut)
|
||||
async def update_invoice_header(
|
||||
header_id: str,
|
||||
req: InvoiceHeaderUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新发票抬头。"""
|
||||
header = await update_header(db, header_id, current_user.id, req)
|
||||
await db.commit()
|
||||
return _header_to_out(header)
|
||||
|
||||
|
||||
@router.delete("/{header_id}")
|
||||
async def delete_invoice_header(
|
||||
header_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除发票抬头。"""
|
||||
await delete_header(db, header_id, current_user.id)
|
||||
await db.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.put("/{header_id}/set-default", response_model=InvoiceHeaderOut)
|
||||
async def set_default_invoice_header(
|
||||
header_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""设置默认发票抬头。"""
|
||||
header = await set_default_header(db, header_id, current_user.id)
|
||||
await db.commit()
|
||||
return _header_to_out(header)
|
||||
@@ -37,6 +37,7 @@ from app.models.user_oauth_app import UserOAuthApp
|
||||
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||
from app.models.contact_request import ContactRequest
|
||||
from app.models.invoice import Invoice, InvoiceOrder
|
||||
from app.models.invoice_header import InvoiceHeader
|
||||
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
|
||||
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
|
||||
|
||||
@@ -58,5 +59,5 @@ __all__ = [
|
||||
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
|
||||
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
|
||||
"ApiModelPricing",
|
||||
"Invoice", "InvoiceOrder",
|
||||
"Invoice", "InvoiceOrder", "InvoiceHeader",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class InvoiceHeader(Base):
|
||||
"""发票抬头表"""
|
||||
__tablename__ = "invoice_headers"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="主键")
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID"
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(16), nullable=False, comment="抬头类型: personal/company")
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="抬头名称")
|
||||
tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="税号")
|
||||
register_address: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="注册地址")
|
||||
register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="注册电话")
|
||||
bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="开户行")
|
||||
bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="银行账号")
|
||||
email: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="接收邮箱")
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否默认")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, comment="创建时间"
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, comment="更新时间"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_invoice_headers_user', 'user_id'),
|
||||
)
|
||||
@@ -81,3 +81,55 @@ class InvoiceOut(BaseModel):
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
orders: list[InvoiceOrderOut] = []
|
||||
|
||||
|
||||
# ── 发票抬头 ──────────────────────────────────────────────
|
||||
|
||||
class InvoiceHeaderCreate(BaseModel):
|
||||
"""创建发票抬头请求。"""
|
||||
type: str = Field(..., pattern="^(personal|company)$", description="抬头类型")
|
||||
name: str = Field(..., min_length=1, max_length=128, description="抬头名称")
|
||||
tax_no: str | None = Field(None, max_length=32, description="税号")
|
||||
register_address: str | None = Field(None, max_length=256, description="注册地址")
|
||||
register_phone: str | None = Field(None, max_length=32, description="注册电话")
|
||||
bank_name: str | None = Field(None, max_length=128, description="开户行")
|
||||
bank_account: str | None = Field(None, max_length=64, description="银行账号")
|
||||
email: str | None = Field(None, max_length=128, description="接收邮箱")
|
||||
is_default: bool = Field(False, description="是否设为默认")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_company_fields(self) -> "InvoiceHeaderCreate":
|
||||
if self.type == "company" and not self.tax_no:
|
||||
raise ValueError("企业抬头必须填写税号")
|
||||
return self
|
||||
|
||||
|
||||
class InvoiceHeaderUpdate(BaseModel):
|
||||
"""更新发票抬头请求。"""
|
||||
name: str | None = Field(None, min_length=1, max_length=128, description="抬头名称")
|
||||
tax_no: str | None = Field(None, max_length=32, description="税号")
|
||||
register_address: str | None = Field(None, max_length=256, description="注册地址")
|
||||
register_phone: str | None = Field(None, max_length=32, description="注册电话")
|
||||
bank_name: str | None = Field(None, max_length=128, description="开户行")
|
||||
bank_account: str | None = Field(None, max_length=64, description="银行账号")
|
||||
email: str | None = Field(None, max_length=128, description="接收邮箱")
|
||||
is_default: bool | None = Field(None, description="是否设为默认")
|
||||
|
||||
|
||||
class InvoiceHeaderOut(BaseModel):
|
||||
"""发票抬头响应体。"""
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
type: str
|
||||
name: str
|
||||
tax_no: str | None = None
|
||||
register_address: str | None = None
|
||||
register_phone: str | None = None
|
||||
bank_name: str | None = None
|
||||
bank_account: str | None = None
|
||||
email: str | None = None
|
||||
is_default: bool = False
|
||||
created_at: NaiveDatetimeOptional = None
|
||||
updated_at: NaiveDatetimeOptional = None
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.invoice_header import InvoiceHeader
|
||||
from app.schemas.invoice import InvoiceHeaderCreate, InvoiceHeaderUpdate
|
||||
from app.utils.id import generate_id
|
||||
|
||||
logger = logging.getLogger("videogen")
|
||||
|
||||
|
||||
async def create_header(db: AsyncSession, user_id: str, data: InvoiceHeaderCreate) -> InvoiceHeader:
|
||||
"""创建发票抬头。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
header = InvoiceHeader(
|
||||
id=generate_id(),
|
||||
user_id=user_id,
|
||||
type=data.type,
|
||||
name=data.name,
|
||||
tax_no=data.tax_no,
|
||||
register_address=data.register_address,
|
||||
register_phone=data.register_phone,
|
||||
bank_name=data.bank_name,
|
||||
bank_account=data.bank_account,
|
||||
email=data.email,
|
||||
is_default=data.is_default,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
# 如果设为默认,先将其他抬头取消默认
|
||||
if data.is_default:
|
||||
await db.execute(
|
||||
update(InvoiceHeader)
|
||||
.where(InvoiceHeader.user_id == user_id)
|
||||
.values(is_default=False, updated_at=now)
|
||||
)
|
||||
|
||||
db.add(header)
|
||||
await db.flush()
|
||||
return header
|
||||
|
||||
|
||||
async def get_user_headers(db: AsyncSession, user_id: str) -> list[InvoiceHeader]:
|
||||
"""获取用户的所有发票抬头。"""
|
||||
result = await db.execute(
|
||||
select(InvoiceHeader)
|
||||
.where(InvoiceHeader.user_id == user_id)
|
||||
.order_by(InvoiceHeader.is_default.desc(), InvoiceHeader.created_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_header_by_id(db: AsyncSession, header_id: str, user_id: str) -> InvoiceHeader | None:
|
||||
"""获取指定发票抬头(仅限本人)。"""
|
||||
result = await db.execute(
|
||||
select(InvoiceHeader).where(
|
||||
InvoiceHeader.id == header_id,
|
||||
InvoiceHeader.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_header(
|
||||
db: AsyncSession, header_id: str, user_id: str, data: InvoiceHeaderUpdate
|
||||
) -> InvoiceHeader:
|
||||
"""更新发票抬头。"""
|
||||
header = await get_header_by_id(db, header_id, user_id)
|
||||
if not header:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
|
||||
|
||||
update_data = {}
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
update_data[field] = value
|
||||
|
||||
if update_data:
|
||||
update_data["updated_at"] = datetime.now(timezone.utc)
|
||||
await db.execute(
|
||||
update(InvoiceHeader)
|
||||
.where(InvoiceHeader.id == header_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
|
||||
# 如果设为默认,先将其他抬头取消默认
|
||||
if data.is_default:
|
||||
now = datetime.now(timezone.utc)
|
||||
await db.execute(
|
||||
update(InvoiceHeader)
|
||||
.where(InvoiceHeader.user_id == user_id, InvoiceHeader.id != header_id)
|
||||
.values(is_default=False, updated_at=now)
|
||||
)
|
||||
|
||||
await db.refresh(header)
|
||||
return header
|
||||
|
||||
|
||||
async def delete_header(db: AsyncSession, header_id: str, user_id: str) -> None:
|
||||
"""删除发票抬头。"""
|
||||
header = await get_header_by_id(db, header_id, user_id)
|
||||
if not header:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
|
||||
|
||||
await db.delete(header)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def set_default_header(db: AsyncSession, header_id: str, user_id: str) -> InvoiceHeader:
|
||||
"""设置默认发票抬头。"""
|
||||
header = await get_header_by_id(db, header_id, user_id)
|
||||
if not header:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票抬头不存在")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# 先取消其他默认
|
||||
await db.execute(
|
||||
update(InvoiceHeader)
|
||||
.where(InvoiceHeader.user_id == user_id, InvoiceHeader.id != header_id)
|
||||
.values(is_default=False, updated_at=now)
|
||||
)
|
||||
# 设置当前为默认
|
||||
header.is_default = True
|
||||
header.updated_at = now
|
||||
await db.flush()
|
||||
return header
|
||||
Reference in New Issue
Block a user