Files
video-gen/video-gen-api/app/services/invoice.py
T
2026-08-10 18:47:34 +08:00

293 lines
8.9 KiB
Python

import logging
import random
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.invoice import Invoice, InvoiceOrder
from app.models.payment_order import PaymentOrder
from app.schemas.invoice import InvoiceCreateRequest, InvoiceStatusUpdateRequest
from app.utils.id_gen import generate_id
logger = logging.getLogger("videogen")
CST = timezone(timedelta(hours=8))
def _generate_invoice_no() -> str:
"""生成发票编号:FP + YYYYMMDD + 5位随机数。"""
now = datetime.now(CST)
date_str = now.strftime("%Y%m%d")
random_part = str(random.randint(10000, 99999))
return f"FP{date_str}{random_part}"
async def check_orders_available(
db: AsyncSession,
order_ids: list[str],
exclude_invoice_id: str | None = None,
) -> list[dict]:
"""检查订单是否已被其他 processing/success 发票占用。
返回被占用的订单列表,每项包含 order_id、order_no、invoice_no。
"""
stmt = (
select(InvoiceOrder.order_id, InvoiceOrder.order_no, Invoice.invoice_no)
.join(Invoice, InvoiceOrder.invoice_id == Invoice.id)
.where(
InvoiceOrder.order_id.in_(order_ids),
Invoice.status.in_(["processing", "success"]),
)
)
if exclude_invoice_id:
stmt = stmt.where(Invoice.id != exclude_invoice_id)
result = await db.execute(stmt)
rows = result.all()
return [
{"order_id": row.order_id, "order_no": row.order_no, "invoice_no": row.invoice_no}
for row in rows
]
async def create_invoice(
db: AsyncSession,
user_id: str,
data: InvoiceCreateRequest,
) -> Invoice:
"""创建发票。校验订单归属、订单唯一性,创建主表+关联表。"""
# 1. 查询订单并校验归属
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.id.in_(data.order_ids))
)
orders = result.scalars().all()
if len(orders) != len(data.order_ids):
found_ids = {o.id for o in orders}
missing = [oid for oid in data.order_ids if oid not in found_ids]
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"订单不存在: {', '.join(missing)}",
)
for order in orders:
if order.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"订单 {order.order_no} 不属于当前用户",
)
if order.status != "paid":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"订单 {order.order_no} 未支付,无法开票",
)
# 2. 检查订单唯一性
occupied = await check_orders_available(db, data.order_ids)
if occupied:
details = "; ".join(
f"订单 {o['order_no']} 已被发票 {o['invoice_no']} 占用"
for o in occupied
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=details,
)
# 3. 创建发票
total_amount = sum(float(o.amount) for o in orders)
total_credits = sum(float(o.credits) for o in orders)
invoice = Invoice(
id=generate_id(),
user_id=user_id,
invoice_no=_generate_invoice_no(),
header_type=data.header_type,
header_name=data.header_name,
header_tax_no=data.header_tax_no,
header_register_address=data.header_register_address,
header_register_phone=data.header_register_phone,
header_bank_name=data.header_bank_name,
header_bank_account=data.header_bank_account,
email=data.email,
total_amount=round(total_amount, 2),
total_credits=round(total_credits, 2),
status="processing",
)
db.add(invoice)
await db.flush()
# 4. 创建关联表
for order in orders:
io = InvoiceOrder(
id=generate_id(),
invoice_id=invoice.id,
order_id=order.id,
order_no=order.order_no,
amount=round(float(order.amount), 2),
credits=round(float(order.credits), 2),
)
db.add(io)
await db.flush()
return invoice
async def get_user_invoices(
db: AsyncSession,
user_id: str,
page: int = 1,
page_size: int = 20,
) -> tuple[list[Invoice], int]:
"""获取用户发票列表。"""
count_query = select(func.count(Invoice.id)).where(Invoice.user_id == user_id)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
select(Invoice)
.where(Invoice.user_id == user_id)
.order_by(Invoice.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
invoices = result.scalars().all()
return list(invoices), total
async def get_invoice_by_id(
db: AsyncSession,
invoice_id: str,
) -> Invoice | None:
"""获取发票详情。"""
result = await db.execute(
select(Invoice).where(Invoice.id == invoice_id).limit(1)
)
return result.scalar_one_or_none()
async def get_invoice_with_orders(
db: AsyncSession,
invoice_id: str,
) -> dict | None:
"""获取发票+关联订单详情。"""
invoice = await get_invoice_by_id(db, invoice_id)
if not invoice:
return None
result = await db.execute(
select(InvoiceOrder)
.where(InvoiceOrder.invoice_id == invoice_id)
.order_by(InvoiceOrder.created_at.asc())
)
orders = result.scalars().all()
return {
"invoice": invoice,
"orders": list(orders),
}
async def update_invoice_status(
db: AsyncSession,
invoice_id: str,
data: InvoiceStatusUpdateRequest,
admin_id: str,
) -> Invoice:
"""更新发票状态,记录审计日志。"""
invoice = await get_invoice_by_id(db, invoice_id)
if not invoice:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="发票不存在",
)
# 终态校验
if invoice.status in ("success", "failed"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"发票已终结({invoice.status}),无法变更",
)
old_status = invoice.status
invoice.status = data.status
if data.status == "success":
invoice.issued_at = datetime.now(CST)
invoice.failure_reason = None
elif data.status == "failed":
invoice.failure_reason = data.failure_reason
invoice.issued_at = None
await db.flush()
return invoice, old_status
async def get_admin_invoices(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
status_filter: str | None = None,
phone: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> tuple[list[dict], int]:
"""后台获取发票列表(含用户信息)。"""
from app.models.user import User
query = select(Invoice, User.username, User.phone).join(User, Invoice.user_id == User.id)
count_query = select(func.count(Invoice.id))
filters = []
if status_filter:
filters.append(Invoice.status == status_filter)
if phone:
filters.append(User.phone.ilike(f"%{phone.strip()}%"))
if start_date:
filters.append(Invoice.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
if end_date:
filters.append(
Invoice.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
query.order_by(Invoice.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
rows = result.all()
items = []
for invoice, username, user_phone in rows:
# 获取关联订单数
order_count_result = await db.execute(
select(func.count(InvoiceOrder.id)).where(InvoiceOrder.invoice_id == invoice.id)
)
order_count = order_count_result.scalar() or 0
items.append({
"id": invoice.id,
"invoiceNo": invoice.invoice_no,
"userId": invoice.user_id,
"username": username,
"phone": user_phone,
"headerType": invoice.header_type,
"headerName": invoice.header_name,
"email": invoice.email,
"totalAmount": round(float(invoice.total_amount), 2),
"totalCredits": round(float(invoice.total_credits), 2),
"orderCount": order_count,
"status": invoice.status,
"failureReason": invoice.failure_reason,
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
"createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
})
return items, total