开票历史模块

This commit is contained in:
BBIT-Kai
2026-05-12 09:33:30 +08:00
parent 4b23f3546a
commit 2401b6e512
21 changed files with 1773 additions and 178 deletions
+190
View File
@@ -335,3 +335,193 @@ export interface InvoiceRequest {
export function invoiceIssueApi(payload: InvoiceRequest): Promise<string> {
return http.post('/pt/invoiceBlue', payload)
}
// =============================================
// 开票历史
// =============================================
/** 分页结果 */
export interface PageResult<T> {
items: T[]
page: number
pageSize: number
total: number
}
/** 发票历史记录 */
export interface InvoiceHistoryItem {
id: string
/** 发票请求流水号 */
invoiceReqSerialNo: string
/** 销方税号 */
taxpayerNum: string
/** 发票种类 */
invoiceKindCode: string
/** 购买方名称 */
buyerName: string
/** 购买方税号 */
buyerTaxpayerNum?: string
/** 购买方地址 */
buyerAddress?: string
/** 购买方电话 */
buyerTel?: string
/** 购买方开户银行 */
buyerBankName?: string
/** 购买方银行账号 */
buyerBankAccount?: string
/** 不含税金额 */
amount: string
/** 税额 */
taxAmount: string
/** 含税总金额 */
totalAmount: string
/** 发票号码 */
invoiceNo?: string
/** 发票代码 */
invoiceCode?: string
/** 数电票号码 */
electronicInvoiceNo?: string
/** 开票时间 */
issuedAt?: string
/** 开票状态 */
status: string
/** PDF 地址 */
pdfUrl?: string
/** OFD 地址 */
ofdUrl?: string
/** XML 地址 */
xmlUrl?: string
/** 订单号 */
tradeNo?: string
/** 备注 */
remark?: string
/** 自定义透传数据 */
definedData?: string
/** 创建时间 */
createdAt: string
/** 错误信息 */
errorMessage?: string
}
/** 发票种类映射 */
export const invoiceKindMap: Record<string, string> = {
'81': '数电专票',
'82': '数电普票',
'87': '机动车发票',
'10': '电子普票',
'08': '电子专票',
'04': '增值税普票',
'01': '增值税专票'
}
/** 开票状态映射 */
export const invoiceStatusMap: Record<string, string> = {
'PENDING': '待处理',
'PROCESSING': '处理中',
'SUCCESS': '开票成功',
'FAILED': '开票失败'
}
/** 开票状态颜色映射 */
export const invoiceStatusColorMap: Record<string, string> = {
'PENDING': '#faad14',
'PROCESSING': '#409eff',
'SUCCESS': '#52c41a',
'FAILED': '#f56c6c'
}
/**
* 分页查询蓝票开票历史
*/
export function invoiceHistoryApi(page: number, pageSize: number): Promise<PageResult<InvoiceHistoryItem>> {
return http.get('/pt/invoiceBlueHistory', { params: { page, pageSize } })
}
// =============================================
// 发票详情
// =============================================
/** 发票商品明细(详情) */
export interface InvoiceDetailGoods {
lineNo: number
goodsName: string
taxClassificationCode: string
specificationModel?: string
meteringUnit?: string
quantity?: string
unitPrice?: string
invoiceAmount: string
taxRateValue: string
taxRateAmount?: string
includeTaxFlag: boolean
}
/** 差额征税凭证明细(详情) */
export interface InvoiceDetailVoucher {
proofType: string
electronicInvoiceNo?: string
invoiceCode?: string
invoiceNo?: string
proofNo?: string
issueDate?: string
proofAmount: string
deductionAmount: string
proofRemark?: string
source?: string
}
/** 关联单据(详情) */
export interface InvoiceDetailOrder {
orderNo: string
}
/** 发票完整详情 */
export interface InvoiceDetailResponse {
id: string
invoiceReqSerialNo: string
taxpayerNum: string
invoiceKindCode: string
invoiceType: string
buyerName: string
buyerTaxpayerNum?: string
buyerAddress?: string
buyerTel?: string
buyerBankName?: string
buyerBankAccount?: string
amount: string
taxAmount: string
totalAmount: string
invoiceNo?: string
invoiceCode?: string
electronicInvoiceNo?: string
issuedAt?: string
status: string
errorMessage?: string
pdfUrl?: string
ofdUrl?: string
xmlUrl?: string
tradeNo?: string
remark?: string
definedData?: string
createdAt: string
/** 商品明细 */
goodsList: InvoiceDetailGoods[]
/** 差额征税凭证明细 */
voucherList: InvoiceDetailVoucher[]
/** 关联单据 */
orderList: InvoiceDetailOrder[]
}
/**
* 查询发票完整详情
*/
export function invoiceDetailApi(invoiceReqSerialNo: string): Promise<InvoiceDetailResponse> {
return http.get('/pt/invoiceDetail', { params: { invoiceReqSerialNo } })
}
/**
* 查询并刷新发票状态
*/
export function queryInvoiceApi(invoiceReqSerialNo: string): Promise<{ invoiceReqSerialNo: string; status: string }> {
return http.get('/pt/queryInvoice', { params: { invoiceReqSerialNo } })
}
@@ -1,35 +1,447 @@
<template>
<div class="page">
<div class="placeholder">
<h2>开票历史</h2>
<p>功能开发中敬请期待</p>
<div class="page-header">
<h2 class="page-title">开票历史</h2>
</div>
<div class="search-bar">
<n-input
v-model:value="query.keyword"
placeholder="搜索购买方名称 / 流水号 / 发票号码"
clearable
style="width: 320px"
@keyup.enter="handleSearch"
/>
<n-button type="primary" @click="handleSearch">查询</n-button>
<n-button @click="handleReset">重置</n-button>
</div>
<n-data-table
:columns="columns"
:data="dataSource"
:loading="loading"
:pagination="pagination"
:bordered="true"
:single-line="false"
size="small"
striped
class="history-table"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
<!-- 查看详情弹窗 -->
<n-modal
v-model:show="showDetail"
preset="card"
title="发票详情"
style="width: 900px; max-width: 92vw"
:mask-closable="false"
>
<template v-if="detailItem">
<n-spin :show="detailLoading">
<!-- ===== 基本信息 ===== -->
<n-descriptions :column="2" bordered size="small" label-placement="left">
<n-descriptions-item label="发票请求流水号" span="2">
{{ detailItem.invoiceReqSerialNo }}
</n-descriptions-item>
<n-descriptions-item label="销方税号">{{ detailItem.taxpayerNum }}</n-descriptions-item>
<n-descriptions-item label="发票种类">{{ invoiceKindMap[detailItem.invoiceKindCode] || detailItem.invoiceKindCode }}</n-descriptions-item>
<n-descriptions-item label="购买方名称">{{ detailItem.buyerName }}</n-descriptions-item>
<n-descriptions-item label="购买方税号">{{ detailItem.buyerTaxpayerNum || '-' }}</n-descriptions-item>
<n-descriptions-item label="购买方地址">{{ detailItem.buyerAddress || '-' }}</n-descriptions-item>
<n-descriptions-item label="购买方电话">{{ detailItem.buyerTel || '-' }}</n-descriptions-item>
<n-descriptions-item label="购买方开户银行">{{ detailItem.buyerBankName || '-' }}</n-descriptions-item>
<n-descriptions-item label="购买方银行账号">{{ detailItem.buyerBankAccount || '-' }}</n-descriptions-item>
<n-descriptions-item label="不含税金额">{{ detailItem.amount }}</n-descriptions-item>
<n-descriptions-item label="税额">{{ detailItem.taxAmount }}</n-descriptions-item>
<n-descriptions-item label="含税总金额">
<span style="font-weight: 600; color: #d4380d">{{ detailItem.totalAmount }}</span>
</n-descriptions-item>
<n-descriptions-item label="发票号码">{{ detailItem.invoiceNo || '-' }}</n-descriptions-item>
<n-descriptions-item label="发票代码">{{ detailItem.invoiceCode || '-' }}</n-descriptions-item>
<n-descriptions-item label="数电票号码">{{ detailItem.electronicInvoiceNo || '-' }}</n-descriptions-item>
<n-descriptions-item label="开票时间">{{ detailItem.issuedAt || '-' }}</n-descriptions-item>
<n-descriptions-item label="状态">
<n-tag :type="statusTagType(detailItem.status)" size="small">
{{ invoiceStatusMap[detailItem.status] || detailItem.status }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="订单号">{{ detailItem.tradeNo || '-' }}</n-descriptions-item>
<n-descriptions-item label="备注" span="2">{{ detailItem.remark || '-' }}</n-descriptions-item>
<n-descriptions-item v-if="detailItem.errorMessage" label="错误信息" span="2">
<span style="color: #f56c6c">{{ detailItem.errorMessage }}</span>
</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ detailItem.createdAt }}</n-descriptions-item>
</n-descriptions>
<!-- 文件下载 -->
<div v-if="detailItem.pdfUrl || detailItem.ofdUrl || detailItem.xmlUrl" class="file-links">
<span class="file-links-label">文件下载</span>
<n-button v-if="detailItem.pdfUrl" text tag="a" :href="detailItem.pdfUrl" target="_blank" type="primary">PDF</n-button>
<n-button v-if="detailItem.ofdUrl" text tag="a" :href="detailItem.ofdUrl" target="_blank" type="primary">OFD</n-button>
<n-button v-if="detailItem.xmlUrl" text tag="a" :href="detailItem.xmlUrl" target="_blank" type="primary">XML</n-button>
</div>
<!-- ===== 商品明细 ===== -->
<div v-if="detailItem.goodsList.length > 0" class="detail-section">
<div class="detail-section-title">商品明细</div>
<n-data-table
:data="detailItem.goodsList"
:columns="goodsColumns"
:bordered="true"
:single-line="false"
size="small"
striped
:pagination="false"
/>
</div>
<!-- ===== 差额征税凭证明细 ===== -->
<div v-if="detailItem.voucherList.length > 0" class="detail-section">
<div class="detail-section-title">差额征税凭证明细</div>
<n-data-table
:data="detailItem.voucherList"
:columns="voucherColumns"
:bordered="true"
:single-line="false"
size="small"
striped
:pagination="false"
/>
</div>
<!-- ===== 关联单据 ===== -->
<div v-if="detailItem.orderList.length > 0" class="detail-section">
<div class="detail-section-title">关联单据</div>
<n-tag v-for="(ord, idx) in detailItem.orderList" :key="idx" style="margin-right: 8px; margin-bottom: 4px">
{{ ord.orderNo }}
</n-tag>
</div>
</n-spin>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { h, onMounted, reactive, ref } from 'vue'
import {
NButton,
NDataTable,
NDescriptions,
NDescriptionsItem,
NInput,
NModal,
NSpin,
NTag,
useMessage
} from 'naive-ui'
import { Eye, RefreshCw } from 'lucide-vue-next'
import { invoiceDetailApi, invoiceHistoryApi, queryInvoiceApi } from '@/api/piaotong'
import type { InvoiceDetailGoods, InvoiceDetailOrder, InvoiceDetailResponse, InvoiceDetailVoucher, InvoiceHistoryItem } from '@/api/piaotong'
import type { DataTableColumn } from 'naive-ui'
const invoiceKindMap: Record<string, string> = {
'81': '数电专票',
'82': '数电普票',
'87': '机动车发票',
'10': '电子普票',
'08': '电子专票',
'04': '增值税普票',
'01': '增值税专票'
}
const invoiceStatusMap: Record<string, string> = {
'PENDING': '待处理',
'PROCESSING': '处理中',
'SUCCESS': '开票成功',
'FAILED': '开票失败'
}
function statusTagType(status: string): 'warning' | 'info' | 'success' | 'error' {
switch (status) {
case 'PENDING': return 'warning'
case 'PROCESSING': return 'info'
case 'SUCCESS': return 'success'
case 'FAILED': return 'error'
default: return 'info'
}
}
const message = useMessage()
const loading = ref(false)
const dataSource = ref<InvoiceHistoryItem[]>([])
const showDetail = ref(false)
const detailLoading = ref(false)
const detailItem = ref<InvoiceDetailResponse | null>(null)
/** 记录正在刷新状态的流水号 */
const refreshingSet = reactive(new Set<string>())
const query = reactive({
keyword: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
pageCount: 1,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
pageSlot: 7,
prefix: ({ itemCount }: { itemCount: number }) => `${itemCount}`
})
/** 商品明细表格列 */
const goodsColumns: DataTableColumn[] = [
{ title: '行号', key: 'lineNo', width: 60, align: 'center' },
{ title: '商品名称', key: 'goodsName', width: 160, ellipsis: { tooltip: true } },
{ title: '税收分类编码', key: 'taxClassificationCode', width: 130, ellipsis: { tooltip: true } },
{ title: '规格型号', key: 'specificationModel', width: 100, render: (r: InvoiceDetailGoods) => r.specificationModel || '-' },
{ title: '单位', key: 'meteringUnit', width: 60, render: (r: InvoiceDetailGoods) => r.meteringUnit || '-' },
{ title: '数量', key: 'quantity', width: 80, render: (r: InvoiceDetailGoods) => r.quantity || '-' },
{ title: '单价', key: 'unitPrice', width: 100, render: (r: InvoiceDetailGoods) => r.unitPrice || '-' },
{ title: '金额', key: 'invoiceAmount', width: 100, align: 'right' },
{ title: '税率', key: 'taxRateValue', width: 70, render: (r: InvoiceDetailGoods) => `${(parseFloat(r.taxRateValue) * 100).toFixed(0)}%` },
{ title: '税额', key: 'taxRateAmount', width: 100, align: 'right', render: (r: InvoiceDetailGoods) => r.taxRateAmount || '-' },
{ title: '含税', key: 'includeTaxFlag', width: 60, align: 'center', render: (r: InvoiceDetailGoods) => r.includeTaxFlag ? '是' : '否' },
]
/** 差额征税凭证表格列 */
const voucherColumns: DataTableColumn[] = [
{ title: '凭证类型', key: 'proofType', width: 120, render: (r: InvoiceDetailVoucher) => proofTypeMap[r.proofType] || r.proofType },
{ title: '凭证号码', key: 'proofNo', width: 130, render: (r: InvoiceDetailVoucher) => r.proofNo || '-' },
{ title: '开具日期', key: 'issueDate', width: 100, render: (r: InvoiceDetailVoucher) => r.issueDate || '-' },
{ title: '凭证金额', key: 'proofAmount', width: 110, align: 'right' },
{ title: '扣除金额', key: 'deductionAmount', width: 110, align: 'right' },
{ title: '来源', key: 'source', width: 90, render: (r: InvoiceDetailVoucher) => r.source || '-' },
]
const proofTypeMap: Record<string, string> = {
'01': '数电票', '02': '增值税专票', '03': '增值税普票', '04': '营业税发票',
'05': '财政票据', '06': '法院裁决书', '07': '契税完税凭证', '08': '其他发票类', '09': '其他扣除凭证'
}
const columns: DataTableColumn[] = [
{
title: '流水号',
key: 'invoiceReqSerialNo',
width: 180,
ellipsis: { tooltip: true }
},
{
title: '购买方',
key: 'buyerName',
width: 140,
ellipsis: { tooltip: true }
},
{
title: '发票种类',
key: 'invoiceKindCode',
width: 100,
render: (row: InvoiceHistoryItem) => invoiceKindMap[row.invoiceKindCode] || row.invoiceKindCode
},
{
title: '不含税金额',
key: 'amount',
width: 120,
align: 'right'
},
{
title: '税额',
key: 'taxAmount',
width: 110,
align: 'right'
},
{
title: '含税总金额',
key: 'totalAmount',
width: 130,
align: 'right',
sorter: (a: InvoiceHistoryItem, b: InvoiceHistoryItem) =>
parseFloat(a.totalAmount) - parseFloat(b.totalAmount),
render: (row: InvoiceHistoryItem) =>
h('span', { style: { fontWeight: 600, color: '#d4380d' } }, row.totalAmount)
},
{
title: '发票号码',
key: 'invoiceNo',
width: 120,
ellipsis: { tooltip: true },
render: (row: InvoiceHistoryItem) => row.invoiceNo || '-'
},
{
title: '状态',
key: 'status',
width: 100,
render: (row: InvoiceHistoryItem) =>
h(NTag, { type: statusTagType(row.status), size: 'small' }, () =>
invoiceStatusMap[row.status] || row.status
)
},
{
title: '开票时间',
key: 'issuedAt',
width: 140,
render: (row: InvoiceHistoryItem) => row.issuedAt || '-'
},
{
title: '创建时间',
key: 'createdAt',
width: 140
},
{
title: '操作',
key: 'actions',
width: 200,
fixed: 'right',
render: (row: InvoiceHistoryItem) =>
h('div', { style: 'display: flex; gap: 8px; align-items: center;' }, [
h(
NButton,
{ size: 'small', type: 'primary', tertiary: true, onClick: () => showDetailInfo(row) },
{ default: () => '查看详情' }
),
h(
NButton,
{
size: 'small',
type: 'warning',
secondary: true,
disabled: refreshingSet.has(row.invoiceReqSerialNo),
onClick: () => refreshStatus(row)
},
{ default: () => refreshingSet.has(row.invoiceReqSerialNo) ? '刷新中...' : '刷新状态', icon: () => h(RefreshCw, { size: 14 }) }
)
])
}
]
async function fetchData() {
loading.value = true
try {
const res = await invoiceHistoryApi(pagination.page, pagination.pageSize)
dataSource.value = res.items
pagination.itemCount = res.total
pagination.pageCount = Math.max(1, Math.ceil(res.total / pagination.pageSize))
} catch {
message.error('查询开票历史失败')
} finally {
loading.value = false
}
}
function handlePageChange(page: number) {
pagination.page = page
fetchData()
}
function handlePageSizeChange(pageSize: number) {
pagination.pageSize = pageSize
pagination.page = 1
fetchData()
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
query.keyword = ''
pagination.page = 1
fetchData()
}
async function showDetailInfo(item: InvoiceHistoryItem) {
showDetail.value = true
detailLoading.value = true
try {
const res = await invoiceDetailApi(item.invoiceReqSerialNo)
detailItem.value = res
} catch {
detailItem.value = null
useMessage().error('查询发票详情失败')
} finally {
detailLoading.value = false
}
}
async function refreshStatus(item: InvoiceHistoryItem) {
refreshingSet.add(item.invoiceReqSerialNo)
try {
const res = await queryInvoiceApi(item.invoiceReqSerialNo)
message.success(`状态已刷新: ${invoiceStatusMap[res.status] || res.status}`)
// 重新加载列表数据更新此行状态
await fetchData()
} catch {
message.error('刷新状态失败')
} finally {
refreshingSet.delete(item.invoiceReqSerialNo)
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.page {
min-height: 100%;
background: #f7f8fa;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 16px;
}
.placeholder {
text-align: center;
color: #bbb;
.page-header {
flex-shrink: 0;
}
.placeholder h2 {
margin: 0 0 8px;
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #999;
color: #333;
}
.placeholder p {
margin: 0;
.search-bar {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.history-table {
flex: 1;
overflow: auto;
}
.file-links {
margin-top: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.file-links-label {
font-size: 13px;
color: #666;
flex-shrink: 0;
}
.detail-section {
margin-top: 16px;
}
.detail-section-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
padding-left: 8px;
border-left: 3px solid #409eff;
}
</style>
@@ -448,13 +448,13 @@
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="金额 *" path="invoiceAmount">
<n-input v-model:value="currentItem.invoiceAmount" :placeholder="amountPlaceholder" clearable />
<n-form-item label="金额是否含税 *" path="includeTaxFlag">
<n-select v-model:value="currentItem.includeTaxFlag" :options="includeTaxFlagOptions" placeholder="选择含税标示" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="含税标示 *" path="includeTaxFlag">
<n-select v-model:value="currentItem.includeTaxFlag" :options="includeTaxFlagOptions" placeholder="选择含税标示" />
<n-form-item label="金额 *" path="invoiceAmount">
<n-input v-model:value="currentItem.invoiceAmount" :placeholder="amountPlaceholder" clearable />
</n-form-item>
</n-gi>
<n-gi>
@@ -538,18 +538,18 @@
</n-form>
</section>
</main>
</div>
<!-- ===== 新增单据号弹窗 ===== -->
<n-modal v-model:show="showOrderDialog" preset="card" title="新增单据号" style="width:420px" :mask-closable="false">
<n-input v-model:value="orderInputValue" placeholder="请输入业务单据号" clearable @keyup.enter="confirmAddOrderNo" />
<template #footer>
<div style="display:flex;justify-content:flex-end;gap:8px">
<n-button @click="showOrderDialog = false">取消</n-button>
<n-button type="primary" @click="confirmAddOrderNo">确定</n-button>
</div>
</template>
</n-modal>
<!-- ===== 新增单据号弹窗 ===== -->
<n-modal v-model:show="showOrderDialog" preset="card" title="新增单据号" style="width:420px" :mask-closable="false">
<n-input v-model:value="orderInputValue" placeholder="请输入业务单据号" clearable @keyup.enter="confirmAddOrderNo" />
<template #footer>
<div style="display:flex;justify-content:flex-end;gap:8px">
<n-button @click="showOrderDialog = false">取消</n-button>
<n-button type="primary" @click="confirmAddOrderNo">确定</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
@@ -557,7 +557,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Plus, RefreshCw } from 'lucide-vue-next'
import {
NButton,
NCard,
NDatePicker,
NForm,
NFormItem,
@@ -1111,19 +1110,24 @@ watch(() => form.specialInvoiceKind, (newVal, oldVal) => {
if (!newVal || newVal === oldVal) return
if (newVal === '02') {
dialog.warning({
title: '提示',
content: '农产品收购发票的开票种类只能是数电普票(82),是否将开票种类修改为"数电普票(82"',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
form.invoiceIssueKindCode = '82'
showSwapSellerBuyerDialog()
},
onNegativeClick: () => {
form.specialInvoiceKind = oldVal
}
})
if (form.invoiceIssueKindCode === '82') {
// 已经是数电普票,直接弹交换信息弹窗
showSwapSellerBuyerDialog()
} else {
dialog.warning({
title: '提示',
content: '农产品收购发票的开票种类只能是数电普票(82),是否将开票种类修改为"数电普票(82"',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
form.invoiceIssueKindCode = '82'
showSwapSellerBuyerDialog()
},
onNegativeClick: () => {
form.specialInvoiceKind = oldVal
}
})
}
} else if (newVal === '12') {
showSwapSellerBuyerDialog()
}
@@ -1237,7 +1241,7 @@ watch(() => form.itemList, (items) => {
flex-shrink: 0;
background: #fff;
border-bottom: 1px solid #e8e8e8;
padding: 12px 24px;
padding: 6px 24px;
display: flex;
justify-content: flex-end;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);