1、增加订单开发票功能和发票抬头添加功能

2、一个订单只能在一个开票里,不允许多开
This commit is contained in:
2026-08-10 15:39:00 +08:00
parent e77d78db1e
commit 6d30347cdb
14 changed files with 1425 additions and 35 deletions
+2
View File
@@ -42,6 +42,7 @@ import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects';
import AdminApiKeys from './pages/AdminApiKeys'; import AdminApiKeys from './pages/AdminApiKeys';
import AdminApiModelPricings from './pages/AdminApiModelPricings'; import AdminApiModelPricings from './pages/AdminApiModelPricings';
import AdminApiUsage from './pages/AdminApiUsage'; import AdminApiUsage from './pages/AdminApiUsage';
import AdminInvoices from './pages/AdminInvoices';
import { useAdminStore } from './store'; import { useAdminStore } from './store';
@@ -106,6 +107,7 @@ const App = () => {
<Route path="api-keys" element={<AdminApiKeys />} /> <Route path="api-keys" element={<AdminApiKeys />} />
<Route path="api-model-pricings" element={<AdminApiModelPricings />} /> <Route path="api-model-pricings" element={<AdminApiModelPricings />} />
<Route path="api-usage" element={<AdminApiUsage />} /> <Route path="api-usage" element={<AdminApiUsage />} />
<Route path="invoices" element={<AdminInvoices />} />
<Route path="notifications" element={<AdminNotificationManager />} /> <Route path="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} /> <Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} /> <Route path="operation-logs" element={<AdminOperationLogs />} />
+34
View File
@@ -568,6 +568,40 @@ export async function refundPaymentOrder(orderNo: string): Promise<void> {
await api.post(`/admin/payment-orders/${orderNo}/refund`); 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<any> {
return api.get(`/admin/invoices/${id}`);
}
export async function updateInvoiceStatus(id: string, data: {
status: 'success' | 'failed';
failureReason?: string;
}): Promise<void> {
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[] }> { export async function getAdminNotifications(page = 1, pageSize = 20): Promise<{ total: number; items: any[] }> {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('page', String(page)); params.set('page', String(page));
+421
View File
@@ -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<InvoiceItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [phoneFilter, setPhoneFilter] = useState<string>('');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
// 详情弹窗
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [currentDetail, setCurrentDetail] = useState<InvoiceDetail | null>(null);
// 失败原因弹窗
const [failModalOpen, setFailModalOpen] = useState(false);
const [failReason, setFailReason] = useState('');
const [failTargetId, setFailTargetId] = useState<string | null>(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<string, { color: string; label: string }> = {
processing: { color: 'blue', label: '开具中' },
success: { color: 'green', label: '已开具' },
failed: { color: 'red', label: '已失败' },
};
const c = config[status] || { color: 'default', label: status };
return <Tag color={c.color}>{c.label}</Tag>;
};
const columns = [
{
title: '发票编号',
dataIndex: 'invoiceNo',
key: 'invoiceNo',
width: 180,
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 13 }}>{v}</span>,
},
{
title: '用户',
dataIndex: 'username',
key: 'username',
width: 100,
render: (_: string, record: InvoiceItem) => (
<div>
<div><Typography.Text strong>{record.username}</Typography.Text></div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{record.phone}</div>
</div>
),
},
{
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) => <span style={{ fontWeight: 600 }}>¥{v.toFixed(2)}</span>,
},
{
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) => (
<Space size={4}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
</Button>
{record.status === 'processing' && (
<>
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleMarkSuccess(record.id)}>
</Button>
<Button type="link" size="small" danger icon={<CloseOutlined />} onClick={() => handleOpenFailModal(record.id)}>
</Button>
</>
)}
</Space>
),
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<FileTextOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple"> {total} </Tag>
</Space>
<div style={{ display: 'flex', gap: 8 }}>
<Button
type={statusFilter === null ? 'primary' : 'default'}
onClick={() => { setStatusFilter(null); setPage(1); }}
icon={<FilterOutlined />}
size="small"
>
</Button>
<Button
type={statusFilter === 'processing' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('processing'); setPage(1); }}
size="small"
>
</Button>
<Button
type={statusFilter === 'success' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('success'); setPage(1); }}
size="small"
>
</Button>
<Button
type={statusFilter === 'failed' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('failed'); setPage(1); }}
size="small"
>
</Button>
</div>
</div>
{/* 搜索栏 */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Input
placeholder="按用户手机号搜索"
value={phoneFilter}
onChange={(e) => setPhoneFilter(e.target.value)}
style={{ width: 200 }}
allowClear
onPressEnter={handleSearch}
/>
<DatePicker
placeholder="开始日期"
onChange={(d) => setStartDate(d ? d.format('YYYY-MM-DD') : '')}
/>
<DatePicker
placeholder="结束日期"
onChange={(d) => setEndDate(d ? d.format('YYYY-MM-DD') : '')}
/>
<Button type="primary" onClick={handleSearch}></Button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>...</div>
) : data.length === 0 ? (
<Empty description="暂无发票记录" style={{ padding: '40px 0' }} />
) : (
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 1200 }}
/>
)}
</Card>
{/* 详情弹窗 */}
<Modal
title={<Space><EyeOutlined /></Space>}
open={detailModalOpen}
onCancel={() => { setDetailModalOpen(false); setCurrentDetail(null); }}
footer={null}
width={700}
>
{detailLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>...</div>
) : currentDetail && (
<div style={{ padding: 8 }}>
{/* 基本信息 */}
<div style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ marginBottom: 16 }}>
{currentDetail.invoiceNo}
<span style={{ marginLeft: 12 }}>{statusTag(currentDetail.status)}</span>
</Typography.Title>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12 }}>
<Typography.Text style={{ color: '#64748b' }}>ID</Typography.Text>
<Typography.Text>{currentDetail.userId}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerType === 'company' ? '企业' : '个人'}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerName}</Typography.Text>
{currentDetail.headerTaxNo && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerTaxNo}</Typography.Text>
</>
)}
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.email}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text strong style={{ color: '#ef4444' }}>¥{currentDetail.totalAmount.toFixed(2)}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{formatDate(currentDetail.createdAt)}</Typography.Text>
{currentDetail.issuedAt && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{formatDate(currentDetail.issuedAt)}</Typography.Text>
</>
)}
{currentDetail.failureReason && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text type="danger">{currentDetail.failureReason}</Typography.Text>
</>
)}
</div>
</div>
{/* 关联订单 */}
{currentDetail.orders && currentDetail.orders.length > 0 && (
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
{currentDetail.orders.length}
</Typography.Text>
<Table
dataSource={currentDetail.orders}
columns={[
{
title: '订单号',
dataIndex: 'orderNo',
key: 'orderNo',
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>,
},
{
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"
/>
</div>
)}
</div>
)}
</Modal>
{/* 失败原因弹窗 */}
<Modal
title="开具失败"
open={failModalOpen}
onCancel={() => { setFailModalOpen(false); setFailTargetId(null); setFailReason(''); }}
onOk={handleConfirmFail}
okText="确认"
cancelText="取消"
confirmLoading={failSubmitting}
>
<Typography.Text style={{ display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Input.TextArea
value={failReason}
onChange={(e) => setFailReason(e.target.value)}
placeholder="例如:抬头信息有误,请重新提交"
rows={3}
maxLength={500}
showCount
/>
</Modal>
</div>
);
};
export default AdminInvoices;
+49
View File
@@ -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[];
}
@@ -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')
+2
View File
@@ -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 import router as private_portrait_router
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_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.upload_resource import router as upload_resource_router
from app.api.v1.invoices import router as invoices_router
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth_router) 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_router)
api_router.include_router(private_portrait_virtual_router) api_router.include_router(private_portrait_virtual_router)
api_router.include_router(upload_resource_router) api_router.include_router(upload_resource_router)
api_router.include_router(invoices_router)
+117 -5
View File
@@ -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.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.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.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 from app.utils.id_gen import generate_id
@@ -1973,11 +1974,10 @@ async def get_stats(
) )
# ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示) # ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示)
# 把 timestamptz 按东八区(业务时区)偏移后再转 DATE # created_at 为 timestamptz,数据库 session 时区已是东八区(CST
# 直接手动 +8 小时再 CAST 成日期,简单稳妥,不依赖数据库时区名配置 # 读取出来的时间值即为北京时间,直接 CAST 成日期即可,无需再 +8 小时
# 与代码中 CST = timezone(timedelta(hours=8)) 保持一致。
from sqlalchemy import Date, cast as sa_cast 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天 # 图表固定展示 [date_end - 6天, date_end] 共7天
_chart_end_dt = date_end _chart_end_dt = date_end
_chart_start_dt = datetime( _chart_start_dt = datetime(
@@ -2487,6 +2487,118 @@ async def upload_login_video(
return {"url": url} 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,
}
+110
View File
@@ -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
],
}
+2
View File
@@ -36,6 +36,7 @@ from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth_app import UserOAuthApp from app.models.user_oauth_app import UserOAuthApp
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.models.contact_request import ContactRequest 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.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
@@ -57,4 +58,5 @@ __all__ = [
"PrivatePortraitAssetGroup", "PrivatePortraitAsset", "PrivatePortraitAssetGroup", "PrivatePortraitAsset",
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink", "ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
"ApiModelPricing", "ApiModelPricing",
"Invoice", "InvoiceOrder",
] ]
+57
View File
@@ -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'),
)
+83
View File
@@ -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] = []
+292
View File
@@ -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
+37
View File
@@ -460,6 +460,43 @@ export async function cancelPaymentOrder(orderNo: string): Promise<void> {
return api.post(`/payments/orders/${orderNo}/cancel`); 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<any> {
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<any> {
return api.get(`/invoices/${id}`);
}
export async function getCreditRatios(): Promise<any[]> { export async function getCreditRatios(): Promise<any[]> {
return api.get('/credits/ratios'); return api.get('/credits/ratios');
} }
+106 -26
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Table, Tag, Empty, Spin, Pagination, Button, Typography, Modal, Form, Input, Radio, message, Space } from 'antd'; 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 { FileTextOutlined, PlusOutlined, CloseOutlined, AlipayCircleOutlined, WechatOutlined } from '@ant-design/icons';
import { getPaymentOrders } from '../api'; import { getPaymentOrders, createInvoice, getInvoices, getInvoiceDetail } from '../api';
interface InvoiceRecord { interface InvoiceRecord {
id: string; id: string;
@@ -37,6 +37,7 @@ const mockHeaders: InvoiceHeader[] = [
const InvoicePage: React.FC = () => { const InvoicePage: React.FC = () => {
const [records, setRecords] = useState<InvoiceRecord[]>([]); const [records, setRecords] = useState<InvoiceRecord[]>([]);
const [recordsLoading, setRecordsLoading] = useState(false);
const [headers, setHeaders] = useState<InvoiceHeader[]>([]); const [headers, setHeaders] = useState<InvoiceHeader[]>([]);
const [headerModalOpen, setHeaderModalOpen] = useState(false); const [headerModalOpen, setHeaderModalOpen] = useState(false);
const [addModalOpen, setAddModalOpen] = useState(false); const [addModalOpen, setAddModalOpen] = useState(false);
@@ -47,6 +48,8 @@ const InvoicePage: React.FC = () => {
const [selectedOrdersMap, setSelectedOrdersMap] = useState<Map<string, any>>(new Map()); const [selectedOrdersMap, setSelectedOrdersMap] = useState<Map<string, any>>(new Map());
const [selectHeaderModalOpen, setSelectHeaderModalOpen] = useState(false); const [selectHeaderModalOpen, setSelectHeaderModalOpen] = useState(false);
const [selectedHeaderId, setSelectedHeaderId] = useState<string | null>(null); const [selectedHeaderId, setSelectedHeaderId] = useState<string | null>(null);
const [email, setEmail] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
// 订单列表(从接口获取) // 订单列表(从接口获取)
const [orderLoading, setOrderLoading] = useState(false); const [orderLoading, setOrderLoading] = useState(false);
@@ -87,6 +90,37 @@ const InvoicePage: React.FC = () => {
setOrderLoading(false); 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(() => { useEffect(() => {
if (issueModalOpen) { if (issueModalOpen) {
loadOrderData(1); loadOrderData(1);
@@ -125,10 +159,10 @@ const InvoicePage: React.FC = () => {
}, },
{ {
title: '开具时间', title: '开具时间',
dataIndex: 'createTime', dataIndex: 'createdAt',
key: 'createTime', key: 'createdAt',
width: 180, width: 180,
render: (text: string) => <span style={{ color: '#64748b' }}>{text}</span>, render: (text: string) => text ? <span style={{ color: '#64748b' }}>{new Date(text).toLocaleString('zh-CN')}</span> : '-',
}, },
{ {
title: '发票状态', title: '发票状态',
@@ -395,33 +429,50 @@ const InvoicePage: React.FC = () => {
setSelectHeaderModalOpen(true); setSelectHeaderModalOpen(true);
}; };
// 开票流程:选择抬头后确认 → 计算总金额,添加到开票记录 // 开票流程:选择抬头后确认 → 调用后端 API 提交开票
const handleHeaderConfirm = () => { const handleHeaderConfirm = async () => {
if (!selectedHeaderId) { if (!selectedHeaderId) {
message.warning('请选择一个发票抬头'); message.warning('请选择一个发票抬头');
return; 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 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 selectedHeader = allHeadersForSelect.find(h => h.id === selectedHeaderId);
const newRecord: InvoiceRecord = { const orderIds = selectedOrders.map((o: any) => o.id || o.order_no || o.orderNo);
id: Date.now().toString(),
orderNo: generateRandomNo(), setSubmitting(true);
orderType: selectedOrders.length > 1 ? `${selectedOrders.length}笔订单合并` : (selectedOrders[0].type || '订单'), try {
amount: totalAmount, await createInvoice({
createTime: now, headerType: selectedHeader?.type as 'personal' | 'company',
status: 'pending', headerName: selectedHeader?.name || '',
headerName: selectedHeader?.name,
headerType: selectedHeader?.type,
headerTaxNo: selectedHeader?.taxNo, headerTaxNo: selectedHeader?.taxNo,
selectedOrders, headerRegisterAddress: selectedHeader?.registerAddress,
}; headerRegisterPhone: selectedHeader?.registerPhone,
setRecords(prev => [newRecord, ...prev]); headerBankName: selectedHeader?.bankName,
headerBankAccount: selectedHeader?.bankAccount,
email: email.trim(),
orderIds,
});
message.success('开票申请已提交,请等待审核');
setSelectHeaderModalOpen(false); setSelectHeaderModalOpen(false);
setSelectedOrdersMap(new Map()); setSelectedOrdersMap(new Map());
setSelectedHeaderId(null); setSelectedHeaderId(null);
message.success(`开票申请已提交,合计金额 ¥${totalAmount.toFixed(2)}`); setEmail('');
// 刷新发票列表
loadInvoices();
} catch (err: any) {
const msg = err?.message || err?.response?.data?.detail || '提交失败,请稍后重试';
message.error(msg);
}
setSubmitting(false);
}; };
const orderRowSelection = { const orderRowSelection = {
@@ -526,7 +577,9 @@ const InvoicePage: React.FC = () => {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{records.length === 0 ? ( {recordsLoading ? (
<div style={{ textAlign: 'center', padding: 60 }}>...</div>
) : records.length === 0 ? (
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<span style={{ color: '#94a3b8' }}></span>} description={<span style={{ color: '#94a3b8' }}></span>}
@@ -603,6 +656,18 @@ const InvoicePage: React.FC = () => {
currentDetail.status === 'issued' ? '已开具' : '已驳回'} currentDetail.status === 'issued' ? '已开具' : '已驳回'}
</Tag> </Tag>
</span> </span>
{currentDetail.email && (
<>
<span style={{ color: '#94a3b8' }}></span>
<span style={{ color: '#1e293b' }}>{currentDetail.email}</span>
</>
)}
{currentDetail.failureReason && (
<>
<span style={{ color: '#94a3b8' }}></span>
<span style={{ color: '#ef4444' }}>{currentDetail.failureReason}</span>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -644,7 +709,7 @@ const InvoicePage: React.FC = () => {
key: 'orderNo', key: 'orderNo',
render: (_: any, r: any) => ( render: (_: any, r: any) => (
<span style={{ fontFamily: 'monospace', fontSize: 12, color: '#64748b' }}> <span style={{ fontFamily: 'monospace', fontSize: 12, color: '#64748b' }}>
{r.order_no || r.orderNo || r.id} {r.order_no || r.orderNo || r.orderNo || r.id}
</span> </span>
), ),
}, },
@@ -746,13 +811,13 @@ const InvoicePage: React.FC = () => {
</div> </div>
} }
open={selectHeaderModalOpen} open={selectHeaderModalOpen}
onCancel={() => { setSelectHeaderModalOpen(false); setSelectedHeaderId(null); setSelectedOrdersMap(new Map()); }} onCancel={() => { setSelectHeaderModalOpen(false); setSelectedHeaderId(null); setSelectedOrdersMap(new Map()); setEmail(''); }}
width={820} width={820}
footer={[ footer={[
<Button key="cancel" onClick={() => { setSelectHeaderModalOpen(false); setSelectedHeaderId(null); setSelectedOrdersMap(new Map()); }}> <Button key="cancel" onClick={() => { setSelectHeaderModalOpen(false); setSelectedHeaderId(null); setSelectedOrdersMap(new Map()); setEmail(''); }}>
</Button>, </Button>,
<Button key="confirm" type="primary" onClick={handleHeaderConfirm}> <Button key="confirm" type="primary" loading={submitting} onClick={handleHeaderConfirm}>
</Button> </Button>
]} ]}
@@ -761,6 +826,21 @@ const InvoicePage: React.FC = () => {
closeIcon={<CloseOutlined style={{ fontSize: 16, color: '#94a3b8' }} />} closeIcon={<CloseOutlined style={{ fontSize: 16, color: '#94a3b8' }} />}
> >
<div style={{ padding: '0 24px 24px' }}> <div style={{ padding: '0 24px 24px' }}>
{/* 邮箱输入 */}
<div style={{ padding: '16px 0 12px', borderBottom: '1px solid #f1f5f9', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ color: '#94a3b8', fontSize: 13, whiteSpace: 'nowrap' }}>
<span style={{ color: '#ef4444', marginRight: 4 }}>*</span>
</span>
<Input
placeholder="发票将发送至该邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ maxWidth: 360 }}
allowClear
/>
</div>
</div>
<Table <Table
dataSource={allHeadersForSelect} dataSource={allHeadersForSelect}
columns={[ columns={[