290 lines
8.9 KiB
Python
290 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 = f"订单 {order.order_no} 未支付,无法开票",
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
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
|