1、增加订单开发票功能和发票抬头添加功能
2、一个订单只能在一个开票里,不允许多开
This commit is contained in:
@@ -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 />} />
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
)
|
||||||
@@ -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] = []
|
||||||
@@ -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
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
headerTaxNo: selectedHeader?.taxNo,
|
||||||
headerType: selectedHeader?.type,
|
headerRegisterAddress: selectedHeader?.registerAddress,
|
||||||
headerTaxNo: selectedHeader?.taxNo,
|
headerRegisterPhone: selectedHeader?.registerPhone,
|
||||||
selectedOrders,
|
headerBankName: selectedHeader?.bankName,
|
||||||
};
|
headerBankAccount: selectedHeader?.bankAccount,
|
||||||
setRecords(prev => [newRecord, ...prev]);
|
email: email.trim(),
|
||||||
setSelectHeaderModalOpen(false);
|
orderIds,
|
||||||
setSelectedOrdersMap(new Map());
|
});
|
||||||
setSelectedHeaderId(null);
|
message.success('开票申请已提交,请等待审核');
|
||||||
message.success(`开票申请已提交,合计金额 ¥${totalAmount.toFixed(2)}`);
|
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 = {
|
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={[
|
||||||
|
|||||||
Reference in New Issue
Block a user