From 6d30347cdbb9a41983bfc922cc79e6415d401590 Mon Sep 17 00:00:00 2001 From: wwwwwwwww <526125649@qq.com> Date: Mon, 10 Aug 2026 15:39:00 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E5=A2=9E=E5=8A=A0=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E7=A5=A8=E5=8A=9F=E8=83=BD=E5=92=8C=E5=8F=91?= =?UTF-8?q?=E7=A5=A8=E6=8A=AC=E5=A4=B4=E6=B7=BB=E5=8A=A0=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=202=E3=80=81=E4=B8=80=E4=B8=AA=E8=AE=A2=E5=8D=95=E5=8F=AA?= =?UTF-8?q?=E8=83=BD=E5=9C=A8=E4=B8=80=E4=B8=AA=E5=BC=80=E7=A5=A8=E9=87=8C?= =?UTF-8?q?,=E4=B8=8D=E5=85=81=E8=AE=B8=E5=A4=9A=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-admin/src/App.tsx | 2 + video-gen-admin/src/api/index.ts | 34 ++ video-gen-admin/src/pages/AdminInvoices.tsx | 421 ++++++++++++++++++ video-gen-admin/src/types/index.ts | 49 ++ .../versions/20260810_20260810_发票管理表.py | 109 +++++ video-gen-api/app/api/v1/__init__.py | 2 + video-gen-api/app/api/v1/admin.py | 122 ++++- video-gen-api/app/api/v1/invoices.py | 110 +++++ video-gen-api/app/models/__init__.py | 2 + video-gen-api/app/models/invoice.py | 57 +++ video-gen-api/app/schemas/invoice.py | 83 ++++ video-gen-api/app/services/invoice.py | 292 ++++++++++++ video-gen-app/src/api/index.ts | 37 ++ video-gen-app/src/pages/InvoicePage.tsx | 140 ++++-- 14 files changed, 1425 insertions(+), 35 deletions(-) create mode 100644 video-gen-admin/src/pages/AdminInvoices.tsx create mode 100644 video-gen-api/alembic/versions/20260810_20260810_发票管理表.py create mode 100644 video-gen-api/app/api/v1/invoices.py create mode 100644 video-gen-api/app/models/invoice.py create mode 100644 video-gen-api/app/schemas/invoice.py create mode 100644 video-gen-api/app/services/invoice.py diff --git a/video-gen-admin/src/App.tsx b/video-gen-admin/src/App.tsx index 33e82977..8385a7db 100644 --- a/video-gen-admin/src/App.tsx +++ b/video-gen-admin/src/App.tsx @@ -42,6 +42,7 @@ import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects'; import AdminApiKeys from './pages/AdminApiKeys'; import AdminApiModelPricings from './pages/AdminApiModelPricings'; import AdminApiUsage from './pages/AdminApiUsage'; +import AdminInvoices from './pages/AdminInvoices'; import { useAdminStore } from './store'; @@ -106,6 +107,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index a27cdfe1..dd29809b 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -568,6 +568,40 @@ export async function refundPaymentOrder(orderNo: string): Promise { await api.post(`/admin/payment-orders/${orderNo}/refund`); } +// ── Invoice Management ─────────────────────────────────── + +export async function getAdminInvoices(params?: { + page?: number; + pageSize?: number; + status?: string; + phone?: string; + startDate?: string; + endDate?: string; +}): Promise<{ items: any[]; total: number }> { + const qs = new URLSearchParams(); + if (params?.page) qs.set('page', String(params.page)); + if (params?.pageSize) qs.set('page_size', String(params.pageSize)); + if (params?.status) qs.set('status', params.status); + if (params?.phone) qs.set('phone', params.phone); + if (params?.startDate) qs.set('start_date', params.startDate); + if (params?.endDate) qs.set('end_date', params.endDate); + return api.get(`/admin/invoices?${qs.toString()}`); +} + +export async function getAdminInvoiceDetail(id: string): Promise { + return api.get(`/admin/invoices/${id}`); +} + +export async function updateInvoiceStatus(id: string, data: { + status: 'success' | 'failed'; + failureReason?: string; +}): Promise { + await api.put(`/admin/invoices/${id}/status`, { + status: data.status, + failure_reason: data.failureReason, + }); +} + export async function getAdminNotifications(page = 1, pageSize = 20): Promise<{ total: number; items: any[] }> { const params = new URLSearchParams(); params.set('page', String(page)); diff --git a/video-gen-admin/src/pages/AdminInvoices.tsx b/video-gen-admin/src/pages/AdminInvoices.tsx new file mode 100644 index 00000000..232b4c8f --- /dev/null +++ b/video-gen-admin/src/pages/AdminInvoices.tsx @@ -0,0 +1,421 @@ +import React, { useState, useEffect } from 'react'; +import { Table, Button, Tag, Space, Typography, message, Modal, Card, Popconfirm, Empty, Input, DatePicker } from 'antd'; +import { CheckOutlined, CloseOutlined, EyeOutlined, FileTextOutlined, FilterOutlined } from '@ant-design/icons'; +import { getAdminInvoices, getAdminInvoiceDetail, updateInvoiceStatus } from '../api'; +import { formatDate } from '../utils/formatDate'; +import type { InvoiceItem, InvoiceDetail } from '../types'; + +const AdminInvoices: React.FC = () => { + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [statusFilter, setStatusFilter] = useState(null); + const [phoneFilter, setPhoneFilter] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + // 详情弹窗 + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [detailLoading, setDetailLoading] = useState(false); + const [currentDetail, setCurrentDetail] = useState(null); + + // 失败原因弹窗 + const [failModalOpen, setFailModalOpen] = useState(false); + const [failReason, setFailReason] = useState(''); + const [failTargetId, setFailTargetId] = useState(null); + const [failSubmitting, setFailSubmitting] = useState(false); + + const fetchData = async () => { + setLoading(true); + try { + const res = await getAdminInvoices({ + page, + pageSize, + status: statusFilter || undefined, + phone: phoneFilter || undefined, + startDate: startDate || undefined, + endDate: endDate || undefined, + }); + setData(res.items); + setTotal(res.total); + } catch (err: any) { + message.error(err?.message || '获取失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, [page, pageSize, statusFilter, startDate, endDate]); + + const handleSearch = () => { + setPage(1); + fetchData(); + }; + + const handleViewDetail = async (id: string) => { + setDetailLoading(true); + setDetailModalOpen(true); + try { + const detail = await getAdminInvoiceDetail(id); + setCurrentDetail(detail); + } catch (err: any) { + message.error(err?.message || '获取详情失败'); + setDetailModalOpen(false); + } finally { + setDetailLoading(false); + } + }; + + const handleMarkSuccess = async (id: string) => { + try { + await updateInvoiceStatus(id, { status: 'success' }); + message.success('已标记为开具成功'); + fetchData(); + } catch (err: any) { + message.error(err?.message || '操作失败'); + } + }; + + const handleOpenFailModal = (id: string) => { + setFailTargetId(id); + setFailReason(''); + setFailModalOpen(true); + }; + + const handleConfirmFail = async () => { + if (!failReason.trim()) { + message.warning('请填写失败原因'); + return; + } + if (!failTargetId) return; + setFailSubmitting(true); + try { + await updateInvoiceStatus(failTargetId, { status: 'failed', failureReason: failReason.trim() }); + message.success('已标记为开具失败'); + setFailModalOpen(false); + setFailTargetId(null); + setFailReason(''); + fetchData(); + } catch (err: any) { + message.error(err?.message || '操作失败'); + } + setFailSubmitting(false); + }; + + const handlePageChange = (p: number, ps: number) => { + setPage(p); + setPageSize(ps); + }; + + const statusTag = (status: string) => { + const config: Record = { + processing: { color: 'blue', label: '开具中' }, + success: { color: 'green', label: '已开具' }, + failed: { color: 'red', label: '已失败' }, + }; + const c = config[status] || { color: 'default', label: status }; + return {c.label}; + }; + + const columns = [ + { + title: '发票编号', + dataIndex: 'invoiceNo', + key: 'invoiceNo', + width: 180, + render: (v: string) => {v}, + }, + { + title: '用户', + dataIndex: 'username', + key: 'username', + width: 100, + render: (_: string, record: InvoiceItem) => ( +
+
{record.username}
+
{record.phone}
+
+ ), + }, + { + title: '抬头类型', + dataIndex: 'headerType', + key: 'headerType', + width: 80, + render: (v: string) => (v === 'company' ? '企业' : '个人'), + }, + { + title: '抬头名称', + dataIndex: 'headerName', + key: 'headerName', + width: 160, + ellipsis: true, + }, + { + title: '邮箱', + dataIndex: 'email', + key: 'email', + width: 160, + ellipsis: true, + }, + { + title: '总金额', + dataIndex: 'totalAmount', + key: 'totalAmount', + width: 100, + align: 'right' as const, + render: (v: number) => ¥{v.toFixed(2)}, + }, + { + title: '订单数', + dataIndex: 'orderCount', + key: 'orderCount', + width: 70, + align: 'center' as const, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 90, + render: (v: string) => statusTag(v), + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 160, + render: (v: string) => formatDate(v), + }, + { + title: '操作', + key: 'actions', + width: 200, + render: (_: unknown, record: InvoiceItem) => ( + + + {record.status === 'processing' && ( + <> + + + + )} + + ), + }, + ]; + + return ( +
+ +
+ + + 发票管理 + 共 {total} 条记录 + +
+ + + + +
+
+ + {/* 搜索栏 */} +
+ setPhoneFilter(e.target.value)} + style={{ width: 200 }} + allowClear + onPressEnter={handleSearch} + /> + setStartDate(d ? d.format('YYYY-MM-DD') : '')} + /> + setEndDate(d ? d.format('YYYY-MM-DD') : '')} + /> + +
+ + {loading ? ( +
加载中...
+ ) : data.length === 0 ? ( + + ) : ( + `共 ${t} 条记录`, + }} + scroll={{ x: 1200 }} + /> + )} + + + {/* 详情弹窗 */} + 发票详情} + open={detailModalOpen} + onCancel={() => { setDetailModalOpen(false); setCurrentDetail(null); }} + footer={null} + width={700} + > + {detailLoading ? ( +
加载中...
+ ) : currentDetail && ( +
+ {/* 基本信息 */} +
+ + {currentDetail.invoiceNo} + {statusTag(currentDetail.status)} + +
+ 用户ID: + {currentDetail.userId} + 抬头类型: + {currentDetail.headerType === 'company' ? '企业' : '个人'} + 抬头名称: + {currentDetail.headerName} + {currentDetail.headerTaxNo && ( + <> + 税号: + {currentDetail.headerTaxNo} + + )} + 接收邮箱: + {currentDetail.email} + 总金额: + ¥{currentDetail.totalAmount.toFixed(2)} + 创建时间: + {formatDate(currentDetail.createdAt)} + {currentDetail.issuedAt && ( + <> + 开票时间: + {formatDate(currentDetail.issuedAt)} + + )} + {currentDetail.failureReason && ( + <> + 失败原因: + {currentDetail.failureReason} + + )} +
+
+ + {/* 关联订单 */} + {currentDetail.orders && currentDetail.orders.length > 0 && ( +
+ + 关联订单({currentDetail.orders.length} 笔) + +
{v}, + }, + { + title: '金额', + dataIndex: 'amount', + key: 'amount', + align: 'right' as const, + render: (v: number) => `¥${v.toFixed(2)}`, + }, + { + title: '积分', + dataIndex: 'credits', + key: 'credits', + align: 'right' as const, + }, + ]} + rowKey="id" + pagination={false} + size="small" + /> + + )} + + )} + + + {/* 失败原因弹窗 */} + { setFailModalOpen(false); setFailTargetId(null); setFailReason(''); }} + onOk={handleConfirmFail} + okText="确认" + cancelText="取消" + confirmLoading={failSubmitting} + > + + 请填写失败原因(用户可见): + + setFailReason(e.target.value)} + placeholder="例如:抬头信息有误,请重新提交" + rows={3} + maxLength={500} + showCount + /> + + + ); +}; + +export default AdminInvoices; diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts index dad13673..12836d37 100644 --- a/video-gen-admin/src/types/index.ts +++ b/video-gen-admin/src/types/index.ts @@ -1442,3 +1442,52 @@ export interface VideoUpscaleConfigSavePayload { }>; }; } + +// ── Invoice Types ─────────────────────────────────────── + +export interface InvoiceItem { + id: string; + invoiceNo: string; + userId: string; + username: string; + phone: string; + headerType: string; + headerName: string; + email: string; + totalAmount: number; + totalCredits: number; + orderCount: number; + status: string; + failureReason: string | null; + issuedAt: string | null; + createdAt: string | null; +} + +export interface InvoiceOrder { + id: string; + orderNo: string; + amount: number; + credits: number; +} + +export interface InvoiceDetail { + id: string; + invoiceNo: string; + userId: string; + headerType: string; + headerName: string; + headerTaxNo: string | null; + headerRegisterAddress: string | null; + headerRegisterPhone: string | null; + headerBankName: string | null; + headerBankAccount: string | null; + email: string; + totalAmount: number; + totalCredits: number; + status: string; + failureReason: string | null; + issuedAt: string | null; + createdAt: string | null; + updatedAt: string | null; + orders: InvoiceOrder[]; +} diff --git a/video-gen-api/alembic/versions/20260810_20260810_发票管理表.py b/video-gen-api/alembic/versions/20260810_20260810_发票管理表.py new file mode 100644 index 00000000..44041230 --- /dev/null +++ b/video-gen-api/alembic/versions/20260810_20260810_发票管理表.py @@ -0,0 +1,109 @@ +"""发票管理表迁移 + +创建 invoices(发票主表)和 invoice_orders(发票-订单关联表)。 + +Revision ID: 20260810_20260810 +Revises: 2026080601 +Create Date: 2026-08-10 00:00:00.000000 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '20260810_20260810' +down_revision: Union[str, None] = '2026080601' +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: + # ============================================================ + # 1. 创建 invoices 表 + # ============================================================ + op.create_table( + 'invoices', + 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('invoice_no', sa.String(32), nullable=False, unique=True), + sa.Column('header_type', sa.String(16), nullable=False), + sa.Column('header_name', sa.String(128), nullable=False), + sa.Column('header_tax_no', sa.String(32), nullable=True), + sa.Column('header_register_address', sa.String(256), nullable=True), + sa.Column('header_register_phone', sa.String(32), nullable=True), + sa.Column('header_bank_name', sa.String(128), nullable=True), + sa.Column('header_bank_account', sa.String(64), nullable=True), + sa.Column('email', sa.String(128), nullable=False), + sa.Column('total_amount', sa.Float, nullable=False, server_default='0'), + sa.Column('total_credits', sa.Float, nullable=False, server_default='0'), + sa.Column('status', sa.String(16), nullable=False, server_default='processing'), + sa.Column('failure_reason', sa.Text, nullable=True), + sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True), + 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_invoices_user_created', 'invoices', ['user_id', 'created_at']) + op.create_index('idx_invoices_status_created', 'invoices', ['status', 'created_at']) + op.create_index('idx_invoices_invoice_no', 'invoices', ['invoice_no'], unique=True) + + # ============================================================ + # 2. 创建 invoice_orders 表 + # ============================================================ + op.create_table( + 'invoice_orders', + sa.Column('id', sa.String(32), primary_key=True), + sa.Column('invoice_id', sa.String(32), sa.ForeignKey('invoices.id', ondelete='CASCADE'), nullable=False), + sa.Column('order_id', sa.String(32), sa.ForeignKey('payment_orders.id', ondelete='CASCADE'), nullable=False), + sa.Column('order_no', sa.String(64), nullable=False), + sa.Column('amount', sa.Float, nullable=False, server_default='0'), + sa.Column('credits', sa.Float, nullable=False, server_default='0'), + 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_orders_invoice', 'invoice_orders', ['invoice_id']) + op.create_index('idx_invoice_orders_order', 'invoice_orders', ['order_id']) + op.create_unique_constraint('uq_invoice_orders', 'invoice_orders', ['invoice_id', 'order_id']) + + # ============================================================ + # 3. 表注释和字段注释 + # ============================================================ + _comment_table('invoices', '发票主表') + _comment_column('invoices', 'id', '主键') + _comment_column('invoices', 'user_id', '申请用户ID') + _comment_column('invoices', 'invoice_no', '发票编号') + _comment_column('invoices', 'header_type', '抬头类型: personal/company') + _comment_column('invoices', 'header_name', '抬头名称') + _comment_column('invoices', 'header_tax_no', '税号') + _comment_column('invoices', 'header_register_address', '注册地址') + _comment_column('invoices', 'header_register_phone', '注册电话') + _comment_column('invoices', 'header_bank_name', '开户行') + _comment_column('invoices', 'header_bank_account', '银行账号') + _comment_column('invoices', 'email', '电子邮箱(必填)') + _comment_column('invoices', 'total_amount', '开票总金额') + _comment_column('invoices', 'total_credits', '总积分') + _comment_column('invoices', 'status', '状态: processing/success/failed') + _comment_column('invoices', 'failure_reason', '失败原因') + _comment_column('invoices', 'issued_at', '开票成功时间') + + _comment_table('invoice_orders', '发票-订单关联表') + _comment_column('invoice_orders', 'id', '主键') + _comment_column('invoice_orders', 'invoice_id', '发票ID') + _comment_column('invoice_orders', 'order_id', '订单ID') + _comment_column('invoice_orders', 'order_no', '订单号(冗余)') + _comment_column('invoice_orders', 'amount', '订单金额(冗余)') + _comment_column('invoice_orders', 'credits', '订单积分(冗余)') + + +def downgrade() -> None: + op.drop_table('invoice_orders') + op.drop_table('invoices') diff --git a/video-gen-api/app/api/v1/__init__.py b/video-gen-api/app/api/v1/__init__.py index bd89d179..af1610cc 100644 --- a/video-gen-api/app/api/v1/__init__.py +++ b/video-gen-api/app/api/v1/__init__.py @@ -35,6 +35,7 @@ from app.api.v1.material_admin import router as material_admin_router 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 api_router = APIRouter() api_router.include_router(auth_router) @@ -72,3 +73,4 @@ api_router.include_router(material_admin_router) 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) diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index 8c37a1b9..7e3097b5 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -60,6 +60,7 @@ from app.services.resource_signed_url_service import build_resource_signed_url from app.services.payment import process_refund from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage from app.services.team_service import batch_get_team_name_map, set_frontend_user_team +from app.schemas.invoice import InvoiceStatusUpdateRequest from app.utils.id_gen import generate_id @@ -1973,11 +1974,10 @@ async def get_stats( ) # ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示) - # 把 timestamptz 按东八区(业务时区)偏移后再转 DATE, - # 直接手动 +8 小时再 CAST 成日期,简单稳妥,不依赖数据库时区名配置。 - # 与代码中 CST = timezone(timedelta(hours=8)) 保持一致。 + # created_at 为 timestamptz,数据库 session 时区已是东八区(CST), + # 读取出来的时间值即为北京时间,直接 CAST 成日期即可,无需再 +8 小时。 from sqlalchemy import Date, cast as sa_cast - _day_expr = sa_cast(CreditRecord.created_at + timedelta(hours=8), Date) + _day_expr = sa_cast(CreditRecord.created_at, Date) # 图表固定展示 [date_end - 6天, date_end] 共7天 _chart_end_dt = date_end _chart_start_dt = datetime( @@ -2487,6 +2487,118 @@ async def upload_login_video( return {"url": url} -# ── Payment Stats ──────────────────────────────────────── +# ── Invoice Management ─────────────────────────────────── + + +@router.get("/invoices") +async def admin_list_invoices( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=500), + status: str | None = Query(None), + phone: str | None = Query(None, description="按用户手机号模糊搜索"), + start_date: str | None = Query(None), + end_date: str | None = Query(None), + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + """后台发票列表(分页+筛选)。""" + from app.services.invoice import get_admin_invoices + + items, total = await get_admin_invoices( + db, page, page_size, + status_filter=status, + phone=phone, + start_date=start_date, + end_date=end_date, + ) + return {"items": items, "total": total, "page": page, "page_size": page_size} + + +@router.get("/invoices/{invoice_id}") +async def admin_get_invoice( + invoice_id: str, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + """后台获取发票详情(含关联订单)。""" + from app.services.invoice import get_invoice_with_orders + + detail = await get_invoice_with_orders(db, invoice_id) + if not detail: + raise HTTPException(status_code=404, detail="发票不存在") + + invoice = detail["invoice"] + orders = detail["orders"] + return { + "id": invoice.id, + "invoiceNo": invoice.invoice_no, + "userId": invoice.user_id, + "headerType": invoice.header_type, + "headerName": invoice.header_name, + "headerTaxNo": invoice.header_tax_no, + "headerRegisterAddress": invoice.header_register_address, + "headerRegisterPhone": invoice.header_register_phone, + "headerBankName": invoice.header_bank_name, + "headerBankAccount": invoice.header_bank_account, + "email": invoice.email, + "totalAmount": round(float(invoice.total_amount), 2), + "totalCredits": round(float(invoice.total_credits), 2), + "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, + "updatedAt": invoice.updated_at.isoformat() if invoice.updated_at else None, + "orders": [ + { + "id": o.id, + "orderNo": o.order_no, + "amount": round(float(o.amount), 2), + "credits": round(float(o.credits), 2), + } + for o in orders + ], + } + + +@router.put("/invoices/{invoice_id}/status") +async def admin_update_invoice_status( + invoice_id: str, + req: InvoiceStatusUpdateRequest, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + """更新发票状态(success/failed)。""" + from app.services.invoice import update_invoice_status + + invoice, old_status = await update_invoice_status(db, invoice_id, req, admin.id) + await db.flush() + + await log_operation( + db, + admin.id, + admin.username, + f"发票状态变更: {invoice.invoice_no} {old_status} → {req.status}", + "PUT", + f"/admin/invoices/{invoice_id}/status", + detail=json.dumps( + { + "invoice_id": invoice_id, + "invoice_no": invoice.invoice_no, + "old_status": old_status, + "new_status": req.status, + "failure_reason": req.failure_reason, + }, + ensure_ascii=False, + ), + ) + await db.commit() + + return { + "id": invoice.id, + "invoiceNo": invoice.invoice_no, + "status": invoice.status, + "failureReason": invoice.failure_reason, + "issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None, + } diff --git a/video-gen-api/app/api/v1/invoices.py b/video-gen-api/app/api/v1/invoices.py new file mode 100644 index 00000000..2516f218 --- /dev/null +++ b/video-gen-api/app/api/v1/invoices.py @@ -0,0 +1,110 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_db, get_current_user +from app.models.invoice import Invoice, InvoiceOrder +from app.models.user import User +from app.schemas.invoice import InvoiceCreateRequest, InvoiceOut, InvoiceOrderOut +from app.services.invoice import ( + create_invoice, + get_user_invoices, + get_invoice_by_id, + get_invoice_with_orders, +) + +logger = logging.getLogger("videogen") + +router = APIRouter(prefix="/invoices", tags=["invoices"]) + + +@router.post("", response_model=InvoiceOut) +async def create_invoice_endpoint( + req: InvoiceCreateRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """创建发票申请。""" + invoice = await create_invoice(db, current_user.id, req) + await db.commit() + + # 重新查询以获取关联订单 + detail = await get_invoice_with_orders(db, invoice.id) + return _invoice_to_out(detail["invoice"], detail["orders"]) + + +@router.get("") +async def list_invoices( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取当前用户的发票列表(分页)。""" + invoices, total = await get_user_invoices(db, current_user.id, page, page_size) + + # 加载每个发票的关联订单 + items = [] + for inv in invoices: + result = await db.execute( + select(InvoiceOrder).where(InvoiceOrder.invoice_id == inv.id) + ) + orders = result.scalars().all() + items.append(_invoice_to_out(inv, list(orders))) + + return {"items": items, "total": total, "page": page, "page_size": page_size} + + +@router.get("/{invoice_id}") +async def get_invoice( + invoice_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取发票详情(含关联订单)。""" + detail = await get_invoice_with_orders(db, invoice_id) + if not detail: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票不存在") + + invoice = detail["invoice"] + if invoice.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权查看该发票") + + return _invoice_to_out(invoice, detail["orders"]) + + +def _invoice_to_out(invoice: Invoice, orders: list[InvoiceOrder]) -> dict: + """将 Invoice ORM 对象转换为响应 dict。""" + return { + "id": invoice.id, + "userId": invoice.user_id, + "invoiceNo": invoice.invoice_no, + "headerType": invoice.header_type, + "headerName": invoice.header_name, + "headerTaxNo": invoice.header_tax_no, + "headerRegisterAddress": invoice.header_register_address, + "headerRegisterPhone": invoice.header_register_phone, + "headerBankName": invoice.header_bank_name, + "headerBankAccount": invoice.header_bank_account, + "email": invoice.email, + "totalAmount": round(float(invoice.total_amount), 2), + "totalCredits": round(float(invoice.total_credits), 2), + "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, + "updatedAt": invoice.updated_at.isoformat() if invoice.updated_at else None, + "orders": [ + { + "id": o.id, + "invoiceId": o.invoice_id, + "orderId": o.order_id, + "orderNo": o.order_no, + "amount": round(float(o.amount), 2), + "credits": round(float(o.credits), 2), + } + for o in orders + ], + } diff --git a/video-gen-api/app/models/__init__.py b/video-gen-api/app/models/__init__.py index 71fd765d..0f1fbc1b 100644 --- a/video-gen-api/app/models/__init__.py +++ b/video-gen-api/app/models/__init__.py @@ -36,6 +36,7 @@ from app.models.user_oauth_account import UserOAuthAccount 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.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink @@ -57,4 +58,5 @@ __all__ = [ "PrivatePortraitAssetGroup", "PrivatePortraitAsset", "ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink", "ApiModelPricing", + "Invoice", "InvoiceOrder", ] diff --git a/video-gen-api/app/models/invoice.py b/video-gen-api/app/models/invoice.py new file mode 100644 index 00000000..fbd68b66 --- /dev/null +++ b/video-gen-api/app/models/invoice.py @@ -0,0 +1,57 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, String, Text, UniqueConstraint, Index +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin + + +class Invoice(Base, TimestampMixin): + __tablename__ = "invoices" + + id: Mapped[str] = mapped_column(String(32), primary_key=True) + user_id: Mapped[str] = mapped_column( + String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + invoice_no: Mapped[str] = mapped_column(String(32), unique=True, nullable=False) + header_type: Mapped[str] = mapped_column(String(16), nullable=False) + header_name: Mapped[str] = mapped_column(String(128), nullable=False) + header_tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True) + header_register_address: Mapped[str | None] = mapped_column(String(256), nullable=True) + header_register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True) + header_bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + header_bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True) + email: Mapped[str] = mapped_column(String(128), nullable=False) + total_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0) + total_credits: Mapped[float] = mapped_column(Float, nullable=False, default=0) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="processing") + failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + issued_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + Index('idx_invoices_user_created', 'user_id', 'created_at'), + Index('idx_invoices_status_created', 'status', 'created_at'), + ) + + +class InvoiceOrder(Base, TimestampMixin): + __tablename__ = "invoice_orders" + + id: Mapped[str] = mapped_column(String(32), primary_key=True) + invoice_id: Mapped[str] = mapped_column( + String(32), ForeignKey("invoices.id", ondelete="CASCADE"), index=True + ) + order_id: Mapped[str] = mapped_column( + String(32), ForeignKey("payment_orders.id", ondelete="CASCADE"), index=True + ) + order_no: Mapped[str] = mapped_column(String(64), nullable=False) + amount: Mapped[float] = mapped_column(Float, nullable=False, default=0) + credits: Mapped[float] = mapped_column(Float, nullable=False, default=0) + + __table_args__ = ( + UniqueConstraint('invoice_id', 'order_id', name='uq_invoice_orders'), + Index('idx_invoice_orders_invoice', 'invoice_id'), + Index('idx_invoice_orders_order', 'order_id'), + ) diff --git a/video-gen-api/app/schemas/invoice.py b/video-gen-api/app/schemas/invoice.py new file mode 100644 index 00000000..e811e734 --- /dev/null +++ b/video-gen-api/app/schemas/invoice.py @@ -0,0 +1,83 @@ +import re +from typing import Any + +from pydantic import BaseModel, Field, model_validator + +from app.schemas.common import NaiveDatetimeOptional + + +_EMAIL_REGEX = re.compile(r"^[\w.\-]+@[\w.\-]+\.\w+$") + + +class InvoiceCreateRequest(BaseModel): + """创建发票请求。""" + header_type: str = Field(..., pattern="^(personal|company)$", description="抬头类型") + header_name: str = Field(..., min_length=1, max_length=128, description="抬头名称") + header_tax_no: str | None = Field(None, max_length=32, description="税号") + header_register_address: str | None = Field(None, max_length=256, description="注册地址") + header_register_phone: str | None = Field(None, max_length=32, description="注册电话") + header_bank_name: str | None = Field(None, max_length=128, description="开户行") + header_bank_account: str | None = Field(None, max_length=64, description="银行账号") + email: str = Field(..., max_length=128, description="电子邮箱(必填)") + order_ids: list[str] = Field(..., min_length=1, description="订单ID列表") + + @model_validator(mode="after") + def validate_email(self) -> "InvoiceCreateRequest": + if not _EMAIL_REGEX.match(self.email): + raise ValueError("邮箱格式不正确") + return self + + @model_validator(mode="after") + def validate_company_fields(self) -> "InvoiceCreateRequest": + if self.header_type == "company" and not self.header_tax_no: + raise ValueError("企业抬头必须填写税号") + return self + + +class InvoiceStatusUpdateRequest(BaseModel): + """更新发票状态请求。""" + status: str = Field(..., pattern="^(success|failed)$", description="目标状态") + failure_reason: str | None = Field(None, max_length=500, description="失败原因") + + @model_validator(mode="after") + def validate_failure_reason(self) -> "InvoiceStatusUpdateRequest": + if self.status == "failed" and not self.failure_reason: + raise ValueError("开具失败时必须填写失败原因") + return self + + +class InvoiceOrderOut(BaseModel): + """发票关联订单响应。""" + model_config = {"from_attributes": True} + + id: str + invoice_id: str + order_id: str + order_no: str + amount: float + credits: float + + +class InvoiceOut(BaseModel): + """发票响应体。""" + model_config = {"from_attributes": True} + + id: str + user_id: str + invoice_no: str + header_type: str + header_name: str + header_tax_no: str | None = None + header_register_address: str | None = None + header_register_phone: str | None = None + header_bank_name: str | None = None + header_bank_account: str | None = None + email: str + total_amount: float + total_credits: float + status: str + failure_reason: str | None = None + issued_at: NaiveDatetimeOptional = None + created_at: NaiveDatetimeOptional = None + updated_at: NaiveDatetimeOptional = None + orders: list[InvoiceOrderOut] = [] diff --git a/video-gen-api/app/services/invoice.py b/video-gen-api/app/services/invoice.py new file mode 100644 index 00000000..8164519c --- /dev/null +++ b/video-gen-api/app/services/invoice.py @@ -0,0 +1,292 @@ +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_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 diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index 0c0df5ad..3c2db39e 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -460,6 +460,43 @@ export async function cancelPaymentOrder(orderNo: string): Promise { return api.post(`/payments/orders/${orderNo}/cancel`); } +// ── Invoices ────────────────────────────────────────────── + +export async function createInvoice(data: { + headerType: 'personal' | 'company'; + headerName: string; + headerTaxNo?: string; + headerRegisterAddress?: string; + headerRegisterPhone?: string; + headerBankName?: string; + headerBankAccount?: string; + email: string; + orderIds: string[]; +}): Promise { + return api.post('/invoices', { + header_type: data.headerType, + header_name: data.headerName, + header_tax_no: data.headerTaxNo, + header_register_address: data.headerRegisterAddress, + header_register_phone: data.headerRegisterPhone, + header_bank_name: data.headerBankName, + header_bank_account: data.headerBankAccount, + email: data.email, + order_ids: data.orderIds, + }); +} + +export async function getInvoices(params?: { page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> { + const qs = new URLSearchParams(); + if (params?.page) qs.set('page', String(params.page)); + if (params?.pageSize) qs.set('page_size', String(params.pageSize)); + return api.get(`/invoices?${qs.toString()}`); +} + +export async function getInvoiceDetail(id: string): Promise { + return api.get(`/invoices/${id}`); +} + export async function getCreditRatios(): Promise { return api.get('/credits/ratios'); } diff --git a/video-gen-app/src/pages/InvoicePage.tsx b/video-gen-app/src/pages/InvoicePage.tsx index 0493fe37..5c0b18e6 100644 --- a/video-gen-app/src/pages/InvoicePage.tsx +++ b/video-gen-app/src/pages/InvoicePage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { Table, Tag, Empty, Spin, Pagination, Button, Typography, Modal, Form, Input, Radio, message, Space } from 'antd'; import { FileTextOutlined, PlusOutlined, CloseOutlined, AlipayCircleOutlined, WechatOutlined } from '@ant-design/icons'; -import { getPaymentOrders } from '../api'; +import { getPaymentOrders, createInvoice, getInvoices, getInvoiceDetail } from '../api'; interface InvoiceRecord { id: string; @@ -37,6 +37,7 @@ const mockHeaders: InvoiceHeader[] = [ const InvoicePage: React.FC = () => { const [records, setRecords] = useState([]); + const [recordsLoading, setRecordsLoading] = useState(false); const [headers, setHeaders] = useState([]); const [headerModalOpen, setHeaderModalOpen] = useState(false); const [addModalOpen, setAddModalOpen] = useState(false); @@ -47,6 +48,8 @@ const InvoicePage: React.FC = () => { const [selectedOrdersMap, setSelectedOrdersMap] = useState>(new Map()); const [selectHeaderModalOpen, setSelectHeaderModalOpen] = useState(false); const [selectedHeaderId, setSelectedHeaderId] = useState(null); + const [email, setEmail] = useState(''); + const [submitting, setSubmitting] = useState(false); // 订单列表(从接口获取) const [orderLoading, setOrderLoading] = useState(false); @@ -87,6 +90,37 @@ const InvoicePage: React.FC = () => { setOrderLoading(false); }; + // 加载发票记录 + const loadInvoices = async () => { + setRecordsLoading(true); + try { + const data = await getInvoices({ page: 1, pageSize: 50 }); + const items = (data.items || []).map((item: any) => ({ + id: item.id, + orderNo: item.invoiceNo, + orderType: item.headerType === 'company' ? '企业' : '个人', + amount: item.totalAmount, + createdAt: item.createdAt, + // 后端状态映射:processing→processing, success→issued, failed→rejected + status: item.status === 'success' ? 'issued' : item.status === 'failed' ? 'rejected' : 'processing', + headerName: item.headerName, + headerType: item.headerType, + headerTaxNo: item.headerTaxNo, + selectedOrders: item.orders || [], + email: item.email, + failureReason: item.failureReason, + })); + setRecords(items); + } catch { + // 静默失败,保留空列表 + } + setRecordsLoading(false); + }; + + useEffect(() => { + loadInvoices(); + }, []); + useEffect(() => { if (issueModalOpen) { loadOrderData(1); @@ -125,10 +159,10 @@ const InvoicePage: React.FC = () => { }, { title: '开具时间', - dataIndex: 'createTime', - key: 'createTime', + dataIndex: 'createdAt', + key: 'createdAt', width: 180, - render: (text: string) => {text}, + render: (text: string) => text ? {new Date(text).toLocaleString('zh-CN')} : '-', }, { title: '发票状态', @@ -395,33 +429,50 @@ const InvoicePage: React.FC = () => { setSelectHeaderModalOpen(true); }; - // 开票流程:选择抬头后确认 → 计算总金额,添加到开票记录 - const handleHeaderConfirm = () => { + // 开票流程:选择抬头后确认 → 调用后端 API 提交开票 + const handleHeaderConfirm = async () => { if (!selectedHeaderId) { message.warning('请选择一个发票抬头'); return; } + if (!email.trim()) { + message.warning('请输入电子邮箱'); + return; + } + const emailRegex = /^[\w.\-]+@[\w.\-]+\.\w+$/; + if (!emailRegex.test(email.trim())) { + message.warning('请输入正确的邮箱格式'); + return; + } const selectedOrders = Array.from(selectedOrdersMap.values()); - const totalAmount = selectedOrders.reduce((sum, o) => sum + (o.amount || o.total_amount || 0), 0); - const now = new Date().toLocaleString('zh-CN'); const selectedHeader = allHeadersForSelect.find(h => h.id === selectedHeaderId); - const newRecord: InvoiceRecord = { - id: Date.now().toString(), - orderNo: generateRandomNo(), - orderType: selectedOrders.length > 1 ? `${selectedOrders.length}笔订单合并` : (selectedOrders[0].type || '订单'), - amount: totalAmount, - createTime: now, - status: 'pending', - headerName: selectedHeader?.name, - headerType: selectedHeader?.type, - headerTaxNo: selectedHeader?.taxNo, - selectedOrders, - }; - setRecords(prev => [newRecord, ...prev]); - setSelectHeaderModalOpen(false); - setSelectedOrdersMap(new Map()); - setSelectedHeaderId(null); - message.success(`开票申请已提交,合计金额 ¥${totalAmount.toFixed(2)}`); + const orderIds = selectedOrders.map((o: any) => o.id || o.order_no || o.orderNo); + + setSubmitting(true); + try { + await createInvoice({ + headerType: selectedHeader?.type as 'personal' | 'company', + headerName: selectedHeader?.name || '', + headerTaxNo: selectedHeader?.taxNo, + headerRegisterAddress: selectedHeader?.registerAddress, + headerRegisterPhone: selectedHeader?.registerPhone, + headerBankName: selectedHeader?.bankName, + headerBankAccount: selectedHeader?.bankAccount, + email: email.trim(), + orderIds, + }); + message.success('开票申请已提交,请等待审核'); + setSelectHeaderModalOpen(false); + setSelectedOrdersMap(new Map()); + setSelectedHeaderId(null); + setEmail(''); + // 刷新发票列表 + loadInvoices(); + } catch (err: any) { + const msg = err?.message || err?.response?.data?.detail || '提交失败,请稍后重试'; + message.error(msg); + } + setSubmitting(false); }; const orderRowSelection = { @@ -526,7 +577,9 @@ const InvoicePage: React.FC = () => { overflow: 'hidden', }} > - {records.length === 0 ? ( + {recordsLoading ? ( +
加载中...
+ ) : records.length === 0 ? ( 暂无申请开票记录} @@ -603,6 +656,18 @@ const InvoicePage: React.FC = () => { currentDetail.status === 'issued' ? '已开具' : '已驳回'} + {currentDetail.email && ( + <> + 接收邮箱: + {currentDetail.email} + + )} + {currentDetail.failureReason && ( + <> + 失败原因: + {currentDetail.failureReason} + + )} @@ -644,7 +709,7 @@ const InvoicePage: React.FC = () => { key: 'orderNo', render: (_: any, r: any) => ( - {r.order_no || r.orderNo || r.id} + {r.order_no || r.orderNo || r.orderNo || r.id} ), }, @@ -746,13 +811,13 @@ const InvoicePage: React.FC = () => { } open={selectHeaderModalOpen} - onCancel={() => { setSelectHeaderModalOpen(false); setSelectedHeaderId(null); setSelectedOrdersMap(new Map()); }} + onCancel={() => { setSelectHeaderModalOpen(false); setSelectedHeaderId(null); setSelectedOrdersMap(new Map()); setEmail(''); }} width={820} footer={[ - , - ]} @@ -761,6 +826,21 @@ const InvoicePage: React.FC = () => { closeIcon={} >
+ {/* 邮箱输入 */} +
+
+ + *接收邮箱: + + setEmail(e.target.value)} + style={{ maxWidth: 360 }} + allowClear + /> +
+