开票历史模块

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
@@ -1,7 +1,10 @@
package com.bbit.ticket.bootstrap package com.bbit.ticket.bootstrap
import com.bbit.ticket.database.piaotong.HistoryInvoiceBasicTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceGoodsTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceOrderTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceVoucherTable
import com.bbit.ticket.database.piaotong.InvoiceItemTable import com.bbit.ticket.database.piaotong.InvoiceItemTable
import com.bbit.ticket.database.piaotong.InvoiceOrderTable
import com.bbit.ticket.database.system.SysApiAccessLogTable import com.bbit.ticket.database.system.SysApiAccessLogTable
import com.bbit.ticket.database.system.SysDictItemTable import com.bbit.ticket.database.system.SysDictItemTable
import com.bbit.ticket.database.system.SysDictTypeTable import com.bbit.ticket.database.system.SysDictTypeTable
@@ -33,7 +36,10 @@ object DatabaseInitializer {
SysOperationLogTable, SysOperationLogTable,
SysApiAccessLogTable, SysApiAccessLogTable,
InvoiceItemTable, InvoiceItemTable,
InvoiceOrderTable, HistoryInvoiceBasicTable,
HistoryInvoiceGoodsTable,
HistoryInvoiceVoucherTable,
HistoryInvoiceOrderTable,
) )
// 先通过 Exposed 生成迁移 SQL,再逐条执行,避免启动时静默跳过缺失表或字段。 // 先通过 Exposed 生成迁移 SQL,再逐条执行,避免启动时静默跳过缺失表或字段。
transaction { transaction {
@@ -2,31 +2,43 @@
package com.bbit.ticket.dao.piaotong package com.bbit.ticket.dao.piaotong
import com.bbit.ticket.database.piaotong.HistoryInvoiceBasicTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceGoodsTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceOrderTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceVoucherTable
import com.bbit.ticket.database.piaotong.InvoiceItemTable import com.bbit.ticket.database.piaotong.InvoiceItemTable
import com.bbit.ticket.database.piaotong.InvoiceOrderTable
import com.bbit.ticket.database.system.SysUserTable import com.bbit.ticket.database.system.SysUserTable
import com.bbit.ticket.entity.request.InvoiceRequest import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.request.AskInvoiceRequest
import com.bbit.ticket.entity.request.TaxRegisterInfo import com.bbit.ticket.entity.request.TaxRegisterInfo
import com.bbit.ticket.entity.request.UpdateDigitalAccountRequest import com.bbit.ticket.entity.request.UpdateDigitalAccountRequest
import com.bbit.ticket.entity.request.UpdateEnterpriseInfoRequest import com.bbit.ticket.entity.request.UpdateEnterpriseInfoRequest
import com.bbit.ticket.entity.request.UpdatePresetDataRequest import com.bbit.ticket.entity.request.UpdatePresetDataRequest
import com.bbit.ticket.entity.response.DigitalAccountResponse import com.bbit.ticket.entity.response.DigitalAccountResponse
import com.bbit.ticket.entity.response.EnterpriseInfoResponse import com.bbit.ticket.entity.response.EnterpriseInfoResponse
import com.bbit.ticket.entity.response.InvoiceDetailGoods
import com.bbit.ticket.entity.response.InvoiceDetailOrder
import com.bbit.ticket.entity.response.InvoiceDetailResponse
import com.bbit.ticket.entity.response.InvoiceDetailVoucher
import com.bbit.ticket.entity.response.InvoiceHistoryItem
import com.bbit.ticket.entity.response.PresetDataResponse import com.bbit.ticket.entity.response.PresetDataResponse
import kotlinx.serialization.encodeToString import com.bbit.ticket.utils.formatDateTime
import kotlinx.serialization.json.Json import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertAndGetId
import org.jetbrains.exposed.v1.jdbc.insertReturning
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.jdbc.update
import java.math.BigDecimal import java.math.BigDecimal
import java.text.DecimalFormat import java.time.LocalDateTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
private fun pageOffset(page: Int, pageSize: Int): Long = ((page - 1) * pageSize).toLong()
object EnterpriseTaxDao { object EnterpriseTaxDao {
// ============================================= // =============================================
@@ -129,36 +141,119 @@ object EnterpriseTaxDao {
} }
} }
fun addInvoice(userId: Uuid, req: InvoiceRequest) { // =============================================
val now = OffsetDateTime.now() // 开票历史查询
val row = InvoiceOrderTable.insert { // =============================================
it[InvoiceOrderTable.userId] = userId
it[InvoiceOrderTable.invoiceReqSerialNo] = req.invoiceReqSerialNo /**
it[InvoiceOrderTable.taxpayerNum] = req.taxpayerNum * 分页查询开票历史
it[InvoiceOrderTable.invoiceKindCode] = req.invoiceIssueKindCode */
it[InvoiceOrderTable.buyerName] = req.buyerName fun invoiceHistory(userId: Uuid, page: Int, pageSize: Int): PageResult<InvoiceHistoryItem> {
it[InvoiceOrderTable.buyerTaxpayerNum] = req.buyerTaxpayerNum val where = HistoryInvoiceBasicTable.userId eq userId
it[InvoiceOrderTable.buyerAddress] = req.buyerAddress val total = HistoryInvoiceBasicTable.selectAll().where { where }.count()
it[InvoiceOrderTable.buyerTel] = req.buyerTel val rows = HistoryInvoiceBasicTable.selectAll().where { where }
it[InvoiceOrderTable.buyerBankName] = req.buyerBankName .orderBy(HistoryInvoiceBasicTable.createdAt, SortOrder.DESC)
it[InvoiceOrderTable.buyerBankAccount] = req.buyerBankAccount .limit(pageSize)
it[InvoiceOrderTable.remark] = req.remark .offset(pageOffset(page, pageSize))
it[InvoiceOrderTable.definedData] = req.definedData .toList()
it[InvoiceOrderTable.tradeNo] = req.tradeNo return PageResult(
it[InvoiceOrderTable.taxAmount] = BigDecimal.ZERO items = rows.map { row ->
it[InvoiceOrderTable.amount] = BigDecimal.ZERO InvoiceHistoryItem(
it[InvoiceOrderTable.totalAmount] = BigDecimal.ZERO id = row[HistoryInvoiceBasicTable.id],
it[InvoiceOrderTable.requestJson] = Json.encodeToString(req) invoiceReqSerialNo = row[HistoryInvoiceBasicTable.invoiceReqSerialNo],
it[InvoiceOrderTable.status] = "PENDING" taxpayerNum = row[HistoryInvoiceBasicTable.taxpayerNum],
it[InvoiceOrderTable.createdAt] = now invoiceKindCode = row[HistoryInvoiceBasicTable.invoiceKindCode],
it[InvoiceOrderTable.createdBy] = userId buyerName = row[HistoryInvoiceBasicTable.buyerName],
buyerTaxpayerNum = row[HistoryInvoiceBasicTable.buyerTaxpayerNum],
buyerAddress = row[HistoryInvoiceBasicTable.buyerAddress],
buyerTel = row[HistoryInvoiceBasicTable.buyerTel],
buyerBankName = row[HistoryInvoiceBasicTable.buyerBankName],
buyerBankAccount = row[HistoryInvoiceBasicTable.buyerBankAccount],
amount = row[HistoryInvoiceBasicTable.amount].toPlainString(),
taxAmount = row[HistoryInvoiceBasicTable.taxAmount].toPlainString(),
totalAmount = row[HistoryInvoiceBasicTable.totalAmount].toPlainString(),
invoiceNo = row[HistoryInvoiceBasicTable.invoiceNo],
invoiceCode = row[HistoryInvoiceBasicTable.invoiceCode],
electronicInvoiceNo = row[HistoryInvoiceBasicTable.electronicInvoiceNo],
issuedAt = formatDateTime(row[HistoryInvoiceBasicTable.issuedAt]),
status = row[HistoryInvoiceBasicTable.status],
pdfUrl = row[HistoryInvoiceBasicTable.pdfUrl],
ofdUrl = row[HistoryInvoiceBasicTable.ofdUrl],
xmlUrl = row[HistoryInvoiceBasicTable.xmlUrl],
tradeNo = row[HistoryInvoiceBasicTable.tradeNo],
remark = row[HistoryInvoiceBasicTable.remark],
definedData = row[HistoryInvoiceBasicTable.definedData],
createdAt = formatDateTime(row[HistoryInvoiceBasicTable.createdAt]) ?: "",
errorMessage = row[HistoryInvoiceBasicTable.errorMessage],
)
},
page = page,
pageSize = pageSize,
total = total,
)
} }
val invoiceId = row[InvoiceOrderTable.id]
fun addInvoice(userId: Uuid, req: AskInvoiceRequest) {
val now = OffsetDateTime.now()
// 1. 插入 HistoryInvoiceBasicTable(基本信息历史快照)
val basicRow = HistoryInvoiceBasicTable.insert {
it[HistoryInvoiceBasicTable.userId] = userId
it[HistoryInvoiceBasicTable.invoiceReqSerialNo] = req.invoiceReqSerialNo
it[HistoryInvoiceBasicTable.taxpayerNum] = req.taxpayerNum
it[HistoryInvoiceBasicTable.invoiceKindCode] = req.invoiceIssueKindCode
it[HistoryInvoiceBasicTable.invoiceType] = "BLUE"
it[HistoryInvoiceBasicTable.buyerName] = req.buyerName
it[HistoryInvoiceBasicTable.buyerTaxpayerNum] = req.buyerTaxpayerNum
it[HistoryInvoiceBasicTable.buyerAddress] = req.buyerAddress
it[HistoryInvoiceBasicTable.buyerTel] = req.buyerTel
it[HistoryInvoiceBasicTable.buyerBankName] = req.buyerBankName
it[HistoryInvoiceBasicTable.buyerBankAccount] = req.buyerBankAccount
it[HistoryInvoiceBasicTable.remark] = req.remark
it[HistoryInvoiceBasicTable.definedData] = req.definedData
it[HistoryInvoiceBasicTable.tradeNo] = req.tradeNo
it[HistoryInvoiceBasicTable.amount] = req.itemList.sumOf { it.invoiceAmount.toBigDecimal() }
it[HistoryInvoiceBasicTable.taxAmount] = req.itemList.sumOf { it.taxRateAmount?.toBigDecimalOrNull() ?: BigDecimal.ZERO }
it[HistoryInvoiceBasicTable.totalAmount] = req.itemList.sumOf { item ->
val amount = item.invoiceAmount.toBigDecimal()
val tax = item.taxRateAmount?.toBigDecimalOrNull() ?: BigDecimal.ZERO
amount + tax
}
it[HistoryInvoiceBasicTable.status] = "PENDING"
it[HistoryInvoiceBasicTable.createdAt] = now
it[HistoryInvoiceBasicTable.createdBy] = userId
}
val historyBasicId = basicRow[HistoryInvoiceBasicTable.id]
// 2. 插入商品明细(HistoryInvoiceGoodsTable + InvoiceItemTable
var lineNo = 1 var lineNo = 1
for (item in req.itemList) { for (item in req.itemList) {
// 历史商品明细
HistoryInvoiceGoodsTable.insert {
it[HistoryInvoiceGoodsTable.basicId] = historyBasicId
it[HistoryInvoiceGoodsTable.lineNo] = lineNo
it[HistoryInvoiceGoodsTable.goodsName] = item.goodsName
it[HistoryInvoiceGoodsTable.taxClassificationCode] = item.taxClassificationCode
it[HistoryInvoiceGoodsTable.specificationModel] = item.specificationModel
it[HistoryInvoiceGoodsTable.meteringUnit] = item.meteringUnit
it[HistoryInvoiceGoodsTable.quantity] = item.quantity?.toBigDecimalOrNull()
it[HistoryInvoiceGoodsTable.unitPrice] = item.unitPrice?.toBigDecimalOrNull()
it[HistoryInvoiceGoodsTable.invoiceAmount] = item.invoiceAmount.toBigDecimal()
it[HistoryInvoiceGoodsTable.taxRateValue] = item.taxRateValue.toBigDecimal()
it[HistoryInvoiceGoodsTable.taxRateAmount] = item.taxRateAmount?.toBigDecimalOrNull()
it[HistoryInvoiceGoodsTable.includeTaxFlag] = item.includeTaxFlag == "1"
it[HistoryInvoiceGoodsTable.discountAmount] = item.discountAmount?.toBigDecimalOrNull()
it[HistoryInvoiceGoodsTable.zeroTaxFlag] = item.zeroTaxFlag
it[HistoryInvoiceGoodsTable.preferentialPolicyFlag] = item.preferentialPolicyFlag
it[HistoryInvoiceGoodsTable.vatSpecialManage] = item.vatSpecialManage
it[HistoryInvoiceGoodsTable.deductionAmount] = item.deductionAmount?.toBigDecimalOrNull()
it[HistoryInvoiceGoodsTable.createdAt] = now
}
// 当前商品明细
InvoiceItemTable.insert { InvoiceItemTable.insert {
it[InvoiceItemTable.invoiceId] = invoiceId it[InvoiceItemTable.invoiceId] = historyBasicId
it[InvoiceItemTable.lineNo] = lineNo++ it[InvoiceItemTable.lineNo] = lineNo
it[InvoiceItemTable.goodsName] = item.goodsName it[InvoiceItemTable.goodsName] = item.goodsName
it[InvoiceItemTable.taxClassificationCode] = item.taxClassificationCode it[InvoiceItemTable.taxClassificationCode] = item.taxClassificationCode
it[InvoiceItemTable.specificationModel] = item.specificationModel it[InvoiceItemTable.specificationModel] = item.specificationModel
@@ -176,6 +271,167 @@ object EnterpriseTaxDao {
it[InvoiceItemTable.deductionAmount] = item.deductionAmount?.toBigDecimalOrNull() it[InvoiceItemTable.deductionAmount] = item.deductionAmount?.toBigDecimalOrNull()
it[InvoiceItemTable.createdAt] = now it[InvoiceItemTable.createdAt] = now
} }
lineNo++
} }
// 3. 插入差额征税凭证明细(HistoryInvoiceVoucherTable
if (!req.variableLevyProofList.isNullOrEmpty()) {
for (proof in req.variableLevyProofList) {
HistoryInvoiceVoucherTable.insert {
it[HistoryInvoiceVoucherTable.basicId] = historyBasicId
it[HistoryInvoiceVoucherTable.proofType] = proof.proofType
it[HistoryInvoiceVoucherTable.electronicInvoiceNo] = proof.electronicInvoiceNo
it[HistoryInvoiceVoucherTable.invoiceCode] = proof.invoiceCode
it[HistoryInvoiceVoucherTable.invoiceNo] = proof.invoiceNo
it[HistoryInvoiceVoucherTable.proofNo] = proof.proofNo
it[HistoryInvoiceVoucherTable.issueDate] = proof.issueDate
it[HistoryInvoiceVoucherTable.proofAmount] = proof.proofAmount.toBigDecimal()
it[HistoryInvoiceVoucherTable.deductionAmount] = proof.deductionAmount.toBigDecimal()
it[HistoryInvoiceVoucherTable.proofRemark] = proof.proofRemark
it[HistoryInvoiceVoucherTable.his_source] = proof.source
it[HistoryInvoiceVoucherTable.createdAt] = now
}
}
}
// 4. 插入关联单据(HistoryInvoiceOrderTable
if (!req.orderList.isNullOrEmpty()) {
for (order in req.orderList) {
HistoryInvoiceOrderTable.insert {
it[HistoryInvoiceOrderTable.basicId] = historyBasicId
it[HistoryInvoiceOrderTable.orderNo] = order.orderNo
it[HistoryInvoiceOrderTable.createdAt] = now
}
}
}
}
/**
* 根据票通查询结果更新发票状态
*/
fun updateInvoiceStatus(
invoiceReqSerialNo: String,
code: String,
msg: String,
invoiceNo: String?,
invoiceCode: String?,
electronicInvoiceNo: String?,
invoiceDate: String?,
tradeNo: String?,
) {
HistoryInvoiceBasicTable.update({
HistoryInvoiceBasicTable.invoiceReqSerialNo eq invoiceReqSerialNo
}) {
val status = when (code) {
"0000" -> "SUCCESS"
"7777" -> "PROCESSING"
"9999" -> "FAILED"
"6666" -> "PENDING"
else -> "PROCESSING"
}
it[HistoryInvoiceBasicTable.status] = status
// 失败时将 msg 写入错误信息,成功时清空
it[HistoryInvoiceBasicTable.errorMessage] = if (status == "FAILED") msg else null
if (!invoiceNo.isNullOrBlank()) it[HistoryInvoiceBasicTable.invoiceNo] = invoiceNo
if (!invoiceCode.isNullOrBlank()) it[HistoryInvoiceBasicTable.invoiceCode] = invoiceCode
if (!electronicInvoiceNo.isNullOrBlank()) it[HistoryInvoiceBasicTable.electronicInvoiceNo] = electronicInvoiceNo
if (!invoiceDate.isNullOrBlank()) {
val localDt = LocalDateTime.parse(invoiceDate, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
it[HistoryInvoiceBasicTable.issuedAt] = localDt.atZone(ZoneId.systemDefault()).toOffsetDateTime()
}
if (!tradeNo.isNullOrBlank()) it[HistoryInvoiceBasicTable.tradeNo] = tradeNo
it[HistoryInvoiceBasicTable.updatedAt] = OffsetDateTime.now()
}
}
/**
* 查询发票完整详情(含商品明细、差额征税凭证、关联单据)
*/
fun invoiceDetail(userId: Uuid, invoiceReqSerialNo: String): InvoiceDetailResponse? {
val basicRow = HistoryInvoiceBasicTable.selectAll().where {
(HistoryInvoiceBasicTable.userId eq userId) and
(HistoryInvoiceBasicTable.invoiceReqSerialNo eq invoiceReqSerialNo)
}.singleOrNull() ?: return null
val basicId = basicRow[HistoryInvoiceBasicTable.id]
// 查询商品明细
val goodsRows = HistoryInvoiceGoodsTable.selectAll().where {
HistoryInvoiceGoodsTable.basicId eq basicId
}.orderBy(HistoryInvoiceGoodsTable.lineNo).toList()
// 查询差额征税凭证
val voucherRows = HistoryInvoiceVoucherTable.selectAll().where {
HistoryInvoiceVoucherTable.basicId eq basicId
}.toList()
// 查询关联单据
val orderRows = HistoryInvoiceOrderTable.selectAll().where {
HistoryInvoiceOrderTable.basicId eq basicId
}.toList()
return InvoiceDetailResponse(
id = basicRow[HistoryInvoiceBasicTable.id],
invoiceReqSerialNo = basicRow[HistoryInvoiceBasicTable.invoiceReqSerialNo],
taxpayerNum = basicRow[HistoryInvoiceBasicTable.taxpayerNum],
invoiceKindCode = basicRow[HistoryInvoiceBasicTable.invoiceKindCode],
invoiceType = basicRow[HistoryInvoiceBasicTable.invoiceType],
buyerName = basicRow[HistoryInvoiceBasicTable.buyerName],
buyerTaxpayerNum = basicRow[HistoryInvoiceBasicTable.buyerTaxpayerNum],
buyerAddress = basicRow[HistoryInvoiceBasicTable.buyerAddress],
buyerTel = basicRow[HistoryInvoiceBasicTable.buyerTel],
buyerBankName = basicRow[HistoryInvoiceBasicTable.buyerBankName],
buyerBankAccount = basicRow[HistoryInvoiceBasicTable.buyerBankAccount],
amount = basicRow[HistoryInvoiceBasicTable.amount].toPlainString(),
taxAmount = basicRow[HistoryInvoiceBasicTable.taxAmount].toPlainString(),
totalAmount = basicRow[HistoryInvoiceBasicTable.totalAmount].toPlainString(),
invoiceNo = basicRow[HistoryInvoiceBasicTable.invoiceNo],
invoiceCode = basicRow[HistoryInvoiceBasicTable.invoiceCode],
electronicInvoiceNo = basicRow[HistoryInvoiceBasicTable.electronicInvoiceNo],
issuedAt = formatDateTime(basicRow[HistoryInvoiceBasicTable.issuedAt]),
status = basicRow[HistoryInvoiceBasicTable.status],
errorMessage = basicRow[HistoryInvoiceBasicTable.errorMessage],
pdfUrl = basicRow[HistoryInvoiceBasicTable.pdfUrl],
ofdUrl = basicRow[HistoryInvoiceBasicTable.ofdUrl],
xmlUrl = basicRow[HistoryInvoiceBasicTable.xmlUrl],
tradeNo = basicRow[HistoryInvoiceBasicTable.tradeNo],
remark = basicRow[HistoryInvoiceBasicTable.remark],
definedData = basicRow[HistoryInvoiceBasicTable.definedData],
createdAt = formatDateTime(basicRow[HistoryInvoiceBasicTable.createdAt]) ?: "",
goodsList = goodsRows.map { row ->
InvoiceDetailGoods(
lineNo = row[HistoryInvoiceGoodsTable.lineNo],
goodsName = row[HistoryInvoiceGoodsTable.goodsName],
taxClassificationCode = row[HistoryInvoiceGoodsTable.taxClassificationCode],
specificationModel = row[HistoryInvoiceGoodsTable.specificationModel],
meteringUnit = row[HistoryInvoiceGoodsTable.meteringUnit],
quantity = row[HistoryInvoiceGoodsTable.quantity]?.toPlainString(),
unitPrice = row[HistoryInvoiceGoodsTable.unitPrice]?.toPlainString(),
invoiceAmount = row[HistoryInvoiceGoodsTable.invoiceAmount].toPlainString(),
taxRateValue = row[HistoryInvoiceGoodsTable.taxRateValue].toPlainString(),
taxRateAmount = row[HistoryInvoiceGoodsTable.taxRateAmount]?.toPlainString(),
includeTaxFlag = row[HistoryInvoiceGoodsTable.includeTaxFlag],
)
},
voucherList = voucherRows.map { row ->
InvoiceDetailVoucher(
proofType = row[HistoryInvoiceVoucherTable.proofType],
electronicInvoiceNo = row[HistoryInvoiceVoucherTable.electronicInvoiceNo],
invoiceCode = row[HistoryInvoiceVoucherTable.invoiceCode],
invoiceNo = row[HistoryInvoiceVoucherTable.invoiceNo],
proofNo = row[HistoryInvoiceVoucherTable.proofNo],
issueDate = row[HistoryInvoiceVoucherTable.issueDate],
proofAmount = row[HistoryInvoiceVoucherTable.proofAmount].toPlainString(),
deductionAmount = row[HistoryInvoiceVoucherTable.deductionAmount].toPlainString(),
proofRemark = row[HistoryInvoiceVoucherTable.proofRemark],
source = row[HistoryInvoiceVoucherTable.his_source],
)
},
orderList = orderRows.map { row ->
InvoiceDetailOrder(
orderNo = row[HistoryInvoiceOrderTable.orderNo],
)
},
)
} }
} }
@@ -5,8 +5,12 @@ import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
/**
* 发票基本信息表历史
* 存储发票开具时的基本信息快照
*/
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
object InvoiceOrderTable : Table("invoice_order") { object HistoryInvoiceBasicTable : Table("history_invoice_basic") {
val id = uuid("id").clientDefault { Uuid.Companion.random() } val id = uuid("id").clientDefault { Uuid.Companion.random() }
@@ -30,29 +34,23 @@ object InvoiceOrderTable : Table("invoice_order") {
* 发票种类 * 发票种类
* 81电子发票增值税专用发票 * 81电子发票增值税专用发票
* 82电子发票普通发票 * 82电子发票普通发票
* 87数电纸质发票机动车销售统一发票
* 10增值税电子普通发票
* 08增值税电子专用发票
* 04增值税普通发票
* 01增值税专用发票
*
*/ */
val invoiceKindCode = varchar("invoice_kind_code", 8) val invoiceKindCode = varchar("invoice_kind_code", 8)
/**
* 发票类型
* BLUE蓝票
* RED红票
*/
val invoiceType = varchar("invoice_type", 8).default("BLUE")
// ========================= // =========================
// 购买方信息(历史快照) // 购买方信息
// ========================= // =========================
/**
* 购买方名称
*/
val buyerName = varchar("buyer_name", 200) val buyerName = varchar("buyer_name", 200)
/** val buyerTaxpayerNum = varchar("buyer_taxpayer_num", 32).nullable()
* 购买方税号
*/
val buyerTaxpayerNum = varchar("buyer_taxpayer_num", 32)
.nullable()
val buyerAddress = varchar("buyer_address", 255).nullable() val buyerAddress = varchar("buyer_address", 255).nullable()
@@ -85,82 +83,36 @@ object InvoiceOrderTable : Table("invoice_order") {
// 开票结果 // 开票结果
// ========================= // =========================
/**
* 发票号码
*/
val invoiceNo = varchar("invoice_no", 64).nullable() val invoiceNo = varchar("invoice_no", 64).nullable()
/**
* 发票代码
*/
val invoiceCode = varchar("invoice_code", 64).nullable() val invoiceCode = varchar("invoice_code", 64).nullable()
/**
* 数电票号码
*/
val electronicInvoiceNo = varchar("electronic_invoice_no", 64).nullable() val electronicInvoiceNo = varchar("electronic_invoice_no", 64).nullable()
/**
* 开票时间
*/
val issuedAt = timestampWithTimeZone("issued_at").nullable() val issuedAt = timestampWithTimeZone("issued_at").nullable()
/** val status = varchar("status", 32).default("PENDING")
* 开票状态
*/
val status = varchar("status", 32) .default("PENDING")
/**
* 第三方平台返回错误
*/
val errorMessage = text("error_message").nullable() val errorMessage = text("error_message").nullable()
/**
* 原始请求报文
*/
val requestJson = text("request_json").nullable()
/**
* 原始响应报文
*/
val responseJson = text("response_json").nullable()
// ========================= // =========================
// 文件 // 文件
// ========================= // =========================
/**
* PDF地址
*/
val pdfUrl = text("pdf_url").nullable() val pdfUrl = text("pdf_url").nullable()
/**
* OFD地址
*/
val ofdUrl = text("ofd_url").nullable() val ofdUrl = text("ofd_url").nullable()
/**
* XML地址
*/
val xmlUrl = text("xml_url").nullable() val xmlUrl = text("xml_url").nullable()
// ========================= // =========================
// 业务字段 // 业务字段
// ========================= // =========================
/**
* 订单号
*/
val tradeNo = varchar("trade_no", 128).nullable() val tradeNo = varchar("trade_no", 128).nullable()
/**
* 备注
*/
val remark = text("remark").nullable() val remark = text("remark").nullable()
/**
* 自定义透传数据
*/
val definedData = text("defined_data").nullable() val definedData = text("defined_data").nullable()
// ========================= // =========================
@@ -0,0 +1,108 @@
package com.bbit.ticket.database.piaotong
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* 发票商品明细表(历史)
* 存储发票中的商品信息明细
*/
@OptIn(ExperimentalUuidApi::class)
object HistoryInvoiceGoodsTable : Table("history_invoice_goods") {
val id = uuid("id").clientDefault { Uuid.Companion.random() }
/**
* 关联历史发票基本信息ID
*/
val basicId = uuid("basic_id").references(HistoryInvoiceBasicTable.id)
/**
* 行号
*/
val lineNo = integer("line_no")
/**
* 商品名称
*/
val goodsName = varchar("goods_name", 200)
/**
* 税收分类编码
*/
val taxClassificationCode = varchar("tax_classification_code", 64)
/**
* 规格型号
*/
val specificationModel = varchar("specification_model", 100).nullable()
/**
* 单位
*/
val meteringUnit = varchar("metering_unit", 32).nullable()
/**
* 数量
*/
val quantity = decimal("quantity", 18, 8).nullable()
/**
* 单价
*/
val unitPrice = decimal("unit_price", 18, 8).nullable()
/**
* 金额
*/
val invoiceAmount = decimal("invoice_amount", 18, 2)
/**
* 税率
*/
val taxRateValue = decimal("tax_rate_value", 8, 4)
/**
* 税额
*/
val taxRateAmount = decimal("tax_rate_amount", 18, 2).nullable()
/**
* 是否含税
*/
val includeTaxFlag = bool("include_tax_flag").default(false)
/**
* 折扣金额
*/
val discountAmount = decimal("discount_amount", 18, 2).nullable()
/**
* 零税率标识
*/
val zeroTaxFlag = varchar("zero_tax_flag", 8).nullable()
/**
* 优惠政策标识
*/
val preferentialPolicyFlag = varchar("preferential_policy_flag", 8).nullable()
/**
* 增值税特殊管理
*/
val vatSpecialManage = varchar("vat_special_manage", 100).nullable()
/**
* 差额扣除金额
*/
val deductionAmount = decimal("deduction_amount", 18, 2).nullable()
/**
* 创建时间
*/
val createdAt = timestampWithTimeZone("created_at")
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,33 @@
package com.bbit.ticket.database.piaotong
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* 发票关联单据表(历史)
* 存储合并开票时关联的业务单据信息
*/
@OptIn(ExperimentalUuidApi::class)
object HistoryInvoiceOrderTable : Table("history_invoice_order") {
val id = uuid("id").clientDefault { Uuid.Companion.random() }
/**
* 关联历史发票基本信息ID
*/
val basicId = uuid("basic_id").references(HistoryInvoiceBasicTable.id)
/**
* 业务单据号
*/
val orderNo = varchar("order_no", 128)
/**
* 创建时间
*/
val createdAt = timestampWithTimeZone("created_at")
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,81 @@
package com.bbit.ticket.database.piaotong
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* 差额征税凭证明细表(历史)
* 存储差额征税时的凭证明细信息
*/
@OptIn(ExperimentalUuidApi::class)
object HistoryInvoiceVoucherTable : Table("history_invoice_voucher") {
val id = uuid("id").clientDefault { Uuid.Companion.random() }
/**
* 关联历史发票基本信息ID
*/
val basicId = uuid("basic_id").references(HistoryInvoiceBasicTable.id)
/**
* 凭证类型
* 01:数电票;02:增值税专用发票;03:增值税普通发票
* 04:营业税发票;05:财政票据;06:法院裁决书
* 07:契税完税凭证;08:其他发票类;09:其他扣除凭证
*/
val proofType = varchar("proof_type", 8)
/**
* 数电票号码(凭证类型为01时必填)
*/
val electronicInvoiceNo = varchar("electronic_invoice_no", 64).nullable()
/**
* 发票代码(凭证类型02/03/04时必填)
*/
val invoiceCode = varchar("invoice_code", 64).nullable()
/**
* 发票号码(凭证类型02/03/04时必填)
*/
val invoiceNo = varchar("invoice_no", 64).nullable()
/**
* 凭证号码
*/
val proofNo = varchar("proof_no", 64).nullable()
/**
* 开具日期(格式yyyy-MM-dd
*/
val issueDate = varchar("issue_date", 16).nullable()
/**
* 凭证合计金额
*/
val proofAmount = decimal("proof_amount", 18, 2)
/**
* 本次扣除金额
*/
val deductionAmount = decimal("deduction_amount", 18, 2)
/**
* 备注
*/
val proofRemark = text("proof_remark").nullable()
/**
* 来源
*/
val his_source = varchar("source", 32).nullable()
/**
* 创建时间
*/
val createdAt = timestampWithTimeZone("created_at")
override val primaryKey = PrimaryKey(id)
}
@@ -1,5 +1,6 @@
package com.bbit.ticket.database.piaotong package com.bbit.ticket.database.piaotong
import com.bbit.ticket.database.piaotong.HistoryInvoiceBasicTable
import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
@@ -13,7 +14,7 @@ object InvoiceItemTable : Table("invoice_item") {
/** /**
* 发票ID * 发票ID
*/ */
val invoiceId = uuid("invoice_id").references(InvoiceOrderTable.id) val invoiceId = uuid("invoice_id").references(HistoryInvoiceBasicTable.id)
/** /**
* 行号 * 行号
@@ -1,13 +1,12 @@
package com.bbit.ticket.entity.request package com.bbit.ticket.entity.request
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* 数电发票开票请求 * 数电发票开票请求
*/ */
@Serializable @Serializable
data class InvoiceRequest( data class AskInvoiceRequest(
/** /**
* 销方纳税人识别号销售方税号 * 销方纳税人识别号销售方税号
@@ -0,0 +1,24 @@
package com.bbit.ticket.entity.request
import kotlinx.serialization.Serializable
/**
* 发票申请请求
*/
@Serializable
data class QueryInvoiceRequest(
/**
* 纳税人识别号
*
* 一般为企业税号、统一社会信用代码
*/
val taxpayerNum: String,
/**
* 发票申请流水号
*
* 用于标识一次唯一的开票申请请求
*/
val invoiceReqSerialNo: String
)
@@ -0,0 +1,104 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.entity.response
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
/**
* 发票完整详情(含商品明细、差额征税凭证、关联单据)
*/
@Serializable
data class InvoiceDetailResponse(
// ===== 基本信息 =====
val id: Uuid,
val invoiceReqSerialNo: String,
val taxpayerNum: String,
val invoiceKindCode: String,
val invoiceType: String,
// ===== 购买方信息 =====
val buyerName: String,
val buyerTaxpayerNum: String? = null,
val buyerAddress: String? = null,
val buyerTel: String? = null,
val buyerBankName: String? = null,
val buyerBankAccount: String? = null,
// ===== 金额 =====
val amount: String,
val taxAmount: String,
val totalAmount: String,
// ===== 开票结果 =====
val invoiceNo: String? = null,
val invoiceCode: String? = null,
val electronicInvoiceNo: String? = null,
val issuedAt: String? = null,
val status: String,
val errorMessage: String? = null,
// ===== 文件 =====
val pdfUrl: String? = null,
val ofdUrl: String? = null,
val xmlUrl: String? = null,
// ===== 业务字段 =====
val tradeNo: String? = null,
val remark: String? = null,
val definedData: String? = null,
// ===== 审计 =====
val createdAt: String,
// ===== 子表数据 =====
/** 商品明细 */
val goodsList: List<InvoiceDetailGoods>,
/** 差额征税凭证明细 */
val voucherList: List<InvoiceDetailVoucher>,
/** 关联单据 */
val orderList: List<InvoiceDetailOrder>,
)
/**
* 发票商品明细(详情)
*/
@Serializable
data class InvoiceDetailGoods(
val lineNo: Int,
val goodsName: String,
val taxClassificationCode: String,
val specificationModel: String? = null,
val meteringUnit: String? = null,
val quantity: String? = null,
val unitPrice: String? = null,
val invoiceAmount: String,
val taxRateValue: String,
val taxRateAmount: String? = null,
val includeTaxFlag: Boolean = false,
)
/**
* 差额征税凭证明细(详情)
*/
@Serializable
data class InvoiceDetailVoucher(
val proofType: String,
val electronicInvoiceNo: String? = null,
val invoiceCode: String? = null,
val invoiceNo: String? = null,
val proofNo: String? = null,
val issueDate: String? = null,
val proofAmount: String,
val deductionAmount: String,
val proofRemark: String? = null,
val source: String? = null,
)
/**
* 关联单据(详情)
*/
@Serializable
data class InvoiceDetailOrder(
val orderNo: String,
)
@@ -0,0 +1,77 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package com.bbit.ticket.entity.response
import kotlinx.serialization.Serializable
import kotlin.uuid.Uuid
/**
* 发票历史记录(列表页展示项)
*/
@Serializable
data class InvoiceHistoryItem(
val id: Uuid,
/** 发票请求流水号 */
val invoiceReqSerialNo: String,
/** 销方税号 */
val taxpayerNum: String,
/** 发票种类 */
val invoiceKindCode: String,
// ===== 购买方信息 =====
/** 购买方名称 */
val buyerName: String,
/** 购买方税号 */
val buyerTaxpayerNum: String? = null,
/** 购买方地址 */
val buyerAddress: String? = null,
/** 购买方电话 */
val buyerTel: String? = null,
/** 购买方开户银行 */
val buyerBankName: String? = null,
/** 购买方银行账号 */
val buyerBankAccount: String? = null,
// ===== 金额 =====
/** 不含税金额 */
val amount: String,
/** 税额 */
val taxAmount: String,
/** 含税总金额 */
val totalAmount: String,
// ===== 开票结果 =====
/** 发票号码 */
val invoiceNo: String? = null,
/** 发票代码 */
val invoiceCode: String? = null,
/** 数电票号码 */
val electronicInvoiceNo: String? = null,
/** 开票时间 */
val issuedAt: String? = null,
/** 开票状态 */
val status: String,
// ===== 文件 =====
/** PDF 地址 */
val pdfUrl: String? = null,
/** OFD 地址 */
val ofdUrl: String? = null,
/** XML 地址 */
val xmlUrl: String? = null,
// ===== 业务字段 =====
/** 订单号 */
val tradeNo: String? = null,
/** 备注 */
val remark: String? = null,
/** 自定义透传数据 */
val definedData: String? = null,
// ===== 审计字段 =====
/** 创建时间 */
val createdAt: String,
/** 错误信息 */
val errorMessage: String? = null,
)
@@ -0,0 +1,227 @@
package com.bbit.ticket.entity.response
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* 发票开具结果响应
*/
@Serializable
data class QueryInvoiceResponse(
/**
* 销售方纳税人识别号
*/
@SerialName("taxpayerNum")
val taxpayerNum: String,
/**
* 发票请求流水号
* 用于关联本次开票请求
*/
@SerialName("invoiceReqSerialNo")
val invoiceReqSerialNo: String,
/**
* 开票类型
*
* 1: 蓝票
* 2: 红票
*/
@SerialName("invoiceType")
val invoiceType: String? = null,
/**
* 发票种类代码
*
* 81: 数电票(增值税专用发票)
* 82: 数电票(普通发票)
* 83: 数电票(机动车销售统一发票)
* 10: 增值税电子普通发票
* 08: 增值税电子专用发票
*/
@SerialName("invoiceKind")
val invoiceKind: String,
/**
* 发票状态码
*
* 0000: 开票成功
* 6666: 未开票
* 7777: 开票中
* 9999: 开票失败
* 3999: 开票失败,需要扫码或短信认证
* 4999: 红字发票确认单申请中
* 5999: 红字发票确认单审核中
*/
@SerialName("code")
val code: String,
/**
* 发票状态描述
* 一般为成功提示或失败原因
*/
@SerialName("msg")
val msg: String,
/**
* 数电票开票通道
*
* 0: RPA电子税局开具
* 1: 乐企自用
* 4: 乐企联用(腾讯)
* 5: 乐企联用(支付宝)
*/
@SerialName("invIssueChannel")
val invIssueChannel: String? = null,
/**
* 开票人税局账号
* 一般为手机号或身份证号
*/
@SerialName("account")
val account: String? = null,
/**
* 实名认证二维码图片
* Base64字符串
*/
@SerialName("authenticationQrcode")
val authenticationQrcode: String? = null,
/**
* 实名认证二维码认证ID
*/
@SerialName("authId")
val authId: String? = null,
/**
* 订单号
* 开票成功时通常会返回
*/
@SerialName("tradeNo")
val tradeNo: String? = null,
/**
* 自定义透传数据
* 通常用于业务侧关联
*/
@SerialName("definedData")
val definedData: String? = null,
/**
* 发票二维码内容
*/
@SerialName("qrCode")
val qrCode: String? = null,
/**
* 发票代码
*/
@SerialName("invoiceCode")
val invoiceCode: String? = null,
/**
* 发票号码
*/
@SerialName("invoiceNo")
val invoiceNo: String? = null,
/**
* 数电发票号码
* 通常 = 发票代码 + 发票号码
*/
@SerialName("electronicInvoiceNo")
val electronicInvoiceNo: String? = null,
/**
* 开票日期
* 格式: yyyy-MM-dd HH:mm:ss
*/
@SerialName("invoiceDate")
val invoiceDate: String? = null,
/**
* 不含税金额
* 单位:元
* 保留两位小数
*/
@SerialName("noTaxAmount")
val noTaxAmount: String? = null,
/**
* 税额
* 单位:元
* 保留两位小数
*/
@SerialName("taxAmount")
val taxAmount: String? = null,
/**
* 发票版式文件类型
*
* pdf: PDF文件
* ofd: OFD文件
*/
@SerialName("invoiceLayoutFileType")
val invoiceLayoutFileType: String? = null,
/**
* 发票版式文件
* Base64编码后的文件流
*/
@SerialName("invoicePdf")
val invoicePdf: String? = null,
/**
* 发票XML文件
* Base64字符串
*/
@SerialName("invoiceXml")
val invoiceXml: String? = null,
/**
* 发票下载地址
*/
@SerialName("downloadUrl")
val downloadUrl: String? = null,
/**
* 收费明细PDF文件
* 常用于门诊/住院发票
* Base64字符串
*/
@SerialName("chargeDetailPdf")
val chargeDetailPdf: String? = null,
/**
* 收费明细PDF下载地址
*/
@SerialName("chargeDetailDownloadUrl")
val chargeDetailDownloadUrl: String? = null,
/**
* 电子发票预览二维码URL
* 用于扫码查看电子发票
*/
@SerialName("invPreviewQrcodePath")
val invPreviewQrcodePath: String? = null,
/**
* 电子发票预览二维码图片
* Base64字符串
*/
@SerialName("invPreviewQrcode")
val invPreviewQrcode: String? = null,
/**
* 发票删除标志
*
* 0: 未删除
* 1: 已删除
*/
@SerialName("invDeletedFlag")
val invDeletedFlag: String? = null,
)
@@ -0,0 +1,14 @@
package com.bbit.ticket.entity.response
import kotlinx.serialization.Serializable
/**
* 查询并刷新发票状态的结果
*/
@Serializable
data class QueryInvoiceResult(
/** 发票请求流水号 */
val invoiceReqSerialNo: String,
/** 刷新后的状态 */
val status: String,
)
@@ -0,0 +1,11 @@
package com.bbit.ticket.plugins
import kotlinx.serialization.json.Json
val myJson = Json {
explicitNulls = false
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
}
@@ -2,13 +2,11 @@
package com.bbit.ticket.route.piaotong package com.bbit.ticket.route.piaotong
import com.bbit.ticket.bootstrap.Global
import com.bbit.ticket.entity.common.PTException import com.bbit.ticket.entity.common.PTException
import com.bbit.ticket.entity.common.fail import com.bbit.ticket.entity.common.fail
import com.bbit.ticket.entity.common.ok import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.entity.common.BizException import com.bbit.ticket.entity.request.AskInvoiceRequest
import com.bbit.ticket.entity.common.ErrorCode import com.bbit.ticket.entity.request.QueryInvoiceRequest
import com.bbit.ticket.entity.request.InvoiceRequest
import com.bbit.ticket.entity.request.TaxBureauAuthReq import com.bbit.ticket.entity.request.TaxBureauAuthReq
import com.bbit.ticket.entity.request.TaxRegisterInfo import com.bbit.ticket.entity.request.TaxRegisterInfo
import com.bbit.ticket.entity.request.TaxRegisterUserRequest import com.bbit.ticket.entity.request.TaxRegisterUserRequest
@@ -18,7 +16,6 @@ import com.bbit.ticket.entity.request.UpdatePresetDataRequest
import com.bbit.ticket.service.piaotong.PTAuthService import com.bbit.ticket.service.piaotong.PTAuthService
import com.bbit.ticket.service.piaotong.PTConfigService import com.bbit.ticket.service.piaotong.PTConfigService
import com.bbit.ticket.utils.requireCurrentUser import com.bbit.ticket.utils.requireCurrentUser
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive import io.ktor.server.request.receive
import io.ktor.server.response.respond import io.ktor.server.response.respond
@@ -168,7 +165,7 @@ fun Route.registerPTTestRoutes() {
post("/invoiceBlue") { post("/invoiceBlue") {
try { try {
val currentUser = call.requireCurrentUser() val currentUser = call.requireCurrentUser()
val req = call.receive<InvoiceRequest>() val req = call.receive<AskInvoiceRequest>()
val response = PTAuthService.invoiceBlue(req, currentUser.id) val response = PTAuthService.invoiceBlue(req, currentUser.id)
call.respond(ok(response)) call.respond(ok(response))
} catch (e: PTException) { } catch (e: PTException) {
@@ -181,6 +178,59 @@ fun Route.registerPTTestRoutes() {
) )
} }
} }
get("/invoiceBlueHistory") {
try {
val currentUser = call.requireCurrentUser()
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
val pageSize = call.request.queryParameters["pageSize"]?.toIntOrNull() ?: 20
val response = PTAuthService.getInvoiceBlueHistory(currentUser.id, page, pageSize)
call.respond(ok(response))
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "查询开票历史失败"))
}
}
get("/invoiceDetail") {
try {
val currentUser = call.requireCurrentUser()
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"]
if (invoiceReqSerialNo.isNullOrBlank()) {
call.respond(fail(code = "-1", message = "请传入发票请求流水号"))
return@get
}
val response = PTAuthService.getInvoiceDetail(currentUser.id, invoiceReqSerialNo)
if (response == null) {
call.respond(fail(code = "-1", message = "未找到该发票记录"))
return@get
}
call.respond(ok(response))
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "查询发票详情失败"))
}
}
get("/queryInvoice"){
try {
val currentUser = call.requireCurrentUser()
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"]
if (invoiceReqSerialNo.isNullOrBlank()) {
call.respond(fail(code = "-1", message = "请传入发票请求流水号"))
return@get
}
val response = PTAuthService.queryInvoice(QueryInvoiceRequest(
taxpayerNum = currentUser.taxPayerNum ?: "",
invoiceReqSerialNo = invoiceReqSerialNo
))
call.respond(ok(response))
} catch (e: PTException) {
call.respond(
fail(
code = e.code,
message = e.message,
traceId = e.serialNo
)
)
}
}
} }
} }
} }
@@ -75,7 +75,7 @@ fun Route.registerDictRoutes() {
} }
} }
} }
route("/api/system/dict-items") { route("/system/dict-items") {
get { get {
call.requirePermission("system:dict:view") call.requirePermission("system:dict:view")
val page = call.queryInt("page", 1) val page = call.queryInt("page", 1)
@@ -3,18 +3,25 @@
package com.bbit.ticket.service.piaotong package com.bbit.ticket.service.piaotong
import com.bbit.ticket.dao.piaotong.EnterpriseTaxDao import com.bbit.ticket.dao.piaotong.EnterpriseTaxDao
import com.bbit.ticket.entity.request.InvoiceRequest import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.request.AskInvoiceRequest
import com.bbit.ticket.entity.request.QueryInvoiceRequest
import com.bbit.ticket.entity.request.TaxBureauAuthReq import com.bbit.ticket.entity.request.TaxBureauAuthReq
import com.bbit.ticket.entity.request.TaxRegister import com.bbit.ticket.entity.request.TaxRegister
import com.bbit.ticket.entity.request.TaxRegisterInfo import com.bbit.ticket.entity.request.TaxRegisterInfo
import com.bbit.ticket.entity.request.TaxRegisterUserRequest import com.bbit.ticket.entity.request.TaxRegisterUserRequest
import com.bbit.ticket.entity.response.QueryInvoiceResult
import com.bbit.ticket.entity.response.EnterpriseTaxInfo import com.bbit.ticket.entity.response.EnterpriseTaxInfo
import com.bbit.ticket.entity.response.EtaxRegisterResponse import com.bbit.ticket.entity.response.EtaxRegisterResponse
import com.bbit.ticket.entity.response.InvoiceCreateResponse import com.bbit.ticket.entity.response.InvoiceCreateResponse
import com.bbit.ticket.entity.response.InvoiceDetailResponse
import com.bbit.ticket.entity.response.InvoiceHistoryItem
import com.bbit.ticket.entity.response.QueryInvoiceResponse
import com.bbit.ticket.entity.response.TaxBureauAccountAuthContent import com.bbit.ticket.entity.response.TaxBureauAccountAuthContent
import com.bbit.ticket.plugins.dbQuery import com.bbit.ticket.plugins.dbQuery
import com.bbit.ticket.utils.CurrentUser import com.bbit.ticket.utils.CurrentUser
import com.bbit.ticket.utils.net.PTClient import com.bbit.ticket.utils.net.PTClient
import io.ktor.server.util.url
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
object PTAuthService { object PTAuthService {
@@ -62,28 +69,73 @@ object PTAuthService {
body = req body = req
) )
dbQuery { EnterpriseTaxDao.updateEnterpriseInfo(userId, req) } dbQuery { EnterpriseTaxDao.updateEnterpriseInfo(userId, req) }
return "操作成功,企业状态为审核中(待审核)" return "操作成功"
} }
/** /**
* 蓝票接口调用 * 蓝票接口调用
*/ */
suspend fun invoiceBlue(req: InvoiceRequest, userId: Uuid): String { suspend fun invoiceBlue(req: AskInvoiceRequest, userId: Uuid): String {
PTClient.ptPost<InvoiceRequest, InvoiceCreateResponse>( PTClient.ptPost<AskInvoiceRequest, InvoiceCreateResponse>(
url = "invoiceBlue.pt", url = "invoiceBlue.pt",
body = req body = req
) )
dbQuery { EnterpriseTaxDao.addInvoice(userId, req) } dbQuery { EnterpriseTaxDao.addInvoice(userId, req) }
return "操作成功,企业状态为审核中(待审核)" return "操作成功"
} }
/** /**
* 红票接口调用 * 红票接口调用
*/ */
suspend fun invoiceRed(req: InvoiceRequest, userId: Uuid): String { suspend fun invoiceRed(req: AskInvoiceRequest, userId: Uuid): String {
PTClient.ptPost<InvoiceRequest, InvoiceCreateResponse>( PTClient.ptPost<AskInvoiceRequest, InvoiceCreateResponse>(
url = "invoiceBlue.pt", url = "invoiceBlue.pt",
body = req body = req
) )
dbQuery { EnterpriseTaxDao.addInvoice(userId, req) } return "操作成功"
return "操作成功,企业状态为审核中(待审核)" }
/**
* 分页查询蓝票开票历史
*/
suspend fun getInvoiceBlueHistory(userId: Uuid, page: Int, pageSize: Int): PageResult<InvoiceHistoryItem> =
dbQuery { EnterpriseTaxDao.invoiceHistory(userId, page, pageSize) }
/**
* 查询发票完整详情
*/
suspend fun getInvoiceDetail(userId: Uuid, invoiceReqSerialNo: String): InvoiceDetailResponse? =
dbQuery { EnterpriseTaxDao.invoiceDetail(userId, invoiceReqSerialNo) }
/**
* 查询并更新发票状态
* 调用票通平台查询实时状态,将结果更新到本地数据库
*/
suspend fun queryInvoice(req: QueryInvoiceRequest): QueryInvoiceResult {
val res = PTClient.ptPost<QueryInvoiceRequest, QueryInvoiceResponse>(
url = "queryInvoice.pt",
body = req
)
dbQuery {
EnterpriseTaxDao.updateInvoiceStatus(
invoiceReqSerialNo = req.invoiceReqSerialNo,
code = res.code,
msg = res.msg,
invoiceNo = res.invoiceNo,
invoiceCode = res.invoiceCode,
electronicInvoiceNo = res.electronicInvoiceNo,
invoiceDate = res.invoiceDate,
tradeNo = res.tradeNo,
)
}
val newStatus = when (res.code) {
"0000" -> "SUCCESS"
"7777" -> "PROCESSING"
"9999" -> "FAILED"
"6666" -> "PENDING"
else -> "PROCESSING"
}
return QueryInvoiceResult(
invoiceReqSerialNo = req.invoiceReqSerialNo,
status = newStatus
)
} }
} }
@@ -3,6 +3,7 @@ package com.bbit.ticket.utils.net
import com.bbit.ticket.bootstrap.Global import com.bbit.ticket.bootstrap.Global
import com.bbit.ticket.entity.common.PTException import com.bbit.ticket.entity.common.PTException
import com.bbit.ticket.entity.response.PTResponse import com.bbit.ticket.entity.response.PTResponse
import com.bbit.ticket.plugins.myJson
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
@@ -24,14 +25,7 @@ object PTClient {
val client = HttpClient(CIO) { val client = HttpClient(CIO) {
install(ContentNegotiation) { install(ContentNegotiation) {
json( json(myJson)
Json {
explicitNulls = false
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
}
)
} }
} }
@@ -48,8 +42,8 @@ object PTClient {
url { url {
// ⚠️ 把 Req 转成 JSON 再拆成 query(统一协议口径) // ⚠️ 把 Req 转成 JSON 再拆成 query(统一协议口径)
val json = Json.encodeToString(queryParams) val json = myJson.encodeToString(queryParams)
val element = Json.parseToJsonElement(json).jsonObject val element = myJson.parseToJsonElement(json).jsonObject
element.forEach { (k, v) -> element.forEach { (k, v) ->
parameters.append(k, v.toString().trim('"')) parameters.append(k, v.toString().trim('"'))
@@ -63,7 +57,7 @@ object PTClient {
val decrypted = disposeResponse(response) val decrypted = disposeResponse(response)
val result = Json.decodeFromString<PTResponse<JsonElement>>(decrypted) val result = myJson.decodeFromString<PTResponse<JsonElement>>(decrypted)
if (result.code != "0000") { if (result.code != "0000") {
throw PTException( throw PTException(
@@ -73,7 +67,7 @@ object PTClient {
) )
} }
return Json.decodeFromJsonElement(result.content!!) return myJson.decodeFromJsonElement(result.content!!)
} }
suspend inline fun <reified Req, reified Resp> ptPost( suspend inline fun <reified Req, reified Resp> ptPost(
@@ -85,11 +79,11 @@ object PTClient {
val response = client.post(Global.baseUrl + url) { val response = client.post(Global.baseUrl + url) {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
headers.forEach { (k, v) -> header(k, v) } headers.forEach { (k, v) -> header(k, v) }
setBody(buildRequestData(Json.encodeToString(body))) setBody(buildRequestData(myJson.encodeToString(body)))
}.bodyAsText() }.bodyAsText()
val decrypted = disposeResponse(response) val decrypted = disposeResponse(response)
val result = Json.decodeFromString<PTResponse<JsonElement>>(decrypted) val result = myJson.decodeFromString<PTResponse<JsonElement>>(decrypted)
if (result.code != "0000") { if (result.code != "0000") {
throw PTException( throw PTException(
code = result.code, code = result.code,
@@ -99,7 +93,7 @@ object PTClient {
} }
println("res = $result.content") println("res = $result.content")
return Json.decodeFromJsonElement<Resp>(result.content!!) return myJson.decodeFromJsonElement<Resp>(result.content!!)
} }
/** /**
@@ -123,14 +117,14 @@ object PTClient {
map["timestamp"] = sdf.format(Date()) map["timestamp"] = sdf.format(Date())
map["serialNo"] = ptDate() map["serialNo"] = ptDate()
map["sign"] = RSAUtil.sign(RSAUtil.getSignatureContent(map), Global.ptPrivateKey) ?: "" map["sign"] = RSAUtil.sign(RSAUtil.getSignatureContent(map), Global.ptPrivateKey) ?: ""
return Json.encodeToString(map) return myJson.encodeToString(map)
} }
fun disposeResponse( fun disposeResponse(
jsonStr: String, jsonStr: String,
): String { ): String {
val json = Json.parseToJsonElement(jsonStr).jsonObject val json = myJson.parseToJsonElement(jsonStr).jsonObject
// 1. 转 Map(用于签名验证) // 1. 转 Map(用于签名验证)
val mutableMap = json val mutableMap = json
.toMutableMap() .toMutableMap()
@@ -161,7 +155,7 @@ object PTClient {
?: "{}" ?: "{}"
val contentElement = runCatching { val contentElement = runCatching {
Json.parseToJsonElement(plainContent) myJson.parseToJsonElement(plainContent)
}.getOrElse { }.getOrElse {
JsonObject(emptyMap()) JsonObject(emptyMap())
} }
+190
View File
@@ -335,3 +335,193 @@ export interface InvoiceRequest {
export function invoiceIssueApi(payload: InvoiceRequest): Promise<string> { export function invoiceIssueApi(payload: InvoiceRequest): Promise<string> {
return http.post('/pt/invoiceBlue', payload) 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> <template>
<div class="page"> <div class="page">
<div class="placeholder"> <div class="page-header">
<h2>开票历史</h2> <h2 class="page-title">开票历史</h2>
<p>功能开发中敬请期待</p>
</div> </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> </div>
</template> </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> <style scoped>
.page { .page {
min-height: 100%; min-height: 100%;
background: #f7f8fa; background: #f7f8fa;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center; gap: 16px;
} }
.placeholder { .page-header {
text-align: center; flex-shrink: 0;
color: #bbb;
} }
.placeholder h2 { .page-title {
margin: 0 0 8px; margin: 0;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #999; color: #333;
} }
.placeholder p { .search-bar {
margin: 0; 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-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
padding-left: 8px;
border-left: 3px solid #409eff;
} }
</style> </style>
@@ -448,13 +448,13 @@
</n-form-item> </n-form-item>
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-form-item label="金额 *" path="invoiceAmount"> <n-form-item label="金额是否含税 *" path="includeTaxFlag">
<n-input v-model:value="currentItem.invoiceAmount" :placeholder="amountPlaceholder" clearable /> <n-select v-model:value="currentItem.includeTaxFlag" :options="includeTaxFlagOptions" placeholder="选择含税标示" />
</n-form-item> </n-form-item>
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-form-item label="含税标示 *" path="includeTaxFlag"> <n-form-item label="金额 *" path="invoiceAmount">
<n-select v-model:value="currentItem.includeTaxFlag" :options="includeTaxFlagOptions" placeholder="选择含税标示" /> <n-input v-model:value="currentItem.invoiceAmount" :placeholder="amountPlaceholder" clearable />
</n-form-item> </n-form-item>
</n-gi> </n-gi>
<n-gi> <n-gi>
@@ -538,7 +538,6 @@
</n-form> </n-form>
</section> </section>
</main> </main>
</div>
<!-- ===== 新增单据号弹窗 ===== --> <!-- ===== 新增单据号弹窗 ===== -->
<n-modal v-model:show="showOrderDialog" preset="card" title="新增单据号" style="width:420px" :mask-closable="false"> <n-modal v-model:show="showOrderDialog" preset="card" title="新增单据号" style="width:420px" :mask-closable="false">
@@ -550,6 +549,7 @@
</div> </div>
</template> </template>
</n-modal> </n-modal>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -557,7 +557,6 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Plus, RefreshCw } from 'lucide-vue-next' import { Plus, RefreshCw } from 'lucide-vue-next'
import { import {
NButton, NButton,
NCard,
NDatePicker, NDatePicker,
NForm, NForm,
NFormItem, NFormItem,
@@ -1111,6 +1110,10 @@ watch(() => form.specialInvoiceKind, (newVal, oldVal) => {
if (!newVal || newVal === oldVal) return if (!newVal || newVal === oldVal) return
if (newVal === '02') { if (newVal === '02') {
if (form.invoiceIssueKindCode === '82') {
// 已经是数电普票,直接弹交换信息弹窗
showSwapSellerBuyerDialog()
} else {
dialog.warning({ dialog.warning({
title: '提示', title: '提示',
content: '农产品收购发票的开票种类只能是数电普票(82),是否将开票种类修改为"数电普票(82"', content: '农产品收购发票的开票种类只能是数电普票(82),是否将开票种类修改为"数电普票(82"',
@@ -1124,6 +1127,7 @@ watch(() => form.specialInvoiceKind, (newVal, oldVal) => {
form.specialInvoiceKind = oldVal form.specialInvoiceKind = oldVal
} }
}) })
}
} else if (newVal === '12') { } else if (newVal === '12') {
showSwapSellerBuyerDialog() showSwapSellerBuyerDialog()
} }
@@ -1237,7 +1241,7 @@ watch(() => form.itemList, (items) => {
flex-shrink: 0; flex-shrink: 0;
background: #fff; background: #fff;
border-bottom: 1px solid #e8e8e8; border-bottom: 1px solid #e8e8e8;
padding: 12px 24px; padding: 6px 24px;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);